@rxova/brand 0.1.2 → 0.3.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 +8 -5
- package/src/components/ThemeSelect.astro +57 -0
- package/src/env.d.ts +14 -0
- package/src/index.ts +4 -0
- package/src/sites.test.ts +126 -0
- package/src/sites.ts +40 -0
- package/src/starlight.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rxova/brand",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Design tokens, Starlight theme and shared site chrome for rxova.org",
|
|
6
6
|
"keywords": [
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
},
|
|
21
21
|
"bugs": "https://github.com/rxova/brand/issues",
|
|
22
22
|
"engines": {
|
|
23
|
-
"node": ">=
|
|
23
|
+
"node": ">=24.0.0"
|
|
24
24
|
},
|
|
25
25
|
"sideEffects": [
|
|
26
26
|
"*.css"
|
|
@@ -58,14 +58,17 @@
|
|
|
58
58
|
"@resvg/resvg-js": "^2.6.2",
|
|
59
59
|
"@types/node": "^26.1.1",
|
|
60
60
|
"astro": "^7.1.3",
|
|
61
|
+
"publint": "^0.3.21",
|
|
61
62
|
"satori": "^0.29.0",
|
|
63
|
+
"tsx": "^4.23.1",
|
|
62
64
|
"typescript": "6.0.3"
|
|
63
65
|
},
|
|
64
66
|
"scripts": {
|
|
65
67
|
"typecheck": "tsc --noEmit",
|
|
66
68
|
"check:astro": "astro check",
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
69
|
+
"pack:smoke": "node --import tsx scripts/pack-smoke.ts",
|
|
70
|
+
"check:exports": "publint --strict",
|
|
71
|
+
"og": "node --import tsx scripts/generate-og.ts",
|
|
72
|
+
"check:og": "node --import tsx scripts/generate-og.ts --check"
|
|
70
73
|
}
|
|
71
74
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Starlight's theme picker, plus a resync when the page comes out of the
|
|
4
|
+
* back/forward cache.
|
|
5
|
+
*
|
|
6
|
+
* Starlight applies the stored theme in two places, both of which run exactly
|
|
7
|
+
* once, while the document is being parsed: the inline script in
|
|
8
|
+
* `ThemeProvider.astro`, and the `<starlight-theme-select>` constructor. A
|
|
9
|
+
* back/forward navigation that hits the bfcache *restores* the document rather
|
|
10
|
+
* than parsing it, so neither runs again and the page keeps whatever
|
|
11
|
+
* `data-theme` it carried when the visitor left.
|
|
12
|
+
*
|
|
13
|
+
* All of rxova.org is one origin sharing one `starlight-theme` key, so that is
|
|
14
|
+
* a visible jump rather than a curiosity: pick a theme on the landing page or
|
|
15
|
+
* on another project's docs, press Back, and the restored page is still on the
|
|
16
|
+
* old one.
|
|
17
|
+
*
|
|
18
|
+
* This wraps rather than replaces Starlight's component — the picker, its
|
|
19
|
+
* labels and its own logic are untouched, and a Starlight release that changes
|
|
20
|
+
* them changes them here too.
|
|
21
|
+
*/
|
|
22
|
+
import Default from '@astrojs/starlight/components/ThemeSelect.astro'
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
<Default><slot /></Default>
|
|
26
|
+
|
|
27
|
+
<script>
|
|
28
|
+
type Theme = 'auto' | 'dark' | 'light'
|
|
29
|
+
|
|
30
|
+
/** Same key and coercion Starlight uses; unknown values mean "follow the OS". */
|
|
31
|
+
const STORAGE_KEY = 'starlight-theme'
|
|
32
|
+
const parseTheme = (theme: unknown): Theme =>
|
|
33
|
+
theme === 'auto' || theme === 'dark' || theme === 'light' ? theme : 'auto'
|
|
34
|
+
const preferredColorScheme = (): Theme =>
|
|
35
|
+
matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
|
|
36
|
+
|
|
37
|
+
// `pageshow` is the only notification of a bfcache restore. A normal load
|
|
38
|
+
// also fires it with `persisted: false`, which we ignore — Starlight has
|
|
39
|
+
// already done the work in that case.
|
|
40
|
+
window.addEventListener('pageshow', (event) => {
|
|
41
|
+
if (!event.persisted) return
|
|
42
|
+
|
|
43
|
+
let stored: string | null
|
|
44
|
+
try {
|
|
45
|
+
stored = localStorage.getItem(STORAGE_KEY)
|
|
46
|
+
} catch {
|
|
47
|
+
// Storage can throw outright (Safari private mode, blocked cookies).
|
|
48
|
+
// Leaving the restored theme alone is the right fallback.
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const theme = parseTheme(stored)
|
|
53
|
+
document.documentElement.dataset.theme = theme === 'auto' ? preferredColorScheme() : theme
|
|
54
|
+
// Keeps the picker from disagreeing with the page it controls.
|
|
55
|
+
StarlightThemeProvider.updatePickers(theme)
|
|
56
|
+
})
|
|
57
|
+
</script>
|
package/src/env.d.ts
CHANGED
|
@@ -5,3 +5,17 @@
|
|
|
5
5
|
// Starlight's own `.ts` sources, which import `virtual:starlight/*` modules
|
|
6
6
|
// that only exist inside an Astro build — so plain `tsc` fails on them.
|
|
7
7
|
// The one component that reads route data narrows it structurally instead.
|
|
8
|
+
|
|
9
|
+
// Same reasoning for this one. It mirrors Starlight's own `global.d.ts`, which
|
|
10
|
+
// we cannot `/// <reference>` because the package's types entry is `index.ts`
|
|
11
|
+
// and drags in those same virtual modules. The global is defined at runtime by
|
|
12
|
+
// the inline script in Starlight's `ThemeProvider.astro`; ThemeSelect.astro
|
|
13
|
+
// calls it. Re-check this against Starlight's `global.d.ts` on a major bump.
|
|
14
|
+
declare global {
|
|
15
|
+
interface StarlightThemeProvider {
|
|
16
|
+
updatePickers(theme?: string): void
|
|
17
|
+
}
|
|
18
|
+
var StarlightThemeProvider: StarlightThemeProvider
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export {}
|
package/src/index.ts
CHANGED
|
@@ -9,11 +9,15 @@
|
|
|
9
9
|
export {
|
|
10
10
|
RXOVA_ORIGIN,
|
|
11
11
|
PROJECTS,
|
|
12
|
+
REPOS,
|
|
13
|
+
REPO_IDS,
|
|
12
14
|
getProject,
|
|
15
|
+
getRepo,
|
|
13
16
|
docsUrl,
|
|
14
17
|
siteUrl,
|
|
15
18
|
type Project,
|
|
16
19
|
type ProjectId,
|
|
20
|
+
type RepoId,
|
|
17
21
|
} from './sites.ts'
|
|
18
22
|
|
|
19
23
|
export { sharedStarlightConfig, type SharedStarlightOptions } from './starlight.ts'
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site map: which projects and repos exist, and the URLs that reach them.
|
|
3
|
+
*
|
|
4
|
+
* Every rxova.org surface reads this — the landing, the docs switcher, and the
|
|
5
|
+
* updates feed's repo filter. It is plain data with a few lookups over it, which is
|
|
6
|
+
* exactly the kind of module that never gets tested until one of its consumers
|
|
7
|
+
* 404s in production.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from 'vitest'
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
RXOVA_ORIGIN,
|
|
14
|
+
PROJECTS,
|
|
15
|
+
REPOS,
|
|
16
|
+
REPO_IDS,
|
|
17
|
+
getProject,
|
|
18
|
+
getRepo,
|
|
19
|
+
docsUrl,
|
|
20
|
+
siteUrl,
|
|
21
|
+
projectFromBase,
|
|
22
|
+
type ProjectId,
|
|
23
|
+
} from './sites.ts'
|
|
24
|
+
|
|
25
|
+
describe('PROJECTS', () => {
|
|
26
|
+
it('has no duplicate ids', () => {
|
|
27
|
+
expect(new Set(PROJECTS.map((p) => p.id)).size).toBe(PROJECTS.length)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// The aggregator derives `packages/<id>` from the id and only relocates the
|
|
31
|
+
// built tree; a mount that disagrees deploys a page with every asset 404ing.
|
|
32
|
+
it('mounts every project at the path its id derives', () => {
|
|
33
|
+
for (const p of PROJECTS) expect(p.mount).toBe(`/packages/${p.id}/`)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('gives every project a tagline, a repo, an npm link and at least one package', () => {
|
|
37
|
+
for (const p of PROJECTS) {
|
|
38
|
+
expect(p.tagline.length).toBeGreaterThan(0)
|
|
39
|
+
expect(p.repo).toMatch(/^https:\/\/github\.com\//)
|
|
40
|
+
expect(p.npm).toMatch(/^https:\/\/www\.npmjs\.com\//)
|
|
41
|
+
expect(p.packages.length).toBeGreaterThan(0)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('getProject', () => {
|
|
47
|
+
it('finds a project by id', () => {
|
|
48
|
+
expect(getProject('journey').label).toBe('Journey')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('throws rather than returning undefined for an unknown id', () => {
|
|
52
|
+
expect(() => getProject('nope' as ProjectId)).toThrow(/unknown project id: nope/)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('REPOS', () => {
|
|
57
|
+
// The whole reason REPOS exists: an update about the website or the brand repo
|
|
58
|
+
// has to be representable, and neither ships a package.
|
|
59
|
+
it('covers every project plus the repos that ship no package', () => {
|
|
60
|
+
for (const p of PROJECTS) expect(REPO_IDS).toContain(p.id)
|
|
61
|
+
expect(REPO_IDS).toContain('rxova-website')
|
|
62
|
+
expect(REPO_IDS).toContain('brand')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('marks which entries are projects', () => {
|
|
66
|
+
expect(REPOS.filter((r) => r.project).map((r) => r.id)).toEqual(PROJECTS.map((p) => p.id))
|
|
67
|
+
expect(REPOS.filter((r) => !r.project).map((r) => r.id)).toEqual(['rxova-website', 'brand'])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('lists projects first, which is the order the repo filter renders', () => {
|
|
71
|
+
const firstNonProject = REPOS.findIndex((r) => !r.project)
|
|
72
|
+
expect(REPOS.slice(0, firstNonProject).every((r) => r.project)).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('has no duplicate ids and a label and repo for each', () => {
|
|
76
|
+
expect(new Set(REPO_IDS).size).toBe(REPOS.length)
|
|
77
|
+
for (const r of REPOS) {
|
|
78
|
+
expect(r.label.length).toBeGreaterThan(0)
|
|
79
|
+
expect(r.repo).toMatch(/^https:\/\/github\.com\//)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('keeps REPO_IDS in step with REPOS', () => {
|
|
84
|
+
expect([...REPO_IDS]).toEqual(REPOS.map((r) => r.id))
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('getRepo', () => {
|
|
89
|
+
it('finds a project repo and a non-project repo alike', () => {
|
|
90
|
+
expect(getRepo('journey').project).toBe(true)
|
|
91
|
+
expect(getRepo('brand').label).toBe('Brand')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('throws on an unknown id', () => {
|
|
95
|
+
expect(() => getRepo('nope' as never)).toThrow(/unknown repo id: nope/)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('urls', () => {
|
|
100
|
+
it('builds an absolute docs url', () => {
|
|
101
|
+
expect(docsUrl('journey')).toBe(`${RXOVA_ORIGIN}/packages/journey/`)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('builds a site url from a path with or without a leading slash', () => {
|
|
105
|
+
expect(siteUrl('/privacy')).toBe(`${RXOVA_ORIGIN}/privacy`)
|
|
106
|
+
expect(siteUrl('privacy')).toBe(`${RXOVA_ORIGIN}/privacy`)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('defaults to the origin root', () => {
|
|
110
|
+
expect(siteUrl()).toBe(`${RXOVA_ORIGIN}/`)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('projectFromBase', () => {
|
|
115
|
+
it('recognises a mount with and without its trailing slash', () => {
|
|
116
|
+
expect(projectFromBase('/packages/journey/')).toBe('journey')
|
|
117
|
+
expect(projectFromBase('/packages/journey')).toBe('journey')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// A standalone docs build has base "/" and belongs to no project. Returning
|
|
121
|
+
// undefined is correct there — the switcher simply omits the current marker.
|
|
122
|
+
it('returns undefined for a base that is not a mount', () => {
|
|
123
|
+
expect(projectFromBase('/')).toBeUndefined()
|
|
124
|
+
expect(projectFromBase('/packages/nope/')).toBeUndefined()
|
|
125
|
+
})
|
|
126
|
+
})
|
package/src/sites.ts
CHANGED
|
@@ -71,6 +71,46 @@ export function getProject(id: ProjectId): Project {
|
|
|
71
71
|
return project
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Every rxova repo a changelog entry can be about — the published projects plus
|
|
76
|
+
* the ones that ship no package.
|
|
77
|
+
*
|
|
78
|
+
* `PROJECTS` deliberately stays what it is: the things with docs, an npm package
|
|
79
|
+
* and a landing card. But "we rebuilt the deploy pipeline" is exactly the kind of
|
|
80
|
+
* progress `/changelog` exists to record, and it belongs to `rxova-website`, which
|
|
81
|
+
* is not a project and never will be. Validating changelog entries against
|
|
82
|
+
* `PROJECTS` would make those entries unrepresentable.
|
|
83
|
+
*
|
|
84
|
+
* Order is display order for the changelog's repo filter: projects first, then
|
|
85
|
+
* infrastructure.
|
|
86
|
+
*/
|
|
87
|
+
export const REPOS = [
|
|
88
|
+
...PROJECTS.map((p) => ({ id: p.id, label: p.label, repo: p.repo, project: true as const })),
|
|
89
|
+
{
|
|
90
|
+
id: 'rxova-website',
|
|
91
|
+
label: 'Website',
|
|
92
|
+
repo: 'https://github.com/rxova/rxova-website',
|
|
93
|
+
project: false as const,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'brand',
|
|
97
|
+
label: 'Brand',
|
|
98
|
+
repo: 'https://github.com/rxova/brand',
|
|
99
|
+
project: false as const,
|
|
100
|
+
},
|
|
101
|
+
] as const
|
|
102
|
+
|
|
103
|
+
export type RepoId = (typeof REPOS)[number]['id']
|
|
104
|
+
|
|
105
|
+
/** The ids only, for schema validation — see packages/content-schema. */
|
|
106
|
+
export const REPO_IDS: readonly RepoId[] = REPOS.map((r) => r.id)
|
|
107
|
+
|
|
108
|
+
export function getRepo(id: RepoId): (typeof REPOS)[number] {
|
|
109
|
+
const found = REPOS.find((r) => r.id === id)
|
|
110
|
+
if (!found) throw new Error(`[@rxova/brand] unknown repo id: ${id}`)
|
|
111
|
+
return found
|
|
112
|
+
}
|
|
113
|
+
|
|
74
114
|
/** Absolute URL to a project's docs root. */
|
|
75
115
|
export function docsUrl(id: ProjectId): string {
|
|
76
116
|
return `${RXOVA_ORIGIN}${getProject(id).mount}`
|
package/src/starlight.ts
CHANGED
|
@@ -73,6 +73,10 @@ export function sharedStarlightConfig({
|
|
|
73
73
|
// Starlight's default footer (pagination, edit link, last updated) plus
|
|
74
74
|
// the shared four-column site footer beneath it.
|
|
75
75
|
Footer: '@rxova/brand/components/Footer.astro',
|
|
76
|
+
// Starlight's own picker, plus a resync when a page is restored from the
|
|
77
|
+
// back/forward cache — without it, changing the theme on one rxova.org
|
|
78
|
+
// surface and pressing Back leaves the restored page on the old theme.
|
|
79
|
+
ThemeSelect: '@rxova/brand/components/ThemeSelect.astro',
|
|
76
80
|
...components,
|
|
77
81
|
},
|
|
78
82
|
|