@rxova/brand 0.2.0 → 0.4.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 +6 -3
- package/src/components/ThemeScript.astro +55 -0
- package/src/components/ThemeToggle.astro +145 -0
- package/src/index.ts +4 -0
- package/src/sites.test.ts +126 -0
- package/src/sites.ts +40 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rxova/brand",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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,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/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/schemas. */
|
|
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}`
|