@rxova/brand 0.10.0 → 0.12.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 +18 -11
- package/package.json +2 -1
- package/src/astro.css +11 -32
- package/src/chrome.css +51 -0
- package/src/components/SiteShell.astro +40 -2
- package/src/feed.test.ts +93 -0
- package/src/feed.ts +117 -0
- package/src/footer.css +2 -2
- package/src/index.ts +2 -0
- package/src/sites.ts +7 -1
- package/src/starlight.ts +27 -0
package/README.md
CHANGED
|
@@ -69,20 +69,27 @@ import { PROJECTS } from '@rxova/brand'
|
|
|
69
69
|
---
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
`astro.css` pulls in `
|
|
73
|
-
|
|
72
|
+
`astro.css` pulls in `chrome.css` — the tokens, the footer's styles and the
|
|
73
|
+
short aliases — and adds the reset and base element styles that Starlight gives
|
|
74
|
+
docs sites for free.
|
|
75
|
+
|
|
76
|
+
Use `chrome.css` on its own where something else already owns the document, such
|
|
77
|
+
as rxova-website's shell templates: their body is composed into a built
|
|
78
|
+
Starlight page, and the reset in `astro.css` is unlayered, so it would outrank
|
|
79
|
+
everything in `@layer starlight.*` and flatten that page.
|
|
74
80
|
|
|
75
81
|
## What's in it
|
|
76
82
|
|
|
77
|
-
| Export | What it is
|
|
78
|
-
| --------------------------------- |
|
|
79
|
-
| `@rxova/brand` | `PROJECTS`, `docsUrl()`, `siteUrl()`, `sharedStarlightConfig()`
|
|
80
|
-
| `@rxova/brand/tokens.css` | The `--rx-*` custom properties. Everything derives from these.
|
|
81
|
-
| `@rxova/brand/starlight.css` | For docs sites: maps `--rx-*` onto Starlight's `--sl-*`, plus chrome
|
|
82
|
-
| `@rxova/brand/astro.css` | For plain Astro sites:
|
|
83
|
-
| `@rxova/brand/
|
|
84
|
-
| `@rxova/brand/
|
|
85
|
-
| `@rxova/brand/
|
|
83
|
+
| Export | What it is |
|
|
84
|
+
| --------------------------------- | ---------------------------------------------------------------------- |
|
|
85
|
+
| `@rxova/brand` | `PROJECTS`, `docsUrl()`, `siteUrl()`, `sharedStarlightConfig()` |
|
|
86
|
+
| `@rxova/brand/tokens.css` | The `--rx-*` custom properties. Everything derives from these. |
|
|
87
|
+
| `@rxova/brand/starlight.css` | For docs sites: maps `--rx-*` onto Starlight's `--sl-*`, plus chrome |
|
|
88
|
+
| `@rxova/brand/astro.css` | For plain Astro sites: `chrome.css`, a reset, and base element styling |
|
|
89
|
+
| `@rxova/brand/chrome.css` | What the shared header and footer need, with nothing document-level |
|
|
90
|
+
| `@rxova/brand/fonts.css` | Self-hosted Space Grotesk + IBM Plex Mono |
|
|
91
|
+
| `@rxova/brand/components/*.astro` | `SiteTitle`, `SocialIcons`, `Footer`, `SiteFooter`, `ProjectSwitcher` |
|
|
92
|
+
| `@rxova/brand/assets/*` | The mark, logos and per-project OG images |
|
|
86
93
|
|
|
87
94
|
## The palette
|
|
88
95
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rxova/brand",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Design tokens, Starlight theme and shared site chrome for rxova.org",
|
|
6
6
|
"keywords": [
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
".": "./src/index.ts",
|
|
34
34
|
"./tokens.css": "./src/tokens.css",
|
|
35
35
|
"./astro.css": "./src/astro.css",
|
|
36
|
+
"./chrome.css": "./src/chrome.css",
|
|
36
37
|
"./starlight.css": "./src/starlight.css",
|
|
37
38
|
"./fonts.css": "./src/fonts.css",
|
|
38
39
|
"./components/*": "./src/components/*",
|
package/src/astro.css
CHANGED
|
@@ -1,42 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Entry point for plain Astro surfaces
|
|
2
|
+
* Entry point for plain Astro surfaces that own their whole document — the
|
|
3
|
+
* rxova.org landing, /blog and /updates.
|
|
3
4
|
*
|
|
4
5
|
* Starlight sites use `starlight.css` instead; it maps the same tokens onto
|
|
5
|
-
* Starlight's own variables. This file
|
|
6
|
+
* Starlight's own variables. This file is `chrome.css` — the tokens, the footer
|
|
7
|
+
* and the short aliases — plus the two things a bare Astro page needs and
|
|
6
8
|
* Starlight already provides: a reset and base element styling.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Load this only where nothing else owns the document. A surface that renders
|
|
11
|
+
* the shared chrome *into* someone else's document — rxova-website's shell
|
|
12
|
+
* templates, whose body is composed into a built Starlight page — must load
|
|
13
|
+
* `chrome.css` instead. The reset below is unlayered, so inside a Starlight
|
|
14
|
+
* document it would outrank every rule in `@layer starlight.*` and flatten the
|
|
15
|
+
* page; see chrome.css.
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
|
-
@import './
|
|
16
|
-
/* The shared site footer, which /blog, /updates and the landing all render. It
|
|
17
|
-
is the one piece of chrome common to these surfaces and the Starlight ones,
|
|
18
|
-
so its rules sit in their own file and both entry points pull them in. */
|
|
19
|
-
@import './footer.css';
|
|
20
|
-
|
|
21
|
-
:root {
|
|
22
|
-
--bg: var(--rx-bg);
|
|
23
|
-
--fg: var(--rx-fg);
|
|
24
|
-
--muted: var(--rx-muted);
|
|
25
|
-
--faint: var(--rx-faint);
|
|
26
|
-
--rule: var(--rx-rule);
|
|
27
|
-
--card: var(--rx-card);
|
|
28
|
-
--tag-bg: var(--rx-tag-bg);
|
|
29
|
-
--glow-tint: var(--rx-glow-tint);
|
|
30
|
-
--font: var(--rx-font-sans);
|
|
31
|
-
--mono: var(--rx-font-mono);
|
|
32
|
-
--max: var(--rx-max);
|
|
33
|
-
|
|
34
|
-
/* The landing's `--accent` is the foreground, not the brand violet: its
|
|
35
|
-
design is deliberately monochrome and uses the gradient only as an accent
|
|
36
|
-
rule. Mapping this to --rx-primary would tint every focus ring on the
|
|
37
|
-
site. */
|
|
38
|
-
--accent: var(--rx-fg);
|
|
39
|
-
}
|
|
18
|
+
@import './chrome.css';
|
|
40
19
|
|
|
41
20
|
/* --- Reset ---------------------------------------------------------------- */
|
|
42
21
|
|
package/src/chrome.css
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything the shared chrome needs, and nothing that touches the document.
|
|
3
|
+
*
|
|
4
|
+
* `Header`, `SiteFooter` and `ThemeToggle` are rendered on two kinds of page:
|
|
5
|
+
* plain Astro surfaces that own their whole document (the landing, /blog,
|
|
6
|
+
* /updates — see astro.css), and rxova-website's shell templates, whose body is
|
|
7
|
+
* composed into an already-complete Starlight document at deploy time.
|
|
8
|
+
*
|
|
9
|
+
* The second case is why this file exists. It carries the tokens, the footer's
|
|
10
|
+
* styles and the short aliases the chrome's scoped CSS is written against — but
|
|
11
|
+
* no reset and no base element styling. Those belong to whoever owns the
|
|
12
|
+
* document, and a shell that brings its own into a Starlight page destroys that
|
|
13
|
+
* page: Starlight puts all of its CSS in `@layer starlight.*`, and an unlayered
|
|
14
|
+
* `* { margin: 0; padding: 0 }` arriving from the shell beats every layered rule
|
|
15
|
+
* it has, whatever the specificity. That is not a hypothetical — it flattened
|
|
16
|
+
* every /packages/* docs page on rxova.org.
|
|
17
|
+
*
|
|
18
|
+
* So: load this to render the chrome, and astro.css (which is this plus the
|
|
19
|
+
* reset) to own a document.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
@import './tokens.css';
|
|
23
|
+
/* The shared site footer, which /blog, /updates and the landing all render. It
|
|
24
|
+
is the one piece of chrome common to these surfaces and the Starlight ones,
|
|
25
|
+
so its rules sit in their own file and both entry points pull them in. */
|
|
26
|
+
@import './footer.css';
|
|
27
|
+
|
|
28
|
+
/* The short token names the landing and the shared chrome were written against
|
|
29
|
+
(`--bg`, `--fg`, `--rule`, …), aliased onto their `--rx-*` equivalents, so
|
|
30
|
+
adopting this package did not require rewriting several hundred lines of
|
|
31
|
+
scoped component styles in one go. New work should use `--rx-*` directly; the
|
|
32
|
+
aliases exist to make the migration incremental, not to be a second API. */
|
|
33
|
+
:root {
|
|
34
|
+
--bg: var(--rx-bg);
|
|
35
|
+
--fg: var(--rx-fg);
|
|
36
|
+
--muted: var(--rx-muted);
|
|
37
|
+
--faint: var(--rx-faint);
|
|
38
|
+
--rule: var(--rx-rule);
|
|
39
|
+
--card: var(--rx-card);
|
|
40
|
+
--tag-bg: var(--rx-tag-bg);
|
|
41
|
+
--glow-tint: var(--rx-glow-tint);
|
|
42
|
+
--font: var(--rx-font-sans);
|
|
43
|
+
--mono: var(--rx-font-mono);
|
|
44
|
+
--max: var(--rx-max);
|
|
45
|
+
|
|
46
|
+
/* The landing's `--accent` is the foreground, not the brand violet: its
|
|
47
|
+
design is deliberately monochrome and uses the gradient only as an accent
|
|
48
|
+
rule. Mapping this to --rx-primary would tint every focus ring on the
|
|
49
|
+
site. */
|
|
50
|
+
--accent: var(--rx-fg);
|
|
51
|
+
}
|
|
@@ -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={
|
|
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>
|
package/src/feed.test.ts
ADDED
|
@@ -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
|
+
'<a href="x">&'</a>',
|
|
17
|
+
)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// The ordering bug this guards is silent: escape `<` before `&` and the
|
|
21
|
+
// ampersand introduced by `<` is escaped again, shipping `&lt;`.
|
|
22
|
+
it('does not double-escape the ampersands it introduces', () => {
|
|
23
|
+
expect(escapeXml('a < b')).toBe('a < b')
|
|
24
|
+
expect(escapeXml('Tom & Jerry')).toBe('Tom & 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 <input> & 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 `&lt;`.
|
|
25
|
+
*/
|
|
26
|
+
export const escapeXml = (value: string): string =>
|
|
27
|
+
value
|
|
28
|
+
.replace(/&/g, '&')
|
|
29
|
+
.replace(/</g, '<')
|
|
30
|
+
.replace(/>/g, '>')
|
|
31
|
+
.replace(/"/g, '"')
|
|
32
|
+
.replace(/'/g, ''')
|
|
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/footer.css
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* is why /blog and /updates shipped a dot-separated link row instead — the
|
|
8
8
|
* shared footer would have rendered unstyled there.
|
|
9
9
|
*
|
|
10
|
-
* Imported by both `starlight.css` and `
|
|
10
|
+
* Imported by both `starlight.css` and `chrome.css`; nothing needs to import it
|
|
11
11
|
* directly. Written against `--rx-*` tokens only, so it does not depend on the
|
|
12
|
-
* short aliases
|
|
12
|
+
* short aliases chrome.css defines.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
@import './tokens.css';
|
package/src/index.ts
CHANGED
package/src/sites.ts
CHANGED
|
@@ -50,8 +50,14 @@ export const PROJECTS: readonly Project[] = [
|
|
|
50
50
|
packages: [
|
|
51
51
|
'@rxova/react-inputs',
|
|
52
52
|
'@rxova/react-intl-currency-input',
|
|
53
|
-
'@rxova/react-otp-input',
|
|
54
53
|
'@rxova/react-rating-input',
|
|
54
|
+
'@rxova/react-otp-input',
|
|
55
|
+
'@rxova/react-password-input',
|
|
56
|
+
'@rxova/react-phone-input',
|
|
57
|
+
'@rxova/react-date-input',
|
|
58
|
+
'@rxova/react-time-input',
|
|
59
|
+
'@rxova/react-tags-input',
|
|
60
|
+
'@rxova/react-file-input',
|
|
55
61
|
],
|
|
56
62
|
},
|
|
57
63
|
{
|
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
|