starlight-heading-badges 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.
- package/LICENSE +21 -0
- package/README.md +29 -0
- package/components/HeadingBadgesMobileTableOfContents.astro +178 -0
- package/components/HeadingBadgesTableOfContents.astro +25 -0
- package/components/TableOfContentHeading.astro +31 -0
- package/components/TableOfContentsList.astro +86 -0
- package/index.ts +27 -0
- package/libs/badge.ts +37 -0
- package/libs/integration.ts +21 -0
- package/libs/plugin.ts +30 -0
- package/libs/rehype.ts +42 -0
- package/libs/remark.ts +64 -0
- package/libs/starlight-toc.ts +116 -0
- package/overrides/MobileTableOfContents.astro +7 -0
- package/overrides/TableOfContents.astro +7 -0
- package/package.json +57 -0
- package/styles.css +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-present, HiDeoo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>starlight-heading-badges 🔖</h1>
|
|
3
|
+
<p>Starlight plugin to add badges to your Markdown and MDX headings.</p>
|
|
4
|
+
<p>
|
|
5
|
+
<a href="https://i.imgur.com/jVkJd9U.png" title="Screenshot of the Starlight Heading Badges plugin">
|
|
6
|
+
<img alt="Screenshot of the Starlight Heading Badges plugin" src="https://i.imgur.com/jVkJd9U.png" width="520" />
|
|
7
|
+
</a>
|
|
8
|
+
</p>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div align="center">
|
|
12
|
+
<a href="https://github.com/HiDeoo/starlight-heading-badges/actions/workflows/integration.yml">
|
|
13
|
+
<img alt="Integration Status" src="https://github.com/HiDeoo/starlight-heading-badges/actions/workflows/integration.yml/badge.svg" />
|
|
14
|
+
</a>
|
|
15
|
+
<a href="https://github.com/HiDeoo/starlight-heading-badges/blob/main/LICENSE">
|
|
16
|
+
<img alt="License" src="https://badgen.net/github/license/HiDeoo/starlight-heading-badges" />
|
|
17
|
+
</a>
|
|
18
|
+
<br />
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
## Getting Started
|
|
22
|
+
|
|
23
|
+
Want to get started immediately? Check out the [getting started guide](https://starlight-heading-badges.vercel.app/getting-started/) or check out the [demo](https://starlight-heading-badges.vercel.app/demo/) to see the theme in action.
|
|
24
|
+
|
|
25
|
+
## License
|
|
26
|
+
|
|
27
|
+
Licensed under the MIT License, Copyright © HiDeoo.
|
|
28
|
+
|
|
29
|
+
See [LICENSE](https://github.com/HiDeoo/starlight-heading-badges/blob/main/LICENSE) for more information.
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A copy of the Starlight `<MobileTableOfContents>` component with support for badges.
|
|
4
|
+
* @see https://github.com/withastro/starlight/blob/1d32c8b0cec0ea5f66351b21b91748dfa78b404b/packages/starlight/components/MobileTableOfContents.astro
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Icon } from '@astrojs/starlight/components'
|
|
8
|
+
import type { Props } from '@astrojs/starlight/props'
|
|
9
|
+
|
|
10
|
+
import TableOfContentsList from './TableOfContentsList.astro'
|
|
11
|
+
|
|
12
|
+
const { labels, toc } = Astro.props
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
toc && (
|
|
17
|
+
<mobile-starlight-toc data-min-h={toc.minHeadingLevel} data-max-h={toc.maxHeadingLevel}>
|
|
18
|
+
<nav aria-labelledby="starlight__on-this-page--mobile">
|
|
19
|
+
<details id="starlight__mobile-toc">
|
|
20
|
+
<summary id="starlight__on-this-page--mobile" class="sl-flex">
|
|
21
|
+
<div class="toggle sl-flex">
|
|
22
|
+
{labels['tableOfContents.onThisPage']}
|
|
23
|
+
<Icon name={'right-caret'} class="caret" size="1rem" />
|
|
24
|
+
</div>
|
|
25
|
+
<span class="display-current" />
|
|
26
|
+
</summary>
|
|
27
|
+
<div class="dropdown">
|
|
28
|
+
<TableOfContentsList toc={toc.items} isMobile />
|
|
29
|
+
</div>
|
|
30
|
+
</details>
|
|
31
|
+
</nav>
|
|
32
|
+
</mobile-starlight-toc>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
<style>
|
|
37
|
+
nav {
|
|
38
|
+
position: fixed;
|
|
39
|
+
z-index: var(--sl-z-index-toc);
|
|
40
|
+
top: calc(var(--sl-nav-height) - 1px);
|
|
41
|
+
inset-inline: 0;
|
|
42
|
+
border-top: 1px solid var(--sl-color-gray-5);
|
|
43
|
+
background-color: var(--sl-color-bg-nav);
|
|
44
|
+
}
|
|
45
|
+
@media (min-width: 50rem) {
|
|
46
|
+
nav {
|
|
47
|
+
inset-inline-start: var(--sl-content-inline-start, 0);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
summary {
|
|
52
|
+
gap: 0.5rem;
|
|
53
|
+
align-items: center;
|
|
54
|
+
height: var(--sl-mobile-toc-height);
|
|
55
|
+
border-bottom: 1px solid var(--sl-color-hairline-shade);
|
|
56
|
+
padding: 0.5rem 1rem;
|
|
57
|
+
font-size: var(--sl-text-xs);
|
|
58
|
+
outline-offset: var(--sl-outline-offset-inside);
|
|
59
|
+
}
|
|
60
|
+
summary::marker,
|
|
61
|
+
summary::-webkit-details-marker {
|
|
62
|
+
display: none;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.toggle {
|
|
66
|
+
flex-shrink: 0;
|
|
67
|
+
gap: 1rem;
|
|
68
|
+
align-items: center;
|
|
69
|
+
justify-content: space-between;
|
|
70
|
+
border: 1px solid var(--sl-color-gray-5);
|
|
71
|
+
border-radius: 0.5rem;
|
|
72
|
+
padding-block: 0.5rem;
|
|
73
|
+
padding-inline-start: 0.75rem;
|
|
74
|
+
padding-inline-end: 0.5rem;
|
|
75
|
+
line-height: 1;
|
|
76
|
+
background-color: var(--sl-color-black);
|
|
77
|
+
user-select: none;
|
|
78
|
+
cursor: pointer;
|
|
79
|
+
}
|
|
80
|
+
details[open] .toggle {
|
|
81
|
+
color: var(--sl-color-white);
|
|
82
|
+
border-color: var(--sl-color-accent);
|
|
83
|
+
}
|
|
84
|
+
details .toggle:hover {
|
|
85
|
+
color: var(--sl-color-white);
|
|
86
|
+
border-color: var(--sl-color-gray-2);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
:global([dir='rtl']) .caret {
|
|
90
|
+
transform: rotateZ(180deg);
|
|
91
|
+
}
|
|
92
|
+
details[open] .caret {
|
|
93
|
+
transform: rotateZ(90deg);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.display-current {
|
|
97
|
+
white-space: nowrap;
|
|
98
|
+
text-overflow: ellipsis;
|
|
99
|
+
overflow: hidden;
|
|
100
|
+
color: var(--sl-color-white);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.dropdown {
|
|
104
|
+
--border-top: 1px;
|
|
105
|
+
margin-top: calc(-1 * var(--border-top));
|
|
106
|
+
border: var(--border-top) solid var(--sl-color-gray-6);
|
|
107
|
+
border-top-color: var(--sl-color-hairline-shade);
|
|
108
|
+
max-height: calc(85vh - var(--sl-nav-height) - var(--sl-mobile-toc-height));
|
|
109
|
+
overflow-y: auto;
|
|
110
|
+
background-color: var(--sl-color-black);
|
|
111
|
+
box-shadow: var(--sl-shadow-md);
|
|
112
|
+
overscroll-behavior: contain;
|
|
113
|
+
}
|
|
114
|
+
</style>
|
|
115
|
+
|
|
116
|
+
<script>
|
|
117
|
+
import { deserializeBadge } from '../libs/badge'
|
|
118
|
+
import { StarlightTOC } from '../libs/starlight-toc'
|
|
119
|
+
|
|
120
|
+
class MobileStarlightTOC extends StarlightTOC {
|
|
121
|
+
override set current(link: HTMLAnchorElement) {
|
|
122
|
+
super.current = link
|
|
123
|
+
const display = this.querySelector('.display-current') as HTMLSpanElement
|
|
124
|
+
if (display) {
|
|
125
|
+
const heading = link.dataset['shbHeading']
|
|
126
|
+
if (heading) {
|
|
127
|
+
const badge = deserializeBadge(heading)
|
|
128
|
+
if (badge) {
|
|
129
|
+
display.textContent = ''
|
|
130
|
+
display.append(document.createTextNode(`${badge.heading} `))
|
|
131
|
+
const span = document.createElement('span')
|
|
132
|
+
span.textContent = badge.text
|
|
133
|
+
span.dataset['shbBadge'] = ''
|
|
134
|
+
span.dataset['shbBadgeVariant'] = badge.variant
|
|
135
|
+
display.append(span)
|
|
136
|
+
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
display.textContent = link.textContent
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
override get current(): HTMLAnchorElement | null {
|
|
145
|
+
return super.current
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
constructor() {
|
|
149
|
+
super()
|
|
150
|
+
const details = this.querySelector('details')
|
|
151
|
+
if (!details) return
|
|
152
|
+
const closeToC = () => {
|
|
153
|
+
details.open = false
|
|
154
|
+
}
|
|
155
|
+
// Close the table of contents whenever a link is clicked.
|
|
156
|
+
for (const link of details.querySelectorAll('a')) {
|
|
157
|
+
link.addEventListener('click', closeToC)
|
|
158
|
+
}
|
|
159
|
+
// Close the table of contents when a user clicks outside of it.
|
|
160
|
+
window.addEventListener('click', (e) => {
|
|
161
|
+
if (!details.contains(e.target as Node)) closeToC()
|
|
162
|
+
})
|
|
163
|
+
// Or when they press the escape key.
|
|
164
|
+
window.addEventListener('keydown', (e) => {
|
|
165
|
+
if (e.key === 'Escape' && details.open) {
|
|
166
|
+
const hasFocus = details.contains(document.activeElement)
|
|
167
|
+
closeToC()
|
|
168
|
+
if (hasFocus) {
|
|
169
|
+
const summary = details.querySelector('summary')
|
|
170
|
+
if (summary) summary.focus()
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
customElements.define('mobile-starlight-toc', MobileStarlightTOC)
|
|
178
|
+
</script>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A copy of the Starlight `<TableOfContents>` component with support for badges.
|
|
4
|
+
* @see https://github.com/withastro/starlight/blob/1d32c8b0cec0ea5f66351b21b91748dfa78b404b/packages/starlight/components/TableOfContents.astro
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Props } from '@astrojs/starlight/props'
|
|
8
|
+
|
|
9
|
+
import TableOfContentsList from './TableOfContentsList.astro'
|
|
10
|
+
|
|
11
|
+
const { labels, toc } = Astro.props
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
toc && (
|
|
16
|
+
<starlight-toc data-min-h={toc.minHeadingLevel} data-max-h={toc.maxHeadingLevel}>
|
|
17
|
+
<nav aria-labelledby="starlight__on-this-page">
|
|
18
|
+
<h2 id="starlight__on-this-page">{labels['tableOfContents.onThisPage']}</h2>
|
|
19
|
+
<TableOfContentsList toc={toc.items} />
|
|
20
|
+
</nav>
|
|
21
|
+
</starlight-toc>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
<script src="../libs/starlight-toc"></script>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Badge } from '@astrojs/starlight/components'
|
|
3
|
+
|
|
4
|
+
import { deserializeBadge } from '../libs/badge'
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
text: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { text } = Astro.props
|
|
11
|
+
|
|
12
|
+
const badge = deserializeBadge(text)
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
badge ? (
|
|
17
|
+
<span class="shb-heading">
|
|
18
|
+
{badge.heading.trim()}
|
|
19
|
+
<Badge size="small" text={badge.text} variant={badge.variant} />
|
|
20
|
+
</span>
|
|
21
|
+
) : (
|
|
22
|
+
<span>{text}</span>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
<style>
|
|
27
|
+
.shb-heading .sl-badge {
|
|
28
|
+
font-size: 0.75rem;
|
|
29
|
+
padding: 0.0625rem 0.25rem;
|
|
30
|
+
}
|
|
31
|
+
</style>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A copy of the Starlight `<TableOfContentsList>` component with support for badges.
|
|
4
|
+
* @see https://github.com/withastro/starlight/blob/1d32c8b0cec0ea5f66351b21b91748dfa78b404b/packages/starlight/components/TableOfContents/TableOfContentsList.astro
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { MarkdownHeading } from 'astro'
|
|
8
|
+
|
|
9
|
+
import TableOfContentHeading from './TableOfContentHeading.astro'
|
|
10
|
+
|
|
11
|
+
interface TocItem extends MarkdownHeading {
|
|
12
|
+
children: TocItem[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
toc: TocItem[]
|
|
17
|
+
depth?: number
|
|
18
|
+
isMobile?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { toc, isMobile = false, depth = 0 } = Astro.props
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<ul class:list={{ isMobile }}>
|
|
25
|
+
{
|
|
26
|
+
toc.map((heading) => (
|
|
27
|
+
<li>
|
|
28
|
+
<a href={`#${heading.slug}`} data-shb-heading={heading.text}>
|
|
29
|
+
<TableOfContentHeading text={heading.text} />
|
|
30
|
+
</a>
|
|
31
|
+
{heading.children.length > 0 && <Astro.self toc={heading.children} depth={depth + 1} isMobile={isMobile} />}
|
|
32
|
+
</li>
|
|
33
|
+
))
|
|
34
|
+
}
|
|
35
|
+
</ul>
|
|
36
|
+
|
|
37
|
+
<style define:vars={{ depth }}>
|
|
38
|
+
ul {
|
|
39
|
+
padding: 0;
|
|
40
|
+
list-style: none;
|
|
41
|
+
}
|
|
42
|
+
a {
|
|
43
|
+
--pad-inline: 0.5rem;
|
|
44
|
+
display: block;
|
|
45
|
+
border-radius: 0.25rem;
|
|
46
|
+
padding-block: 0.25rem;
|
|
47
|
+
padding-inline: calc(1rem * var(--depth) + var(--pad-inline)) var(--pad-inline);
|
|
48
|
+
line-height: 1.25;
|
|
49
|
+
}
|
|
50
|
+
a[aria-current='true'] {
|
|
51
|
+
color: var(--sl-color-text-accent);
|
|
52
|
+
}
|
|
53
|
+
.isMobile a {
|
|
54
|
+
--pad-inline: 1rem;
|
|
55
|
+
display: flex;
|
|
56
|
+
justify-content: space-between;
|
|
57
|
+
gap: var(--pad-inline);
|
|
58
|
+
border-top: 1px solid var(--sl-color-gray-6);
|
|
59
|
+
border-radius: 0;
|
|
60
|
+
padding-block: 0.5rem;
|
|
61
|
+
color: var(--sl-color-text);
|
|
62
|
+
font-size: var(--sl-text-sm);
|
|
63
|
+
text-decoration: none;
|
|
64
|
+
outline-offset: var(--sl-outline-offset-inside);
|
|
65
|
+
}
|
|
66
|
+
.isMobile:first-child > li:first-child > a {
|
|
67
|
+
border-top: 0;
|
|
68
|
+
}
|
|
69
|
+
.isMobile a[aria-current='true'],
|
|
70
|
+
.isMobile a[aria-current='true']:hover,
|
|
71
|
+
.isMobile a[aria-current='true']:focus {
|
|
72
|
+
color: var(--sl-color-white);
|
|
73
|
+
background-color: unset;
|
|
74
|
+
}
|
|
75
|
+
.isMobile a[aria-current='true']::after {
|
|
76
|
+
content: '';
|
|
77
|
+
width: 1rem;
|
|
78
|
+
background-color: var(--sl-color-text-accent);
|
|
79
|
+
/* Check mark SVG icon */
|
|
80
|
+
-webkit-mask-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAxNCAxNCc+PHBhdGggZD0nTTEwLjkxNCA0LjIwNmEuNTgzLjU4MyAwIDAgMC0uODI4IDBMNS43NCA4LjU1NyAzLjkxNCA2LjcyNmEuNTk2LjU5NiAwIDAgMC0uODI4Ljg1N2wyLjI0IDIuMjRhLjU4My41ODMgMCAwIDAgLjgyOCAwbDQuNzYtNC43NmEuNTgzLjU4MyAwIDAgMCAwLS44NTdaJy8+PC9zdmc+Cg==');
|
|
81
|
+
mask-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAxNCAxNCc+PHBhdGggZD0nTTEwLjkxNCA0LjIwNmEuNTgzLjU4MyAwIDAgMC0uODI4IDBMNS43NCA4LjU1NyAzLjkxNCA2LjcyNmEuNTk2LjU5NiAwIDAgMC0uODI4Ljg1N2wyLjI0IDIuMjRhLjU4My41ODMgMCAwIDAgLjgyOCAwbDQuNzYtNC43NmEuNTgzLjU4MyAwIDAgMCAwLS44NTdaJy8+PC9zdmc+Cg==');
|
|
82
|
+
-webkit-mask-repeat: no-repeat;
|
|
83
|
+
mask-repeat: no-repeat;
|
|
84
|
+
flex-shrink: 0;
|
|
85
|
+
}
|
|
86
|
+
</style>
|
package/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { StarlightPlugin } from '@astrojs/starlight/types'
|
|
2
|
+
|
|
3
|
+
import { starlightHeadingBadgesIntegration } from './libs/integration'
|
|
4
|
+
import { overrideComponents } from './libs/plugin'
|
|
5
|
+
|
|
6
|
+
export default function starlightHeadingBadgesPlugin(): StarlightPlugin {
|
|
7
|
+
return {
|
|
8
|
+
name: 'starlight-heading-badges-plugin',
|
|
9
|
+
hooks: {
|
|
10
|
+
setup({ addIntegration, config: starlightConfig, logger, updateConfig }) {
|
|
11
|
+
addIntegration(starlightHeadingBadgesIntegration())
|
|
12
|
+
|
|
13
|
+
updateConfig({
|
|
14
|
+
components: overrideComponents(
|
|
15
|
+
starlightConfig,
|
|
16
|
+
[
|
|
17
|
+
{ name: 'MobileTableOfContents', fallback: 'HeadingBadgesMobileTableOfContents' },
|
|
18
|
+
{ name: 'TableOfContents', fallback: 'HeadingBadgesTableOfContents' },
|
|
19
|
+
],
|
|
20
|
+
logger,
|
|
21
|
+
),
|
|
22
|
+
customCss: [...(starlightConfig.customCss ?? []), 'starlight-heading-badges/styles.css'],
|
|
23
|
+
})
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
}
|
package/libs/badge.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const BadgeDirectiveName = 'badge'
|
|
2
|
+
|
|
3
|
+
const variants = ['caution', 'danger', 'default', 'note', 'success', 'tip'] as const
|
|
4
|
+
|
|
5
|
+
const serializedBadgeDelimiter = '__SHB__'
|
|
6
|
+
|
|
7
|
+
export function isBadgeVariant(value: string): value is Variant {
|
|
8
|
+
return variants.includes(value as Variant)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function serializeBadge(variant: Variant, text: string) {
|
|
12
|
+
return [serializedBadgeDelimiter, variant, serializedBadgeDelimiter, text, serializedBadgeDelimiter].join('')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function deserializeBadge(value: string): Badge | undefined {
|
|
16
|
+
const serializeBadge = value.split(' ').pop()
|
|
17
|
+
if (!serializeBadge) return
|
|
18
|
+
|
|
19
|
+
const parts = serializeBadge.split(serializedBadgeDelimiter)
|
|
20
|
+
const [, variant, text] = parts
|
|
21
|
+
|
|
22
|
+
if (!variant || !isBadgeVariant(variant) || !text) return undefined
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
heading: value.replace(new RegExp(`${serializedBadgeDelimiter}.*${serializedBadgeDelimiter}`), ''),
|
|
26
|
+
text,
|
|
27
|
+
variant,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type Variant = (typeof variants)[number]
|
|
32
|
+
|
|
33
|
+
export interface Badge {
|
|
34
|
+
heading: string
|
|
35
|
+
text: string
|
|
36
|
+
variant: Variant
|
|
37
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { rehypeHeadingIds } from '@astrojs/markdown-remark'
|
|
2
|
+
import type { AstroIntegration } from 'astro'
|
|
3
|
+
|
|
4
|
+
import { rehypeStarlightHeadingBadges } from './rehype'
|
|
5
|
+
import { remarkStarlightHeadingBadges } from './remark'
|
|
6
|
+
|
|
7
|
+
export function starlightHeadingBadgesIntegration(): AstroIntegration {
|
|
8
|
+
return {
|
|
9
|
+
name: 'starlight-heading-badges-integration',
|
|
10
|
+
hooks: {
|
|
11
|
+
'astro:config:setup': ({ updateConfig }) => {
|
|
12
|
+
updateConfig({
|
|
13
|
+
markdown: {
|
|
14
|
+
rehypePlugins: [rehypeHeadingIds, rehypeStarlightHeadingBadges],
|
|
15
|
+
remarkPlugins: [remarkStarlightHeadingBadges],
|
|
16
|
+
},
|
|
17
|
+
})
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
}
|
package/libs/plugin.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { StarlightPlugin } from '@astrojs/starlight/types'
|
|
2
|
+
import type { AstroIntegrationLogger } from 'astro'
|
|
3
|
+
|
|
4
|
+
export function overrideComponents(
|
|
5
|
+
starlightConfig: StarlightUserConfig,
|
|
6
|
+
overrides: ComponentOverride[],
|
|
7
|
+
logger: AstroIntegrationLogger,
|
|
8
|
+
): StarlightUserConfig['components'] {
|
|
9
|
+
const components = { ...starlightConfig.components }
|
|
10
|
+
|
|
11
|
+
for (const { name, fallback } of overrides) {
|
|
12
|
+
if (starlightConfig.components?.[name]) {
|
|
13
|
+
logger.warn(`A \`<${name}>\` component override is already defined in your Starlight configuration.`)
|
|
14
|
+
logger.warn(
|
|
15
|
+
`To use \`starlight-heading-badges\`, either remove this override or manually render the content from \`starlight-heading-badges/components/${fallback}.astro\`.`,
|
|
16
|
+
)
|
|
17
|
+
continue
|
|
18
|
+
}
|
|
19
|
+
components[name] = `starlight-heading-badges/overrides/${name}.astro`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return components
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ComponentOverride {
|
|
26
|
+
name: keyof NonNullable<StarlightUserConfig['components']>
|
|
27
|
+
fallback: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type StarlightUserConfig = Parameters<StarlightPlugin['hooks']['setup']>['0']['config']
|
package/libs/rehype.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import 'mdast-util-directive'
|
|
2
|
+
|
|
3
|
+
import type { ElementContent, Root } from 'hast'
|
|
4
|
+
import { CONTINUE, SKIP, visit } from 'unist-util-visit'
|
|
5
|
+
|
|
6
|
+
import { deserializeBadge, type Badge } from './badge'
|
|
7
|
+
|
|
8
|
+
export function rehypeStarlightHeadingBadges() {
|
|
9
|
+
return function transformer(tree: Root) {
|
|
10
|
+
visit(tree, (node, index, parent) => {
|
|
11
|
+
if (node.type === 'text') {
|
|
12
|
+
if (index === undefined || !parent) return CONTINUE
|
|
13
|
+
|
|
14
|
+
const badge = deserializeBadge(node.value)
|
|
15
|
+
if (!badge) return SKIP
|
|
16
|
+
|
|
17
|
+
if (badge.heading) {
|
|
18
|
+
node.value = badge.heading
|
|
19
|
+
parent.children.splice(index + 1, 0, createBadgeNode(badge))
|
|
20
|
+
} else {
|
|
21
|
+
parent.children.splice(index, 1, createBadgeNode(badge))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return SKIP
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return CONTINUE
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createBadgeNode(badge: Badge): ElementContent {
|
|
33
|
+
return {
|
|
34
|
+
type: 'element',
|
|
35
|
+
tagName: 'span',
|
|
36
|
+
properties: {
|
|
37
|
+
'data-shb-badge': '',
|
|
38
|
+
'data-shb-badge-variant': badge.variant,
|
|
39
|
+
},
|
|
40
|
+
children: [{ type: 'text', value: badge.text }],
|
|
41
|
+
}
|
|
42
|
+
}
|
package/libs/remark.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import 'mdast-util-directive'
|
|
2
|
+
|
|
3
|
+
import GithubSlugger from 'github-slugger'
|
|
4
|
+
import type { Root } from 'mdast'
|
|
5
|
+
import { CONTINUE, SKIP, visit } from 'unist-util-visit'
|
|
6
|
+
|
|
7
|
+
import { BadgeDirectiveName, isBadgeVariant, serializeBadge, type Variant } from './badge'
|
|
8
|
+
|
|
9
|
+
export function remarkStarlightHeadingBadges() {
|
|
10
|
+
return function transformer(tree: Root) {
|
|
11
|
+
const slugger = new GithubSlugger()
|
|
12
|
+
|
|
13
|
+
visit(tree, (node, index, parent) => {
|
|
14
|
+
if (!parent || typeof index !== 'number' || parent.type !== 'heading') return CONTINUE
|
|
15
|
+
|
|
16
|
+
if (index === 0) {
|
|
17
|
+
let headingText = ''
|
|
18
|
+
|
|
19
|
+
visit(parent, (headingNode, _, headingParent) => {
|
|
20
|
+
if (
|
|
21
|
+
headingParent?.type !== 'textDirective' &&
|
|
22
|
+
(headingNode.type === 'text' || headingNode.type === 'inlineCode')
|
|
23
|
+
) {
|
|
24
|
+
headingText += headingNode.value
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
headingText = headingText.trim()
|
|
29
|
+
|
|
30
|
+
if (!headingText) return CONTINUE
|
|
31
|
+
|
|
32
|
+
parent.data ??= {}
|
|
33
|
+
parent.data.hProperties ??= {}
|
|
34
|
+
parent.data.hProperties['id'] = slugger.slug(headingText)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (node.type !== 'textDirective' || node.name !== BadgeDirectiveName) return CONTINUE
|
|
38
|
+
|
|
39
|
+
const contentNode = node.children[0]
|
|
40
|
+
if (!contentNode || contentNode.type !== 'text' || contentNode.value.length === 0) return CONTINUE
|
|
41
|
+
|
|
42
|
+
let variant: Variant = 'default'
|
|
43
|
+
|
|
44
|
+
if (node.attributes?.['variant']) {
|
|
45
|
+
if (isBadgeVariant(node.attributes['variant'])) {
|
|
46
|
+
variant = node.attributes['variant']
|
|
47
|
+
} else {
|
|
48
|
+
return CONTINUE
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (parent.children.length !== index + 1) {
|
|
53
|
+
parent.children.splice(parent.children.length, 0, { type: 'text', value: ' ' })
|
|
54
|
+
}
|
|
55
|
+
parent.children.splice(index, 1)
|
|
56
|
+
parent.children.splice(parent.children.length, 0, {
|
|
57
|
+
type: 'text',
|
|
58
|
+
value: serializeBadge(variant, contentNode.value),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
return SKIP
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A copy of the Starlight `<starlight-toc>` component with support for badges.
|
|
3
|
+
* @see https://github.com/withastro/starlight/blob/1d32c8b0cec0ea5f66351b21b91748dfa78b404b/packages/starlight/components/TableOfContents/starlight-toc.ts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const PAGE_TITLE_ID = '_top'
|
|
7
|
+
|
|
8
|
+
export class StarlightTOC extends HTMLElement {
|
|
9
|
+
private _current = this.querySelector<HTMLAnchorElement>('a[aria-current="true"]')
|
|
10
|
+
private minH = Number.parseInt(this.dataset['minH'] ?? '2', 10)
|
|
11
|
+
private maxH = Number.parseInt(this.dataset['maxH'] ?? '3', 10)
|
|
12
|
+
|
|
13
|
+
protected set current(link: HTMLAnchorElement) {
|
|
14
|
+
if (link === this._current) return
|
|
15
|
+
if (this._current) this._current.removeAttribute('aria-current')
|
|
16
|
+
link.setAttribute('aria-current', 'true')
|
|
17
|
+
this._current = link
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
protected get current(): HTMLAnchorElement | null {
|
|
21
|
+
return this._current
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
constructor() {
|
|
25
|
+
super()
|
|
26
|
+
|
|
27
|
+
/** All the links in the table of contents. */
|
|
28
|
+
const links = [...this.querySelectorAll('a')]
|
|
29
|
+
|
|
30
|
+
/** Walk up the DOM to find the nearest heading. */
|
|
31
|
+
const getElementHeading = (el: Element | null): HTMLHeadingElement | null => {
|
|
32
|
+
if (!el) return null
|
|
33
|
+
const origin = el
|
|
34
|
+
while (el) {
|
|
35
|
+
if (this.isHeading(el)) return el
|
|
36
|
+
// Assign the previous sibling’s last, most deeply nested child to el.
|
|
37
|
+
el = el.previousElementSibling
|
|
38
|
+
while (el?.lastElementChild) {
|
|
39
|
+
el = el.lastElementChild
|
|
40
|
+
}
|
|
41
|
+
// Look for headings amongst siblings.
|
|
42
|
+
const h = getElementHeading(el)
|
|
43
|
+
if (h) return h
|
|
44
|
+
}
|
|
45
|
+
// Walk back up the parent.
|
|
46
|
+
return getElementHeading(origin.parentElement)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Handle intersections and set the current link to the heading for the current intersection. */
|
|
50
|
+
const setCurrent: IntersectionObserverCallback = (entries) => {
|
|
51
|
+
for (const { isIntersecting, target } of entries) {
|
|
52
|
+
if (!isIntersecting) continue
|
|
53
|
+
const heading = getElementHeading(target)
|
|
54
|
+
if (!heading) continue
|
|
55
|
+
const link = links.find((link) => link.hash === `#${encodeURIComponent(heading.id)}`)
|
|
56
|
+
if (link) {
|
|
57
|
+
this.current = link
|
|
58
|
+
break
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Observe elements with an `id` (most likely headings) and their siblings.
|
|
64
|
+
// Also observe direct children of `.content` to include elements before
|
|
65
|
+
// the first heading.
|
|
66
|
+
const toObserve = document.querySelectorAll('main [id], main [id] ~ *, main .content > *')
|
|
67
|
+
|
|
68
|
+
let observer: IntersectionObserver | undefined
|
|
69
|
+
const observe = () => {
|
|
70
|
+
if (observer) observer.disconnect()
|
|
71
|
+
observer = new IntersectionObserver(setCurrent, { rootMargin: this.getRootMargin() })
|
|
72
|
+
for (const element of toObserve) observer.observe(element)
|
|
73
|
+
}
|
|
74
|
+
observe()
|
|
75
|
+
|
|
76
|
+
// `requestIdleCallback` is not available in Safari.
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
78
|
+
const onIdle = window.requestIdleCallback || ((cb) => setTimeout(cb, 1))
|
|
79
|
+
let timeout: NodeJS.Timeout
|
|
80
|
+
window.addEventListener('resize', () => {
|
|
81
|
+
// Disable intersection observer while window is resizing.
|
|
82
|
+
if (observer) observer.disconnect()
|
|
83
|
+
clearTimeout(timeout)
|
|
84
|
+
timeout = setTimeout(() => onIdle(observe), 200)
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Test if an element is a table-of-contents heading. */
|
|
89
|
+
private isHeading = (el: Element): el is HTMLHeadingElement => {
|
|
90
|
+
if (el instanceof HTMLHeadingElement) {
|
|
91
|
+
// Special case for page title h1
|
|
92
|
+
if (el.id === PAGE_TITLE_ID) return true
|
|
93
|
+
// Check the heading level is within the user-configured limits for the ToC
|
|
94
|
+
const level = el.tagName[1]
|
|
95
|
+
if (level) {
|
|
96
|
+
const int = Number.parseInt(level, 10)
|
|
97
|
+
if (int >= this.minH && int <= this.maxH) return true
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private getRootMargin(): `-${number}px 0% ${number}px` {
|
|
104
|
+
const navBarHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0
|
|
105
|
+
// `<summary>` only exists in mobile ToC, so will fall back to 0 in large viewport component.
|
|
106
|
+
const mobileTocHeight = this.querySelector('summary')?.getBoundingClientRect().height ?? 0
|
|
107
|
+
/** Start intersections at nav height + 2rem padding. */
|
|
108
|
+
const top = navBarHeight + mobileTocHeight + 32
|
|
109
|
+
/** End intersections `53px` later. This is slightly more than the maximum `margin-top` in Markdown content. */
|
|
110
|
+
const bottom = top + 53
|
|
111
|
+
const height = document.documentElement.clientHeight
|
|
112
|
+
return `-${top}px 0% ${bottom - height}px`
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
customElements.define('starlight-toc', StarlightTOC)
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "starlight-heading-badges",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Starlight plugin to add badges to your Markdown and MDX headings.",
|
|
6
|
+
"author": "HiDeoo <github@hideoo.dev> (https://hideoo.dev)",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.ts",
|
|
10
|
+
"./components/HeadingBadgesMobileTableOfContents.astro": "./components/HeadingBadgesMobileTableOfContents.astro",
|
|
11
|
+
"./components/HeadingBadgesTableOfContents.astro": "./components/HeadingBadgesTableOfContents.astro",
|
|
12
|
+
"./overrides/MobileTableOfContents.astro": "./overrides/MobileTableOfContents.astro",
|
|
13
|
+
"./overrides/TableOfContents.astro": "./overrides/TableOfContents.astro",
|
|
14
|
+
"./styles.css": "./styles.css",
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@astrojs/markdown-remark": "^5.1.1",
|
|
19
|
+
"github-slugger": "^2.0.0",
|
|
20
|
+
"unist-util-visit": "^5.0.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@playwright/test": "^1.45.0",
|
|
24
|
+
"@types/hast": "^3.0.4",
|
|
25
|
+
"@types/mdast": "^4.0.4",
|
|
26
|
+
"mdast-util-directive": "^3.0.0"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@astrojs/starlight": ">=0.24.5"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"sideEffects": false,
|
|
38
|
+
"keywords": [
|
|
39
|
+
"starlight",
|
|
40
|
+
"plugin",
|
|
41
|
+
"badges",
|
|
42
|
+
"headings",
|
|
43
|
+
"documentation",
|
|
44
|
+
"astro"
|
|
45
|
+
],
|
|
46
|
+
"homepage": "https://github.com/HiDeoo/starlight-heading-badges",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "https://github.com/HiDeoo/starlight-heading-badges.git",
|
|
50
|
+
"directory": "packages/starlight-heading-badges"
|
|
51
|
+
},
|
|
52
|
+
"bugs": "https://github.com/HiDeoo/starlight-heading-badges/issues",
|
|
53
|
+
"scripts": {
|
|
54
|
+
"test": "playwright install --with-deps chromium && playwright test",
|
|
55
|
+
"lint": "prettier -c --cache . && eslint . --cache --max-warnings=0"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/styles.css
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
span[data-shb-badge] {
|
|
2
|
+
background-color: var(--sl-color-bg-badge);
|
|
3
|
+
border: 1px solid var(--sl-color-border-badge);
|
|
4
|
+
border-radius: 0.25rem;
|
|
5
|
+
color: var(--sl-color-text-badge);
|
|
6
|
+
display: inline-block;
|
|
7
|
+
font-family: var(--sl-font-system-mono);
|
|
8
|
+
font-size: var(--sl-text-sm);
|
|
9
|
+
line-height: normal;
|
|
10
|
+
overflow-wrap: anywhere;
|
|
11
|
+
padding: 0.175rem 0.35rem;
|
|
12
|
+
vertical-align: middle;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
:is(h5, h6) span[data-shb-badge] {
|
|
16
|
+
font-size: var(--sl-text-xs);
|
|
17
|
+
padding: 0.0625rem 0.25rem;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
mobile-starlight-toc span[data-shb-badge] {
|
|
21
|
+
font-size: 0.75rem;
|
|
22
|
+
margin-inline-start: 1ch;
|
|
23
|
+
padding: 0.0625rem 0.25rem;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
span[data-shb-badge-variant='default'] {
|
|
27
|
+
--sl-color-bg-badge: var(--sl-badge-default-bg);
|
|
28
|
+
--sl-color-border-badge: var(--sl-badge-default-border);
|
|
29
|
+
--sl-color-text-badge: var(--sl-badge-default-text);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
span[data-shb-badge-variant='note'] {
|
|
33
|
+
--sl-color-bg-badge: var(--sl-badge-note-bg);
|
|
34
|
+
--sl-color-border-badge: var(--sl-badge-note-border);
|
|
35
|
+
--sl-color-text-badge: var(--sl-badge-note-text);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
span[data-shb-badge-variant='danger'] {
|
|
39
|
+
--sl-color-bg-badge: var(--sl-badge-danger-bg);
|
|
40
|
+
--sl-color-border-badge: var(--sl-badge-danger-border);
|
|
41
|
+
--sl-color-text-badge: var(--sl-badge-danger-text);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
span[data-shb-badge-variant='success'] {
|
|
45
|
+
--sl-color-bg-badge: var(--sl-badge-success-bg);
|
|
46
|
+
--sl-color-border-badge: var(--sl-badge-success-border);
|
|
47
|
+
--sl-color-text-badge: var(--sl-badge-success-text);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
span[data-shb-badge-variant='tip'] {
|
|
51
|
+
--sl-color-bg-badge: var(--sl-badge-tip-bg);
|
|
52
|
+
--sl-color-border-badge: var(--sl-badge-tip-border);
|
|
53
|
+
--sl-color-text-badge: var(--sl-badge-tip-text);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
span[data-shb-badge-variant='caution'] {
|
|
57
|
+
--sl-color-bg-badge: var(--sl-badge-caution-bg);
|
|
58
|
+
--sl-color-border-badge: var(--sl-badge-caution-border);
|
|
59
|
+
--sl-color-text-badge: var(--sl-badge-caution-text);
|
|
60
|
+
}
|