@rxova/brand 0.11.0 → 0.13.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/README.md CHANGED
@@ -9,17 +9,18 @@
9
9
 
10
10
  ## Why this exists
11
11
 
12
- `rxova.org` serves four surfaces from one origin — an Astro landing at `/`, plus
12
+ `rxova.org` serves one surface per project from a single origin — an Astro
13
+ landing at `/`, plus [overlock](https://github.com/rxova/overlock),
13
14
  [journey](https://github.com/rxova/journey),
14
15
  [react-inputs](https://github.com/rxova/react-inputs) and
15
16
  [use-everywhere](https://github.com/rxova/use-everywhere) docs, each built in its
16
17
  own repo and mounted as a static tree under `/packages/<name>/`.
17
18
 
18
- They were built independently and ended up looking like four unrelated products:
19
- three different accent colours, three different footers, no way to navigate from
19
+ They were built independently and ended up looking like unrelated products:
20
+ a different accent colour each, a different footer each, no way to navigate from
20
21
  one project's docs to another's. This package is the single source of truth that
21
- makes them one site — and it lives in its own repo because it is consumed by four
22
- and owned by none.
22
+ makes them one site — and it lives in its own repo because it is consumed by all
23
+ of them and owned by none.
23
24
 
24
25
  ## Install
25
26
 
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rxova/brand",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "description": "Design tokens, Starlight theme and shared site chrome for rxova.org",
6
6
  "keywords": [
@@ -59,7 +59,7 @@
59
59
  "@resvg/resvg-js": "^2.6.2",
60
60
  "@types/node": "^26.1.1",
61
61
  "@vitest/coverage-v8": "^4.1.10",
62
- "astro": "^7.1.3",
62
+ "astro": "^7.3.2",
63
63
  "publint": "^0.3.21",
64
64
  "satori": "^0.29.0",
65
65
  "tsx": "^4.23.1",
@@ -67,14 +67,15 @@ const surfaces: readonly SiteLink[] = site ?? [
67
67
  <span>Rxova</span>
68
68
  </a>
69
69
  {
70
- /* Not "React libraries": two of the three ship a framework-agnostic
71
- core that runs in a worker or a plain script, and Journey also ships a
72
- browser extension. React is a binding we provide, not the boundary of
70
+ /* Not "React libraries": most of these ship a framework-agnostic core
71
+ that runs in a worker or a plain script, Journey also ships a browser
72
+ extension, and overlock is a command-line tool that never sees a
73
+ browser at all. React is a binding we provide, not the boundary of
73
74
  what these are. Same correction as the landing's hero. */
74
75
  }
75
76
  <p class="rx-footer__blurb">
76
- Small TypeScript libraries for the browser, documented in one place. MIT licensed, built in
77
- the open.
77
+ Small TypeScript libraries and developer tools, documented in one place. MIT licensed, built
78
+ in the open.
78
79
  </p>
79
80
  </div>
80
81
 
@@ -22,14 +22,46 @@ interface Props {
22
22
  /** Canonical path, e.g. `/blog/why-rxova-has-a-blog`. Normalised below. */
23
23
  path: string
24
24
  ogType?: 'website' | 'article'
25
+ /**
26
+ * Absolute URL of the social card. Defaults to the umbrella card.
27
+ *
28
+ * A prop rather than something derived here, because what makes the best card
29
+ * differs per surface and only the caller knows: a post with a cover should
30
+ * share its cover, an update belongs to a project and should carry that
31
+ * project's card, and an index has neither.
32
+ */
33
+ image?: string
34
+ /** Advertises an RSS feed for this surface, as `rel="alternate"`. */
35
+ feed?: { href: string; title: string }
36
+ /**
37
+ * JSON-LD for this page, emitted verbatim as `application/ld+json`.
38
+ *
39
+ * Structured data is the only way a crawler learns that /blog carries articles
40
+ * with an author and a date rather than prose it has to infer that from. Typed
41
+ * as unknown because the shapes differ per surface and schema.org is not worth
42
+ * modelling in TypeScript for three call sites.
43
+ */
44
+ jsonLd?: unknown
25
45
  }
26
46
 
27
- const { title, description, path, ogType = 'website' } = Astro.props
47
+ const { title, description, path, ogType = 'website', image, feed, jsonLd } = Astro.props
28
48
 
29
49
  // Normalised here rather than at the four call sites: every one of them had
30
50
  // dropped the trailing slash, so every blog and updates page was declaring itself
31
51
  // canonical at a URL that 301s. A caller can forget again; this cannot.
32
52
  const canonical = canonicalUrl(path)
53
+
54
+ const ogImage = image ?? siteUrl('/og/rxova.png')
55
+
56
+ /**
57
+ * Serialise JSON-LD for an inline `<script>`.
58
+ *
59
+ * `<` is escaped because a `</script>` anywhere inside a string value — a post
60
+ * title about a tag, a description quoting markup — closes the element early and
61
+ * spills the rest of the payload into the document as text. `<` is valid
62
+ * JSON and parses back to `<`, so the structured data is unchanged.
63
+ */
64
+ const serialiseJsonLd = (value: unknown): string => JSON.stringify(value).replace(/</g, '\\u003c')
33
65
  ---
34
66
 
35
67
  <!doctype html>
@@ -44,8 +76,14 @@ const canonical = canonicalUrl(path)
44
76
  <meta property="og:description" content={description} />
45
77
  <meta property="og:type" content={ogType} />
46
78
  <meta property="og:url" content={canonical} />
47
- <meta property="og:image" content={siteUrl('/og/rxova.png')} />
79
+ <meta property="og:image" content={ogImage} />
48
80
  <meta name="twitter:card" content="summary_large_image" />
81
+ {
82
+ feed && (
83
+ <link rel="alternate" type="application/rss+xml" title={feed.title} href={feed.href} />
84
+ )
85
+ }
86
+ {jsonLd && <script type="application/ld+json" is:inline set:html={serialiseJsonLd(jsonLd)} />}
49
87
  </head>
50
88
  <body>
51
89
  <main>
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { escapeXml, renderFeed, rfc822, type FeedItem } from './feed.ts'
4
+
5
+ const item = (over: Partial<FeedItem> = {}): FeedItem => ({
6
+ title: 'A post',
7
+ link: 'https://rxova.org/blog/a-post/',
8
+ description: 'What it is about.',
9
+ pubDate: new Date('2026-08-03T09:00:00Z'),
10
+ ...over,
11
+ })
12
+
13
+ describe('escapeXml', () => {
14
+ it('escapes the five XML entities', () => {
15
+ expect(escapeXml(`<a href="x">&'</a>`)).toBe(
16
+ '&lt;a href=&quot;x&quot;&gt;&amp;&apos;&lt;/a&gt;',
17
+ )
18
+ })
19
+
20
+ // The ordering bug this guards is silent: escape `<` before `&` and the
21
+ // ampersand introduced by `&lt;` is escaped again, shipping `&amp;lt;`.
22
+ it('does not double-escape the ampersands it introduces', () => {
23
+ expect(escapeXml('a < b')).toBe('a &lt; b')
24
+ expect(escapeXml('Tom & Jerry')).toBe('Tom &amp; Jerry')
25
+ })
26
+ })
27
+
28
+ describe('rfc822', () => {
29
+ // RSS 2.0 requires RFC 822 dates, not ISO 8601. A reader handed an ISO date
30
+ // either drops the item or dates it "now", and both look like the feed works.
31
+ it('formats as RFC 822, not ISO 8601', () => {
32
+ expect(rfc822(new Date('2026-08-09T09:00:00Z'))).toBe('Sun, 09 Aug 2026 09:00:00 GMT')
33
+ })
34
+ })
35
+
36
+ describe('renderFeed', () => {
37
+ const base = {
38
+ title: 'Rxova Blog',
39
+ description: 'Essays from the Rxova projects.',
40
+ siteUrl: 'https://rxova.org/blog/',
41
+ feedUrl: 'https://rxova.org/blog/rss.xml',
42
+ }
43
+
44
+ it('renders a channel with a self-referencing atom link', () => {
45
+ const xml = renderFeed({ ...base, items: [item()] })
46
+ expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>')
47
+ expect(xml).toContain('<title>Rxova Blog</title>')
48
+ expect(xml).toContain(
49
+ '<atom:link href="https://rxova.org/blog/rss.xml" rel="self" type="application/rss+xml" />',
50
+ )
51
+ })
52
+
53
+ it('uses the entry URL as a permalink guid', () => {
54
+ const xml = renderFeed({ ...base, items: [item()] })
55
+ expect(xml).toContain('<guid isPermaLink="true">https://rxova.org/blog/a-post/</guid>')
56
+ })
57
+
58
+ it('keeps the order it was given', () => {
59
+ const xml = renderFeed({
60
+ ...base,
61
+ items: [item({ title: 'Newer' }), item({ title: 'Older' })],
62
+ })
63
+ expect(xml.indexOf('Newer')).toBeLessThan(xml.indexOf('Older'))
64
+ })
65
+
66
+ it('dates the build from the newest item when not told otherwise', () => {
67
+ const xml = renderFeed({ ...base, items: [item()] })
68
+ expect(xml).toContain('<lastBuildDate>Mon, 03 Aug 2026 09:00:00 GMT</lastBuildDate>')
69
+ })
70
+
71
+ it('omits lastBuildDate for an empty feed rather than emitting an invalid date', () => {
72
+ const xml = renderFeed({ ...base, items: [] })
73
+ expect(xml).not.toContain('lastBuildDate')
74
+ expect(xml).toContain('</channel>')
75
+ })
76
+
77
+ it('renders bylines as dc:creator, never as an email-shaped author', () => {
78
+ const xml = renderFeed({ ...base, items: [item({ authors: ['Jonatan Kruszewski'] })] })
79
+ expect(xml).toContain('<dc:creator>Jonatan Kruszewski</dc:creator>')
80
+ expect(xml).not.toContain('<author>')
81
+ })
82
+
83
+ it('escapes titles that carry markup characters', () => {
84
+ const xml = renderFeed({ ...base, items: [item({ title: 'Why <input> & you' })] })
85
+ expect(xml).toContain('<title>Why &lt;input&gt; &amp; you</title>')
86
+ })
87
+
88
+ it('renders categories from tags', () => {
89
+ const xml = renderFeed({ ...base, items: [item({ categories: ['react', 'intl'] })] })
90
+ expect(xml).toContain('<category>react</category>')
91
+ expect(xml).toContain('<category>intl</category>')
92
+ })
93
+ })
package/src/feed.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * RSS 2.0 for the rxova.org surfaces that publish a stream.
3
+ *
4
+ * Hand-written rather than `@astrojs/rss`, for the same reason rxova-website
5
+ * hand-writes its sitemaps: this is a few hundred bytes of well-specified XML,
6
+ * both consumers are static Astro builds where the feed is one prerendered
7
+ * endpoint, and the dependency would be carried by two packages to save a
8
+ * `map()`. The escaping is the only part with teeth, and it is one function
9
+ * with its own tests.
10
+ *
11
+ * Deliberately in `@rxova/brand` rather than in either consumer: /blog and
12
+ * /updates are separate Astro projects that already share this package for
13
+ * their chrome, and a feed each would be the same file twice — which is how
14
+ * their two document shells came to differ before `SiteShell` existed.
15
+ *
16
+ * Node-only imports are avoided so this stays importable from an Astro
17
+ * endpoint in any runtime.
18
+ */
19
+
20
+ /**
21
+ * Escape text for an XML text node or attribute value.
22
+ *
23
+ * `&` first, or the ampersands introduced by the later replacements get escaped
24
+ * a second time and `<` ships as `&amp;lt;`.
25
+ */
26
+ export const escapeXml = (value: string): string =>
27
+ value
28
+ .replace(/&/g, '&amp;')
29
+ .replace(/</g, '&lt;')
30
+ .replace(/>/g, '&gt;')
31
+ .replace(/"/g, '&quot;')
32
+ .replace(/'/g, '&apos;')
33
+
34
+ export interface FeedItem {
35
+ title: string
36
+ /** Absolute URL. Also used as the guid, which is why it must be stable. */
37
+ link: string
38
+ description: string
39
+ pubDate: Date
40
+ /** Plain author names. Rendered as `<dc:creator>`, not `<author>`. */
41
+ authors?: readonly string[]
42
+ categories?: readonly string[]
43
+ }
44
+
45
+ export interface FeedOptions {
46
+ title: string
47
+ description: string
48
+ /** Absolute URL of the page this feed describes. */
49
+ siteUrl: string
50
+ /** Absolute URL of the feed document itself, for `atom:link rel="self"`. */
51
+ feedUrl: string
52
+ items: readonly FeedItem[]
53
+ /** Defaults to the newest item's date. */
54
+ lastBuildDate?: Date
55
+ }
56
+
57
+ /**
58
+ * RFC 822, which is what RSS 2.0 requires — not ISO 8601.
59
+ *
60
+ * `toUTCString()` produces exactly this shape ("Sun, 09 Aug 2026 09:00:00 GMT")
61
+ * and is locale-independent, so it is used directly rather than assembled from
62
+ * day and month tables that would need their own test.
63
+ */
64
+ export const rfc822 = (date: Date): string => date.toUTCString()
65
+
66
+ /**
67
+ * A complete RSS 2.0 document.
68
+ *
69
+ * `dc:creator` carries bylines because RSS's own `<author>` element is specified
70
+ * as an email address, and publishing the maintainer's address to every
71
+ * aggregator that has ever scraped a feed is not worth a byline.
72
+ *
73
+ * Items are emitted in the order given; both callers hand them over newest-first
74
+ * already, and re-sorting here would quietly disagree with the page the feed
75
+ * describes.
76
+ */
77
+ export function renderFeed({
78
+ title,
79
+ description,
80
+ siteUrl,
81
+ feedUrl,
82
+ items,
83
+ lastBuildDate,
84
+ }: FeedOptions): string {
85
+ const newest = items[0]?.pubDate
86
+ const built = lastBuildDate ?? newest
87
+
88
+ const entries = items.map((item) => {
89
+ const parts = [
90
+ ` <title>${escapeXml(item.title)}</title>`,
91
+ ` <link>${escapeXml(item.link)}</link>`,
92
+ // Permalink: the URL is the identity, so a re-dated entry keeps its guid
93
+ // and does not resurface in every reader as a new item.
94
+ ` <guid isPermaLink="true">${escapeXml(item.link)}</guid>`,
95
+ ` <description>${escapeXml(item.description)}</description>`,
96
+ ` <pubDate>${rfc822(item.pubDate)}</pubDate>`,
97
+ ...(item.authors ?? []).map((a) => ` <dc:creator>${escapeXml(a)}</dc:creator>`),
98
+ ...(item.categories ?? []).map((c) => ` <category>${escapeXml(c)}</category>`),
99
+ ]
100
+ return ` <item>\n${parts.join('\n')}\n </item>`
101
+ })
102
+
103
+ return `<?xml version="1.0" encoding="UTF-8"?>
104
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
105
+ <channel>
106
+ <title>${escapeXml(title)}</title>
107
+ <link>${escapeXml(siteUrl)}</link>
108
+ <description>${escapeXml(description)}</description>
109
+ <language>en</language>
110
+ <atom:link href="${escapeXml(feedUrl)}" rel="self" type="application/rss+xml" />${
111
+ built ? `\n <lastBuildDate>${rfc822(built)}</lastBuildDate>` : ''
112
+ }
113
+ ${entries.join('\n')}
114
+ </channel>
115
+ </rss>
116
+ `
117
+ }
package/src/index.ts CHANGED
@@ -22,3 +22,5 @@ export {
22
22
  } from './sites.ts'
23
23
 
24
24
  export { sharedStarlightConfig, type SharedStarlightOptions } from './starlight.ts'
25
+
26
+ export { renderFeed, escapeXml, rfc822, type FeedItem, type FeedOptions } from './feed.ts'
package/src/sites.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * rxova.org is an aggregator: an Astro landing at `/`, plus each project's docs
5
5
  * built in its own repo and mounted as a static tree under `/packages/<name>/`.
6
- * That means four surfaces live on one origin at four different base paths, so
6
+ * That means every surface lives on one origin at its own base path, so
7
7
  * **every cross-project link must be absolute**. A relative href would resolve
8
8
  * against the local base and produce `/packages/journey/packages/react-inputs/`.
9
9
  *
@@ -14,7 +14,7 @@
14
14
  /** Canonical origin. Override for a staging deploy (e.g. https://web.rxova.org). */
15
15
  export const RXOVA_ORIGIN = process.env.RXOVA_ORIGIN ?? 'https://rxova.org'
16
16
 
17
- export type ProjectId = 'journey' | 'react-inputs' | 'use-everywhere'
17
+ export type ProjectId = 'overlock' | 'journey' | 'react-inputs' | 'use-everywhere'
18
18
 
19
19
  export interface Project {
20
20
  id: ProjectId
@@ -31,6 +31,15 @@ export interface Project {
31
31
  }
32
32
 
33
33
  export const PROJECTS: readonly Project[] = [
34
+ {
35
+ id: 'overlock',
36
+ label: 'overlock',
37
+ mount: '/packages/overlock/',
38
+ tagline: 'A deterministic gate on test integrity in a git patch.',
39
+ repo: 'https://github.com/rxova/overlock',
40
+ npm: 'https://www.npmjs.com/package/overlock',
41
+ packages: ['overlock'],
42
+ },
34
43
  {
35
44
  id: 'journey',
36
45
  label: 'Journey',
@@ -50,8 +59,14 @@ export const PROJECTS: readonly Project[] = [
50
59
  packages: [
51
60
  '@rxova/react-inputs',
52
61
  '@rxova/react-intl-currency-input',
53
- '@rxova/react-otp-input',
54
62
  '@rxova/react-rating-input',
63
+ '@rxova/react-otp-input',
64
+ '@rxova/react-password-input',
65
+ '@rxova/react-phone-input',
66
+ '@rxova/react-date-input',
67
+ '@rxova/react-time-input',
68
+ '@rxova/react-tags-input',
69
+ '@rxova/react-file-input',
55
70
  ],
56
71
  },
57
72
  {
package/src/starlight.ts CHANGED
@@ -96,6 +96,33 @@ export function sharedStarlightConfig({
96
96
  tag: 'meta' as const,
97
97
  attrs: { name: 'twitter:card', content: 'summary_large_image' },
98
98
  },
99
+ // What this project *is*, in the vocabulary a crawler already parses.
100
+ //
101
+ // Every field is read from PROJECTS, so a project that changes its tagline
102
+ // or adds a package updates its structured data with it — the failure this
103
+ // avoids is the usual one for hand-written JSON-LD, which is that it
104
+ // describes the site as it was when someone last remembered to edit it.
105
+ //
106
+ // Emitted on every page of the docs rather than only the root: Starlight
107
+ // has no "site index only" hook, and repeating an identical
108
+ // SoftwareSourceCode across a subtree is well-formed — each page really is
109
+ // documentation for that one piece of software.
110
+ {
111
+ tag: 'script' as const,
112
+ attrs: { type: 'application/ld+json' },
113
+ content: JSON.stringify({
114
+ '@context': 'https://schema.org',
115
+ '@type': 'SoftwareSourceCode',
116
+ name: self.label,
117
+ description: self.tagline,
118
+ url: `${RXOVA_ORIGIN}${self.mount}`,
119
+ codeRepository: self.repo,
120
+ programmingLanguage: 'TypeScript',
121
+ runtimePlatform: 'Node.js',
122
+ license: 'https://opensource.org/licenses/MIT',
123
+ author: { '@type': 'Person', name: 'Jonatan Kruszewski' },
124
+ }).replace(/</g, '\\u003c'),
125
+ },
99
126
  ],
100
127
 
101
128
  // Pagefind ships with Starlight and replaces the third-party search plugin