@astrojs/starlight 0.0.3 → 0.0.5
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/CHANGELOG.md +14 -0
- package/components/HeadSEO.astro +83 -40
- package/components.ts +2 -0
- package/index.astro +0 -2
- package/index.ts +2 -1
- package/integrations/sitemap.ts +22 -0
- package/package.json +6 -2
- package/schema.ts +4 -0
- package/schemas/head.ts +29 -0
- package/user-components/TabItem.astro +17 -0
- package/user-components/Tabs.astro +148 -0
- package/user-components/rehype-tabs.ts +82 -0
- package/utils/head.ts +95 -0
- package/utils/user-config.ts +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @astrojs/starlight
|
|
2
2
|
|
|
3
|
+
## 0.0.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#42](https://github.com/withastro/starlight/pull/42) [`c6c1b67`](https://github.com/withastro/starlight/commit/c6c1b6727140a76c42c661f406000cc6e9b175de) Thanks [@delucis](https://github.com/delucis)! - Support setting custom `<head>` tags in config or frontmatter.
|
|
8
|
+
|
|
9
|
+
## 0.0.4
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- [#40](https://github.com/withastro/starlight/pull/40) [`e22dd76`](https://github.com/withastro/starlight/commit/e22dd76136f9749bb5d43f96241385faccfc90a1) Thanks [@delucis](https://github.com/delucis)! - Generate sitemaps for Starlight sites
|
|
14
|
+
|
|
15
|
+
- [#38](https://github.com/withastro/starlight/pull/38) [`623b577`](https://github.com/withastro/starlight/commit/623b577319b1dea2d6c42f1b680139fb858d85d6) Thanks [@delucis](https://github.com/delucis)! - Add tab components for use in MDX.
|
|
16
|
+
|
|
3
17
|
## 0.0.3
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
package/components/HeadSEO.astro
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
---
|
|
2
|
-
import type { CollectionEntry } from 'astro:content';
|
|
2
|
+
import type { CollectionEntry, z } from 'astro:content';
|
|
3
3
|
import config from 'virtual:starlight/user-config';
|
|
4
|
+
import type { HeadConfigSchema } from '../schemas/head';
|
|
5
|
+
import { createHead } from '../utils/head';
|
|
4
6
|
import { localizedUrl } from '../utils/localizedUrl';
|
|
5
7
|
|
|
6
8
|
interface Props {
|
|
@@ -15,46 +17,87 @@ const canonical = Astro.site
|
|
|
15
17
|
: undefined;
|
|
16
18
|
const title = data.title || config.title;
|
|
17
19
|
const description = data.description || config.description;
|
|
18
|
-
---
|
|
19
20
|
|
|
20
|
-
<
|
|
21
|
-
{
|
|
22
|
-
|
|
23
|
-
{
|
|
24
|
-
canonical
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
21
|
+
const headDefaults: z.input<ReturnType<typeof HeadConfigSchema>> = [
|
|
22
|
+
{ tag: 'meta', attrs: { charset: 'utf-8' } },
|
|
23
|
+
{ tag: 'meta', attrs: { name: 'viewport', content: 'width=device-width' } },
|
|
24
|
+
{ tag: 'title', content: title },
|
|
25
|
+
{ tag: 'link', attrs: { rel: 'canonical', href: canonical?.href } },
|
|
26
|
+
{ tag: 'meta', attrs: { name: 'generator', content: Astro.generator } },
|
|
27
|
+
// Favicon
|
|
28
|
+
{
|
|
29
|
+
tag: 'link',
|
|
30
|
+
attrs: {
|
|
31
|
+
rel: 'shortcut icon',
|
|
32
|
+
href: import.meta.env.BASE_URL + 'favicon.svg',
|
|
33
|
+
type: 'image/svg+xml',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
// OpenGraph Tags
|
|
37
|
+
{ tag: 'meta', attrs: { property: 'og:title', content: title } },
|
|
38
|
+
{ tag: 'meta', attrs: { property: 'og:type', content: 'article' } },
|
|
39
|
+
{ tag: 'meta', attrs: { property: 'og:url', content: canonical?.href } },
|
|
40
|
+
{ tag: 'meta', attrs: { property: 'og:locale', content: lang } },
|
|
41
|
+
{ tag: 'meta', attrs: { property: 'og:description', content: description } },
|
|
42
|
+
{ tag: 'meta', attrs: { property: 'og:site_name', content: config.title } },
|
|
43
|
+
// Twitter Tags
|
|
44
|
+
{
|
|
45
|
+
tag: 'meta',
|
|
46
|
+
attrs: { name: 'twitter:card', content: 'summary_large_image' },
|
|
47
|
+
},
|
|
48
|
+
{ tag: 'meta', attrs: { name: 'twitter:title', content: title } },
|
|
49
|
+
{ tag: 'meta', attrs: { name: 'twitter:description', content: description } },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
if (description)
|
|
53
|
+
headDefaults.push({
|
|
54
|
+
tag: 'meta',
|
|
55
|
+
attrs: { name: 'description', content: description },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Link to language alternates.
|
|
59
|
+
if (canonical && config.isMultilingual) {
|
|
60
|
+
for (const locale in config.locales) {
|
|
61
|
+
const localeOpts = config.locales[locale];
|
|
62
|
+
if (!localeOpts) continue;
|
|
63
|
+
headDefaults.push({
|
|
64
|
+
tag: 'link',
|
|
65
|
+
attrs: {
|
|
66
|
+
rel: 'alternate',
|
|
67
|
+
hreflang: localeOpts.lang,
|
|
68
|
+
href: localizedUrl(canonical, locale).href,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Link to sitemap, but only when `site` is set.
|
|
75
|
+
if (Astro.site) {
|
|
76
|
+
headDefaults.push({
|
|
77
|
+
tag: 'link',
|
|
78
|
+
attrs: {
|
|
79
|
+
rel: 'sitemap',
|
|
80
|
+
href: import.meta.env.BASE_URL + 'sitemap-index.xml',
|
|
81
|
+
},
|
|
82
|
+
});
|
|
36
83
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
<!-- Twitter Tags -->
|
|
53
|
-
<meta name="twitter:card" content="summary_large_image" />
|
|
84
|
+
|
|
85
|
+
// Link to Twitter account if set in Starlight config.
|
|
86
|
+
if (config.social?.twitter) {
|
|
87
|
+
headDefaults.push({
|
|
88
|
+
tag: 'meta',
|
|
89
|
+
attrs: {
|
|
90
|
+
name: 'twitter:site',
|
|
91
|
+
content: new URL(config.social.twitter).pathname,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const head = createHead(headDefaults, config.head, data.head);
|
|
97
|
+
---
|
|
98
|
+
|
|
54
99
|
{
|
|
55
|
-
|
|
56
|
-
<
|
|
57
|
-
)
|
|
100
|
+
head.map(({ tag: Tag, attrs, content }) => (
|
|
101
|
+
<Tag {...attrs} set:html={content} />
|
|
102
|
+
))
|
|
58
103
|
}
|
|
59
|
-
<meta name="twitter:title" content={title} />
|
|
60
|
-
<meta name="twitter:description" content={description} />
|
package/components.ts
ADDED
package/index.astro
CHANGED
package/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { spawn } from 'node:child_process';
|
|
|
9
9
|
import { dirname, relative } from 'node:path';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { starlightAsides } from './integrations/asides';
|
|
12
|
+
import { starlightSitemap } from './integrations/sitemap';
|
|
12
13
|
import {
|
|
13
14
|
StarlightUserConfig,
|
|
14
15
|
StarlightConfig,
|
|
@@ -63,7 +64,7 @@ export default function StarlightIntegration(
|
|
|
63
64
|
},
|
|
64
65
|
};
|
|
65
66
|
|
|
66
|
-
return [Starlight, mdx()];
|
|
67
|
+
return [starlightSitemap(userConfig), Starlight, mdx()];
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
function resolveVirtualModuleId(id: string) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import sitemap, { SitemapOptions } from '@astrojs/sitemap';
|
|
2
|
+
import type { StarlightConfig } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A wrapped version of the `@astrojs/sitemap` integration configured based
|
|
6
|
+
* on Starlight i18n config.
|
|
7
|
+
*/
|
|
8
|
+
export function starlightSitemap(opts: StarlightConfig) {
|
|
9
|
+
const sitemapConfig: SitemapOptions = {};
|
|
10
|
+
if (opts.isMultilingual) {
|
|
11
|
+
sitemapConfig.i18n = {
|
|
12
|
+
defaultLocale: opts.defaultLocale.locale! || 'root',
|
|
13
|
+
locales: Object.fromEntries(
|
|
14
|
+
Object.entries(opts.locales).map(([locale, config]) => [
|
|
15
|
+
locale,
|
|
16
|
+
config?.lang!,
|
|
17
|
+
])
|
|
18
|
+
),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return sitemap(sitemapConfig);
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astrojs/starlight",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "Build beautiful, high-performance documentation websites with Astro",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"docs",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"type": "module",
|
|
21
21
|
"exports": {
|
|
22
22
|
".": "./index.ts",
|
|
23
|
+
"./components": "./components.ts",
|
|
23
24
|
"./schema": "./schema.ts",
|
|
24
25
|
"./types": "./types.ts",
|
|
25
26
|
"./index.astro": "./index.astro",
|
|
@@ -34,16 +35,19 @@
|
|
|
34
35
|
},
|
|
35
36
|
"dependencies": {
|
|
36
37
|
"@astrojs/mdx": "^0.19.1",
|
|
38
|
+
"@astrojs/sitemap": "^1.3.1",
|
|
37
39
|
"@pagefind/default-ui": "^0.12.0",
|
|
38
40
|
"@types/mdast": "^3.0.11",
|
|
39
41
|
"bcp-47": "^2.1.0",
|
|
40
42
|
"execa": "^7.1.1",
|
|
41
43
|
"hastscript": "^7.2.0",
|
|
42
44
|
"pagefind": "^0.12.0",
|
|
45
|
+
"rehype": "^12.0.1",
|
|
43
46
|
"remark-directive": "^2.0.1",
|
|
44
47
|
"unified": "^10.1.2",
|
|
45
48
|
"unist-util-remove": "^3.1.1",
|
|
46
|
-
"unist-util-visit": "^4.1.2"
|
|
49
|
+
"unist-util-visit": "^4.1.2",
|
|
50
|
+
"vfile": "^5.3.7"
|
|
47
51
|
},
|
|
48
52
|
"scripts": {}
|
|
49
53
|
}
|
package/schema.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'astro/zod';
|
|
2
|
+
import { HeadConfigSchema } from './schemas/head';
|
|
2
3
|
|
|
3
4
|
export function docsSchema() {
|
|
4
5
|
return z.object({
|
|
@@ -19,5 +20,8 @@ export function docsSchema() {
|
|
|
19
20
|
* Can also be set to `false` to disable showing an edit link on this page.
|
|
20
21
|
*/
|
|
21
22
|
editUrl: z.union([z.string().url(), z.boolean()]).optional().default(true),
|
|
23
|
+
|
|
24
|
+
/** Set custom `<head>` tags just for this page. */
|
|
25
|
+
head: HeadConfigSchema(),
|
|
22
26
|
});
|
|
23
27
|
}
|
package/schemas/head.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from 'astro/zod';
|
|
2
|
+
|
|
3
|
+
export const HeadConfigSchema = () =>
|
|
4
|
+
z
|
|
5
|
+
.array(
|
|
6
|
+
z.object({
|
|
7
|
+
/** Name of the HTML tag to add to `<head>`, e.g. `'meta'`, `'link'`, or `'script'`. */
|
|
8
|
+
tag: z.enum([
|
|
9
|
+
'title',
|
|
10
|
+
'base',
|
|
11
|
+
'link',
|
|
12
|
+
'style',
|
|
13
|
+
'meta',
|
|
14
|
+
'script',
|
|
15
|
+
'noscript',
|
|
16
|
+
'template',
|
|
17
|
+
]),
|
|
18
|
+
/** Attributes to set on the tag, e.g. `{ rel: 'stylesheet', href: '/custom.css' }`. */
|
|
19
|
+
attrs: z
|
|
20
|
+
.record(z.union([z.string(), z.boolean(), z.undefined()]))
|
|
21
|
+
.default({}),
|
|
22
|
+
/** Content to place inside the tag (optional). */
|
|
23
|
+
content: z.string().default(''),
|
|
24
|
+
})
|
|
25
|
+
)
|
|
26
|
+
.default([]);
|
|
27
|
+
|
|
28
|
+
export type HeadUserConfig = z.input<ReturnType<typeof HeadConfigSchema>>;
|
|
29
|
+
export type HeadConfig = z.output<ReturnType<typeof HeadConfigSchema>>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { TabItemTagname } from './rehype-tabs';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
label: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { label } = Astro.props;
|
|
9
|
+
|
|
10
|
+
if (!label) {
|
|
11
|
+
throw new Error('Missing prop `label` on `<TabItem>` component.');
|
|
12
|
+
}
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<TabItemTagname data-label={label}>
|
|
16
|
+
<slot />
|
|
17
|
+
</TabItemTagname>
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { processPanels } from './rehype-tabs';
|
|
3
|
+
|
|
4
|
+
const panelHtml = await Astro.slots.render('default');
|
|
5
|
+
const { html, panels } = processPanels(panelHtml);
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<starlight-tabs>
|
|
9
|
+
{
|
|
10
|
+
panels && (
|
|
11
|
+
<div class="tablist-wrapper">
|
|
12
|
+
<ul role="tablist">
|
|
13
|
+
{panels.map(({ label, panelId, tabId }, idx) => (
|
|
14
|
+
<li role="presentation" class="tab">
|
|
15
|
+
<a
|
|
16
|
+
role="tab"
|
|
17
|
+
href={'#' + panelId}
|
|
18
|
+
id={tabId}
|
|
19
|
+
aria-selected={idx === 0 && 'true'}
|
|
20
|
+
tabindex={idx !== 0 ? -1 : 0}
|
|
21
|
+
>
|
|
22
|
+
{label}
|
|
23
|
+
</a>
|
|
24
|
+
</li>
|
|
25
|
+
))}
|
|
26
|
+
</ul>
|
|
27
|
+
</div>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
<Fragment set:html={html} />
|
|
31
|
+
</starlight-tabs>
|
|
32
|
+
|
|
33
|
+
<style>
|
|
34
|
+
starlight-tabs {
|
|
35
|
+
display: block;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.tablist-wrapper {
|
|
39
|
+
overflow-x: auto;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
[role='tablist'] {
|
|
43
|
+
display: flex;
|
|
44
|
+
list-style: none;
|
|
45
|
+
border-bottom: 2px solid var(--sl-color-gray-5);
|
|
46
|
+
padding: 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
[role='tablist'] .tab + .tab {
|
|
50
|
+
margin-top: 0;
|
|
51
|
+
}
|
|
52
|
+
.tab {
|
|
53
|
+
margin-bottom: -2px;
|
|
54
|
+
}
|
|
55
|
+
.tab > [role='tab'] {
|
|
56
|
+
display: block;
|
|
57
|
+
padding: 0 1.25rem;
|
|
58
|
+
text-decoration: none;
|
|
59
|
+
border-bottom: 2px solid var(--sl-color-gray-5);
|
|
60
|
+
color: var(--sl-color-gray-3);
|
|
61
|
+
}
|
|
62
|
+
.tab [role='tab'][aria-selected] {
|
|
63
|
+
color: var(--sl-color-white);
|
|
64
|
+
border-color: var(--sl-color-text-accent);
|
|
65
|
+
font-weight: 600;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.tablist-wrapper ~ :global([role='tabpanel']) {
|
|
69
|
+
margin-top: 1rem;
|
|
70
|
+
}
|
|
71
|
+
</style>
|
|
72
|
+
|
|
73
|
+
<script>
|
|
74
|
+
class StarlightTabs extends HTMLElement {
|
|
75
|
+
tabs: HTMLAnchorElement[];
|
|
76
|
+
panels: HTMLElement[];
|
|
77
|
+
|
|
78
|
+
constructor() {
|
|
79
|
+
super();
|
|
80
|
+
const tablist = this.querySelector<HTMLUListElement>('[role="tablist"]')!;
|
|
81
|
+
this.tabs = [
|
|
82
|
+
...tablist.querySelectorAll<HTMLAnchorElement>('[role="tab"]'),
|
|
83
|
+
];
|
|
84
|
+
this.panels = [
|
|
85
|
+
...this.querySelectorAll<HTMLElement>('[role="tabpanel"]'),
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
this.tabs.forEach((tab, i) => {
|
|
89
|
+
// Handle clicks for mouse users
|
|
90
|
+
tab.addEventListener('click', (e) => {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
const currentTab = tablist.querySelector('[aria-selected]');
|
|
93
|
+
if (e.currentTarget !== currentTab) {
|
|
94
|
+
this.switchTab(e.currentTarget as HTMLAnchorElement, i);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Handle keyboard input
|
|
99
|
+
tab.addEventListener('keydown', (e) => {
|
|
100
|
+
const index = this.tabs.indexOf(e.currentTarget as any);
|
|
101
|
+
// Work out which key the user is pressing and
|
|
102
|
+
// Calculate the new tab's index where appropriate
|
|
103
|
+
const dir =
|
|
104
|
+
e.key === 'ArrowLeft'
|
|
105
|
+
? index - 1
|
|
106
|
+
: e.key === 'ArrowRight'
|
|
107
|
+
? index + 1
|
|
108
|
+
: e.key === 'ArrowDown'
|
|
109
|
+
? 'down'
|
|
110
|
+
: null;
|
|
111
|
+
if (dir === null) return;
|
|
112
|
+
// If the down key is pressed, move focus to the open panel,
|
|
113
|
+
// otherwise switch to the adjacent tab
|
|
114
|
+
if (dir === 'down') {
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
this.panels[i]?.focus();
|
|
117
|
+
} else if (this.tabs[dir]) {
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
this.switchTab(this.tabs[dir], dir);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
switchTab(newTab: HTMLAnchorElement | null | undefined, index: number) {
|
|
126
|
+
if (!newTab) return;
|
|
127
|
+
|
|
128
|
+
// Mark all tabs as unselected and hide all tab panels.
|
|
129
|
+
this.tabs.forEach((tab) => {
|
|
130
|
+
tab.removeAttribute('aria-selected');
|
|
131
|
+
tab.setAttribute('tabindex', '-1');
|
|
132
|
+
});
|
|
133
|
+
this.panels.forEach((oldPanel) => {
|
|
134
|
+
oldPanel.hidden = true;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Show new panel and mark new tab as selected.
|
|
138
|
+
const newPanel = this.panels[index];
|
|
139
|
+
if (newPanel) newPanel.hidden = false;
|
|
140
|
+
// Restore active tab to the default tab order.
|
|
141
|
+
newTab.removeAttribute('tabindex');
|
|
142
|
+
newTab.setAttribute('aria-selected', 'true');
|
|
143
|
+
newTab.focus();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
customElements.define('starlight-tabs', StarlightTabs);
|
|
148
|
+
</script>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { rehype } from 'rehype';
|
|
2
|
+
import { CONTINUE, SKIP, visit } from 'unist-util-visit';
|
|
3
|
+
|
|
4
|
+
interface Panel {
|
|
5
|
+
panelId: string;
|
|
6
|
+
tabId: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
declare module 'vfile' {
|
|
11
|
+
interface DataMap {
|
|
12
|
+
panels: Panel[];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const TabItemTagname = 'starlight-tab-item';
|
|
17
|
+
|
|
18
|
+
let count = 0;
|
|
19
|
+
const getIDs = () => {
|
|
20
|
+
const id = count++;
|
|
21
|
+
return { panelId: 'tab-panel-' + id, tabId: 'tab-' + id };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Rehype processor to extract tab panel data and turn each
|
|
26
|
+
* `<starlight-tab-item>` into a `<section>` with the necessary
|
|
27
|
+
* attributes.
|
|
28
|
+
*/
|
|
29
|
+
const tabsProcessor = rehype()
|
|
30
|
+
.data('settings', { fragment: true })
|
|
31
|
+
.use(function tabs() {
|
|
32
|
+
return (tree, file) => {
|
|
33
|
+
file.data.panels = [];
|
|
34
|
+
let isFirst = true;
|
|
35
|
+
visit(tree, 'element', (node) => {
|
|
36
|
+
if (node.tagName !== TabItemTagname || !node.properties) {
|
|
37
|
+
return CONTINUE;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { dataLabel } = node.properties;
|
|
41
|
+
const ids = getIDs();
|
|
42
|
+
file.data.panels?.push({
|
|
43
|
+
...ids,
|
|
44
|
+
label: String(dataLabel),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Remove `<TabItem>` props
|
|
48
|
+
delete node.properties.dataLabel;
|
|
49
|
+
// Turn into `<section>` with required attributes
|
|
50
|
+
node.tagName = 'section';
|
|
51
|
+
node.properties.id = ids.panelId;
|
|
52
|
+
node.properties['aria-labelledby'] = ids.tabId;
|
|
53
|
+
node.properties.role = 'tabpanel';
|
|
54
|
+
node.properties.tabindex = -1;
|
|
55
|
+
// Hide all panels except the first
|
|
56
|
+
// TODO: make initially visible tab configurable
|
|
57
|
+
if (isFirst) {
|
|
58
|
+
isFirst = false;
|
|
59
|
+
} else {
|
|
60
|
+
node.properties.hidden = true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Skip over the tab panel’s children.
|
|
64
|
+
return SKIP;
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Process tab panel items to extract data for the tab links and format
|
|
71
|
+
* each tab panel correctly.
|
|
72
|
+
* @param html Inner HTML passed to the `<Tabs>` component.
|
|
73
|
+
*/
|
|
74
|
+
export const processPanels = (html: string) => {
|
|
75
|
+
const file = tabsProcessor.processSync({ value: html });
|
|
76
|
+
return {
|
|
77
|
+
/** Data for each tab panel. */
|
|
78
|
+
panels: file.data.panels,
|
|
79
|
+
/** Processed HTML for the tab panels. */
|
|
80
|
+
html: file.toString(),
|
|
81
|
+
};
|
|
82
|
+
};
|
package/utils/head.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { HeadConfig, HeadConfigSchema, HeadUserConfig } from '../schemas/head';
|
|
2
|
+
|
|
3
|
+
const HeadSchema = HeadConfigSchema();
|
|
4
|
+
|
|
5
|
+
/** Create a fully parsed, merged, and sorted head entry array from multiple sources. */
|
|
6
|
+
export function createHead(defaults: HeadUserConfig, ...heads: HeadConfig[]) {
|
|
7
|
+
let head = HeadSchema.parse(defaults);
|
|
8
|
+
for (const next of heads) {
|
|
9
|
+
head = mergeHead(head, next);
|
|
10
|
+
}
|
|
11
|
+
return sortHead(head);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Test if a head config object contains a matching `<title>` or `<meta>` tag.
|
|
16
|
+
*
|
|
17
|
+
* For example, will return true if `head` already contains
|
|
18
|
+
* `<meta name="description" content="A">` and the passed `tag`
|
|
19
|
+
* is `<meta name="description" content="B">`. Tests against `name`,
|
|
20
|
+
* `property`, and `http-equiv` attributes for `<meta>` tags.
|
|
21
|
+
*/
|
|
22
|
+
function hasTag(head: HeadConfig, entry: HeadConfig[number]): boolean {
|
|
23
|
+
switch (entry.tag) {
|
|
24
|
+
case 'title':
|
|
25
|
+
return head.some(({ tag }) => tag === 'title');
|
|
26
|
+
case 'meta':
|
|
27
|
+
return hasOneOf(head, entry, ['name', 'property', 'http-equiv']);
|
|
28
|
+
default:
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Test if a head config object contains a tag of the same type
|
|
35
|
+
* as `entry` and a matching attribute for one of the passed `keys`.
|
|
36
|
+
*/
|
|
37
|
+
function hasOneOf(
|
|
38
|
+
head: HeadConfig,
|
|
39
|
+
entry: HeadConfig[number],
|
|
40
|
+
keys: string[]
|
|
41
|
+
): boolean {
|
|
42
|
+
const attr = getAttr(keys, entry);
|
|
43
|
+
if (!attr) return false;
|
|
44
|
+
const [key, val] = attr;
|
|
45
|
+
return head.some(({ tag, attrs }) => tag === entry.tag && attrs[key] === val);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Find the first matching key–value pair in a head entry’s attributes. */
|
|
49
|
+
function getAttr(
|
|
50
|
+
keys: string[],
|
|
51
|
+
entry: HeadConfig[number]
|
|
52
|
+
): [key: string, value: string | boolean] | undefined {
|
|
53
|
+
let attr: [string, string | boolean] | undefined;
|
|
54
|
+
for (const key of keys) {
|
|
55
|
+
const val = entry.attrs[key];
|
|
56
|
+
if (val) {
|
|
57
|
+
attr = [key, val];
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return attr;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Merge two heads, overwriting entries in the first head that exist in the second. */
|
|
65
|
+
function mergeHead(oldHead: HeadConfig, newHead: HeadConfig) {
|
|
66
|
+
return [...oldHead.filter((tag) => !hasTag(newHead, tag)), ...newHead];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Sort head tags to place important tags first and relegate “SEO” meta tags. */
|
|
70
|
+
function sortHead(head: HeadConfig) {
|
|
71
|
+
return head.sort((a, b) => {
|
|
72
|
+
const aImportance = getImportance(a);
|
|
73
|
+
const bImportance = getImportance(b);
|
|
74
|
+
return aImportance > bImportance ? -1 : bImportance > aImportance ? 1 : 0;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Get the relative importance of a specific head tag. */
|
|
79
|
+
function getImportance(entry: HeadConfig[number]) {
|
|
80
|
+
// 1. Important meta tags.
|
|
81
|
+
if (
|
|
82
|
+
entry.tag === 'meta' &&
|
|
83
|
+
('charset' in entry.attrs ||
|
|
84
|
+
'http-equiv' in entry.attrs ||
|
|
85
|
+
entry.attrs.name === 'viewport')
|
|
86
|
+
) {
|
|
87
|
+
return 100;
|
|
88
|
+
}
|
|
89
|
+
// 2. Page title
|
|
90
|
+
if (entry.tag === 'title') return 90;
|
|
91
|
+
// 3. Anything that isn’t an SEO meta tag.
|
|
92
|
+
if (entry.tag !== 'meta') return 80;
|
|
93
|
+
// 4. SEO meta tags.
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
package/utils/user-config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'astro/zod';
|
|
2
2
|
import { parse as bcpParse, stringify as bcpStringify } from 'bcp-47';
|
|
3
|
+
import { HeadConfigSchema } from '../schemas/head';
|
|
3
4
|
|
|
4
5
|
const LocaleSchema = z.object({
|
|
5
6
|
/** The label for this language to show in UI, e.g. `"English"`, `"العربية"`, or `"简体中文"`. */
|
|
@@ -193,6 +194,28 @@ const UserConfigSchema = z.object({
|
|
|
193
194
|
/** Configure your site’s sidebar navigation items. */
|
|
194
195
|
sidebar: SidebarGroupSchema.array().optional(),
|
|
195
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Add extra tags to your site’s `<head>`.
|
|
199
|
+
*
|
|
200
|
+
* Can also be set for a single page in a page’s frontmatter.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* // Add Fathom analytics to your site
|
|
204
|
+
* starlight({
|
|
205
|
+
* head: [
|
|
206
|
+
* {
|
|
207
|
+
* tag: 'script',
|
|
208
|
+
* attrs: {
|
|
209
|
+
* src: 'https://cdn.usefathom.com/script.js',
|
|
210
|
+
* 'data-site': 'MY-FATHOM-ID',
|
|
211
|
+
* defer: true,
|
|
212
|
+
* },
|
|
213
|
+
* },
|
|
214
|
+
* ],
|
|
215
|
+
* })
|
|
216
|
+
*/
|
|
217
|
+
head: HeadConfigSchema(),
|
|
218
|
+
|
|
196
219
|
/**
|
|
197
220
|
* Provide CSS files to customize the look and feel of your Starlight site.
|
|
198
221
|
*
|