@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.
@@ -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} />