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 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()}&nbsp;
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)
@@ -0,0 +1,7 @@
1
+ ---
2
+ import type { Props } from '@astrojs/starlight/props'
3
+
4
+ import HeadingBadgesMobileTableOfContents from '../components/HeadingBadgesMobileTableOfContents.astro'
5
+ ---
6
+
7
+ <HeadingBadgesMobileTableOfContents {...Astro.props} />
@@ -0,0 +1,7 @@
1
+ ---
2
+ import type { Props } from '@astrojs/starlight/props'
3
+
4
+ import HeadingBadgesTableOfContents from '../components/HeadingBadgesTableOfContents.astro'
5
+ ---
6
+
7
+ <HeadingBadgesTableOfContents {...Astro.props} />
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
+ }