astro-pure 1.0.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/bun.lockb +0 -0
- package/bunfig.toml +2 -0
- package/components/advanced/Comment.astro +148 -0
- package/components/advanced/GithubCard.astro +148 -0
- package/components/advanced/LinkPreview.astro +82 -0
- package/components/advanced/MediumZoom.astro +50 -0
- package/components/advanced/QRCode.astro +35 -0
- package/components/advanced/Quote.astro +44 -0
- package/components/advanced/index.ts +11 -0
- package/components/user/Aside.astro +74 -0
- package/components/user/Button.astro +79 -0
- package/components/user/Card.astro +23 -0
- package/components/user/CardList.astro +28 -0
- package/components/user/CardListChildren.astro +24 -0
- package/components/user/Collapse.astro +84 -0
- package/components/user/FormattedDate.astro +21 -0
- package/components/user/Label.astro +18 -0
- package/components/user/Spoiler.astro +11 -0
- package/components/user/Steps.astro +84 -0
- package/components/user/TabItem.astro +18 -0
- package/components/user/Tabs.astro +266 -0
- package/components/user/Timeline.astro +38 -0
- package/components/user/index.ts +17 -0
- package/index.ts +74 -0
- package/package.json +38 -0
- package/plugins/link-preview.ts +110 -0
- package/plugins/rehype-steps.ts +98 -0
- package/plugins/rehype-tabs.ts +112 -0
- package/plugins/virtual-user-config.ts +83 -0
- package/schemas/favicon.ts +42 -0
- package/schemas/head.ts +18 -0
- package/schemas/logo.ts +28 -0
- package/schemas/social.ts +51 -0
- package/types/common.d.ts +48 -0
- package/types/index.d.ts +6 -0
- package/types/integrations-config.ts +43 -0
- package/types/theme-config.ts +125 -0
- package/types/user-config.ts +24 -0
- package/utils/clsx.ts +24 -0
- package/utils/collections.ts +48 -0
- package/utils/date.ts +17 -0
- package/utils/docsContents.ts +36 -0
- package/utils/index.ts +23 -0
- package/utils/module.d.ts +25 -0
- package/utils/server.ts +11 -0
- package/utils/tailwind.ts +7 -0
- package/utils/theme.ts +40 -0
- package/utils/toast.ts +3 -0
- package/utils/toc.ts +41 -0
- package/virtual.d.ts +2 -0
package/index.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { dirname, relative } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import type { AstroIntegration } from 'astro'
|
|
5
|
+
import mdx from '@astrojs/mdx'
|
|
6
|
+
import sitemap from '@astrojs/sitemap'
|
|
7
|
+
|
|
8
|
+
import { vitePluginUserConfig } from './plugins/virtual-user-config'
|
|
9
|
+
import type { UserConfig } from './types/user-config'
|
|
10
|
+
|
|
11
|
+
export default function AstroPureIntegration(opts: UserConfig): AstroIntegration {
|
|
12
|
+
let integrations: AstroIntegration[] = []
|
|
13
|
+
return {
|
|
14
|
+
name: 'astro-pure',
|
|
15
|
+
hooks: {
|
|
16
|
+
'astro:config:setup': async ({ config, injectRoute, updateConfig }) => {
|
|
17
|
+
// Add built-in integrations only if they are not already added by the user through the
|
|
18
|
+
// config or by a plugin.
|
|
19
|
+
const allIntegrations = [...config.integrations, ...integrations]
|
|
20
|
+
if (!allIntegrations.find(({ name }) => name === '@astrojs/sitemap')) {
|
|
21
|
+
integrations.push(sitemap())
|
|
22
|
+
}
|
|
23
|
+
if (!allIntegrations.find(({ name }) => name === '@astrojs/mdx')) {
|
|
24
|
+
integrations.push(mdx({ optimize: true }))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Add Starlight directives restoration integration at the end of the list so that remark
|
|
28
|
+
// plugins injected by Starlight plugins through Astro integrations can handle text and
|
|
29
|
+
// leaf directives before they are transformed back to their original form.
|
|
30
|
+
// integrations.push(starlightDirectivesRestorationIntegration())
|
|
31
|
+
|
|
32
|
+
// Add integrations immediately after Starlight in the config array.
|
|
33
|
+
// e.g. if a user has `integrations: [starlight(), tailwind()]`, then the order will be
|
|
34
|
+
// `[starlight(), expressiveCode(), sitemap(), mdx(), tailwind()]`.
|
|
35
|
+
// This ensures users can add integrations before/after Starlight and we respect that order.
|
|
36
|
+
const selfIndex = config.integrations.findIndex((i) => i.name === 'astro-pure')
|
|
37
|
+
config.integrations.splice(selfIndex + 1, 0, ...integrations)
|
|
38
|
+
|
|
39
|
+
updateConfig({
|
|
40
|
+
vite: {
|
|
41
|
+
// @ts-ignore
|
|
42
|
+
plugins: [vitePluginUserConfig(opts, config)]
|
|
43
|
+
},
|
|
44
|
+
markdown: {
|
|
45
|
+
remarkPlugins: [
|
|
46
|
+
// ...starlightAsides({ starlightConfig, astroConfig: config, useTranslations })
|
|
47
|
+
]
|
|
48
|
+
// rehypePlugins: [rehypeRtlCodeSupport()],
|
|
49
|
+
// shikiConfig:
|
|
50
|
+
// Configure Shiki theme if the user is using the default github-dark theme.
|
|
51
|
+
// config.markdown.shikiConfig.theme !== 'github-dark' ? {} : { theme: 'css-variables' }
|
|
52
|
+
},
|
|
53
|
+
scopedStyleStrategy: 'where',
|
|
54
|
+
// If not already configured, default to prefetching all links on hover.
|
|
55
|
+
prefetch: config.prefetch ?? { prefetchAll: true }
|
|
56
|
+
})
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
'astro:build:done': ({ dir }) => {
|
|
60
|
+
if (!opts.integ.pagefind) return
|
|
61
|
+
const targetDir = fileURLToPath(dir)
|
|
62
|
+
const cwd = dirname(fileURLToPath(import.meta.url))
|
|
63
|
+
const relativeDir = relative(cwd, targetDir)
|
|
64
|
+
return new Promise<void>((resolve) => {
|
|
65
|
+
spawn('npx', ['-y', 'pagefind', '--site', relativeDir], {
|
|
66
|
+
stdio: 'inherit',
|
|
67
|
+
shell: true,
|
|
68
|
+
cwd
|
|
69
|
+
}).on('close', () => resolve())
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "astro-pure",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A simple, clean but powerful blog theme build by astro.",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
7
|
+
},
|
|
8
|
+
"keywords": [
|
|
9
|
+
"Astro",
|
|
10
|
+
"Theme",
|
|
11
|
+
"Blog"
|
|
12
|
+
],
|
|
13
|
+
"author": "CWorld",
|
|
14
|
+
"license": "Apache-2.0",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/cworld1/astro-theme-pure",
|
|
18
|
+
"directory": "packages/pure"
|
|
19
|
+
},
|
|
20
|
+
"bugs": "https://github.com/cworld1/astro-theme-pure",
|
|
21
|
+
"homepage": "https://astro-theme-pure.vercel.app/",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": "./index.ts",
|
|
25
|
+
"./user": "./components/user/index.ts",
|
|
26
|
+
"./advanced": "./components/advanced/index.ts",
|
|
27
|
+
"./utils": "./utils/index.ts",
|
|
28
|
+
"./locals": "./locals.ts",
|
|
29
|
+
"./props": "./props.ts",
|
|
30
|
+
"./schema": "./schema.ts",
|
|
31
|
+
"./types": "./types.ts"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@astrojs/mdx": "^4.0.1",
|
|
35
|
+
"@astrojs/sitemap": "^3.2.1",
|
|
36
|
+
"astro": "^5.0.3"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { parse as htmlParser } from 'node-html-parser'
|
|
2
|
+
|
|
3
|
+
class LRU<K, V> extends Map<K, V> {
|
|
4
|
+
constructor(private readonly maxSize: number) {
|
|
5
|
+
super()
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
override get(key: K): V | undefined {
|
|
9
|
+
const value = super.get(key)
|
|
10
|
+
if (value) this.#touch(key, value)
|
|
11
|
+
return value
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
override set(key: K, value: V): this {
|
|
15
|
+
this.#touch(key, value)
|
|
16
|
+
if (this.size > this.maxSize) {
|
|
17
|
+
const firstKey = this.keys().next().value
|
|
18
|
+
if (firstKey !== undefined) this.delete(firstKey)
|
|
19
|
+
}
|
|
20
|
+
return this
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
#touch(key: K, value: V): void {
|
|
24
|
+
this.delete(key)
|
|
25
|
+
super.set(key, value)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const formatError = (...lines: string[]) => lines.join('\n ')
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Fetch a URL and parse it as JSON, but catch errors to stop builds erroring.
|
|
33
|
+
* @param url URL to fetch
|
|
34
|
+
* @returns {Promise<Record<string, unknown> | undefined>}
|
|
35
|
+
*/
|
|
36
|
+
export const safeGet = makeSafeGetter<Record<string, unknown>>((res) => res.json())
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fetch a URL and parse it as HTML, but catch errors to stop builds erroring.
|
|
40
|
+
* @param url URL to fetch
|
|
41
|
+
* @returns {Promise<Document | undefined>}
|
|
42
|
+
*/
|
|
43
|
+
const safeGetDOM = makeSafeGetter(async (res) => htmlParser.parse(await res.text()))
|
|
44
|
+
|
|
45
|
+
/** Factory to create safe, caching fetch functions. */
|
|
46
|
+
function makeSafeGetter<T>(
|
|
47
|
+
handleResponse: (res: Response) => T | Promise<T>,
|
|
48
|
+
{ cacheSize = 1000 }: { cacheSize?: number } = {}
|
|
49
|
+
) {
|
|
50
|
+
const cache = new LRU<string, T>(cacheSize)
|
|
51
|
+
return async function safeGet(url: string): Promise<T | undefined> {
|
|
52
|
+
try {
|
|
53
|
+
const cached = cache.get(url)
|
|
54
|
+
if (cached) return cached
|
|
55
|
+
const response = await fetch(url)
|
|
56
|
+
if (!response.ok)
|
|
57
|
+
throw new Error(
|
|
58
|
+
formatError(`Failed to fetch ${url}`, `Error ${response.status}: ${response.statusText}`)
|
|
59
|
+
)
|
|
60
|
+
const result = await handleResponse(response)
|
|
61
|
+
cache.set(url, result)
|
|
62
|
+
return result
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.error(formatError(`[error] astro-embed`, (e as Error)?.message ?? e, `URL: ${url}`))
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Helper to get the `content` attribute of an element. */
|
|
71
|
+
const getContent = (el: HTMLElement | null) => el?.getAttribute('content')
|
|
72
|
+
/** Helper to filter out insecure or non-absolute URLs. */
|
|
73
|
+
const urlOrNull = (url: string | null | undefined) => (url?.slice(0, 8) === 'https://' ? url : null)
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Loads and parses an HTML page to return Open Graph metadata.
|
|
77
|
+
* @param pageUrl URL to parse
|
|
78
|
+
*/
|
|
79
|
+
async function parseOpenGraph(pageUrl: string) {
|
|
80
|
+
const html = await safeGetDOM(pageUrl)
|
|
81
|
+
if (!html) return
|
|
82
|
+
|
|
83
|
+
const getMetaProperty = (prop: string) =>
|
|
84
|
+
getContent(html.querySelector(`meta[property=${JSON.stringify(prop)}]`) as HTMLElement | null)
|
|
85
|
+
const getMetaName = (name: string) =>
|
|
86
|
+
getContent(html.querySelector(`meta[name=${JSON.stringify(name)}]`) as HTMLElement | null)
|
|
87
|
+
|
|
88
|
+
const title = getMetaProperty('og:title') || html.querySelector('title')?.textContent
|
|
89
|
+
const description = getMetaProperty('og:description') || getMetaName('description')
|
|
90
|
+
const image = urlOrNull(
|
|
91
|
+
getMetaProperty('og:image:secure_url') ||
|
|
92
|
+
getMetaProperty('og:image:url') ||
|
|
93
|
+
getMetaProperty('og:image')
|
|
94
|
+
)
|
|
95
|
+
const imageAlt = getMetaProperty('og:image:alt')
|
|
96
|
+
const video = urlOrNull(
|
|
97
|
+
getMetaProperty('og:video:secure_url') ||
|
|
98
|
+
getMetaProperty('og:video:url') ||
|
|
99
|
+
getMetaProperty('og:video')
|
|
100
|
+
)
|
|
101
|
+
const videoType = getMetaProperty('og:video:type')
|
|
102
|
+
const url =
|
|
103
|
+
urlOrNull(
|
|
104
|
+
getMetaProperty('og:url') || html.querySelector("link[rel='canonical']")?.getAttribute('href')
|
|
105
|
+
) || pageUrl
|
|
106
|
+
|
|
107
|
+
return { title, description, image, imageAlt, url, video, videoType }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export { safeGetDOM, parseOpenGraph }
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// https://github.com/withastro/starlight/blob/main/packages/starlight/user-components/rehype-steps.ts
|
|
2
|
+
|
|
3
|
+
import { AstroError } from 'astro/errors'
|
|
4
|
+
import type { Element, Root } from 'hast'
|
|
5
|
+
import { rehype } from 'rehype'
|
|
6
|
+
import type { VFile } from 'vfile'
|
|
7
|
+
|
|
8
|
+
const prettyPrintProcessor = rehype().data('settings', { fragment: true })
|
|
9
|
+
|
|
10
|
+
const prettyPrintHtml = (html: string) =>
|
|
11
|
+
prettyPrintProcessor.processSync({ value: html }).toString()
|
|
12
|
+
|
|
13
|
+
const stepsProcessor = rehype()
|
|
14
|
+
.data('settings', { fragment: true })
|
|
15
|
+
|
|
16
|
+
.use(function steps() {
|
|
17
|
+
return (tree: Root, vfile: VFile) => {
|
|
18
|
+
const rootElements = tree.children.filter((item): item is Element => item.type === 'element')
|
|
19
|
+
|
|
20
|
+
const [rootElement] = rootElements
|
|
21
|
+
|
|
22
|
+
if (!rootElement) {
|
|
23
|
+
throw new StepsError(
|
|
24
|
+
'The `<Steps>` component expects its content to be a single ordered list (`<ol>`) but found no child elements.'
|
|
25
|
+
)
|
|
26
|
+
} else if (rootElements.length > 1) {
|
|
27
|
+
throw new StepsError(
|
|
28
|
+
'The `<Steps>` component expects its content to be a single ordered list (`<ol>`) but found multiple child elements: ' +
|
|
29
|
+
rootElements.map((element: Element) => `\`<${element.tagName}>\``).join(', ') +
|
|
30
|
+
'.',
|
|
31
|
+
|
|
32
|
+
vfile.value.toString()
|
|
33
|
+
)
|
|
34
|
+
} else if (rootElement.tagName !== 'ol') {
|
|
35
|
+
throw new StepsError(
|
|
36
|
+
'The `<Steps>` component expects its content to be a single ordered list (`<ol>`) but found the following element: ' +
|
|
37
|
+
`\`<${rootElement.tagName}>\`.`,
|
|
38
|
+
|
|
39
|
+
vfile.value.toString()
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Ensure `role="list"` is set on the ordered list.
|
|
44
|
+
|
|
45
|
+
// We use `list-style: none` in the styles for this component and need to ensure the list
|
|
46
|
+
|
|
47
|
+
// retains its semantics in Safari, which will remove them otherwise.
|
|
48
|
+
|
|
49
|
+
rootElement.properties.role = 'list'
|
|
50
|
+
|
|
51
|
+
// Add the required CSS class name, preserving existing classes if present.
|
|
52
|
+
|
|
53
|
+
if (!Array.isArray(rootElement.properties.className)) {
|
|
54
|
+
rootElement.properties.className = ['sl-steps']
|
|
55
|
+
} else {
|
|
56
|
+
rootElement.properties.className.push('sl-steps')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Add the `start` attribute as a CSS custom property so we can use it as the starting index
|
|
60
|
+
|
|
61
|
+
// of the steps custom counter.
|
|
62
|
+
|
|
63
|
+
if (typeof rootElement.properties.start === 'number') {
|
|
64
|
+
const styles = [`--sl-steps-start: ${rootElement.properties.start - 1}`]
|
|
65
|
+
|
|
66
|
+
if (rootElement.properties.style) styles.push(String(rootElement.properties.style))
|
|
67
|
+
|
|
68
|
+
rootElement.properties.style = styles.join(';')
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
|
|
75
|
+
* Process steps children: validates the HTML and adds `role="list"` to the ordered list.
|
|
76
|
+
|
|
77
|
+
* @param html Inner HTML passed to the `<Steps>` component.
|
|
78
|
+
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
export const processSteps = (html: string | undefined) => {
|
|
82
|
+
const file = stepsProcessor.processSync({ value: html })
|
|
83
|
+
|
|
84
|
+
return { html: file.toString() }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
class StepsError extends AstroError {
|
|
88
|
+
constructor(message: string, html?: string) {
|
|
89
|
+
let hint =
|
|
90
|
+
'To learn more about the `<Steps>` component, see https://starlight.astro.build/components/steps/'
|
|
91
|
+
|
|
92
|
+
if (html) {
|
|
93
|
+
hint += '\n\nFull HTML passed to `<Steps>`:\n' + prettyPrintHtml(html)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
super(message, hint)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Element } from 'hast'
|
|
2
|
+
import { select } from 'hast-util-select'
|
|
3
|
+
import { rehype } from 'rehype'
|
|
4
|
+
import { CONTINUE, SKIP, visit } from 'unist-util-visit'
|
|
5
|
+
|
|
6
|
+
interface Panel {
|
|
7
|
+
panelId: string
|
|
8
|
+
tabId: string
|
|
9
|
+
label: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
declare module 'vfile' {
|
|
13
|
+
interface DataMap {
|
|
14
|
+
panels: Panel[]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const TabItemTagname = 'starlight-tab-item'
|
|
19
|
+
|
|
20
|
+
// https://github.com/adobe/react-spectrum/blob/99ca82e87ba2d7fdd54f5b49326fd242320b4b51/packages/%40react-aria/focus/src/FocusScope.tsx#L256-L275
|
|
21
|
+
const focusableElementSelectors = [
|
|
22
|
+
'input:not([disabled]):not([type=hidden])',
|
|
23
|
+
'select:not([disabled])',
|
|
24
|
+
'textarea:not([disabled])',
|
|
25
|
+
'button:not([disabled])',
|
|
26
|
+
'a[href]',
|
|
27
|
+
'area[href]',
|
|
28
|
+
'summary',
|
|
29
|
+
'iframe',
|
|
30
|
+
'object',
|
|
31
|
+
'embed',
|
|
32
|
+
'audio[controls]',
|
|
33
|
+
'video[controls]',
|
|
34
|
+
'[contenteditable]',
|
|
35
|
+
'[tabindex]:not([disabled])'
|
|
36
|
+
]
|
|
37
|
+
.map((selector) => `${selector}:not([hidden]):not([tabindex="-1"])`)
|
|
38
|
+
.join(',')
|
|
39
|
+
|
|
40
|
+
let count = 0
|
|
41
|
+
const getIDs = () => {
|
|
42
|
+
const id = count++
|
|
43
|
+
return { panelId: 'tab-panel-' + id, tabId: 'tab-' + id }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Rehype processor to extract tab panel data and turn each
|
|
48
|
+
* `<starlight-tab-item>` into a `<div>` with the necessary
|
|
49
|
+
* attributes.
|
|
50
|
+
*/
|
|
51
|
+
const tabsProcessor = rehype()
|
|
52
|
+
.data('settings', { fragment: true })
|
|
53
|
+
.use(function tabs() {
|
|
54
|
+
return (tree: Element, file) => {
|
|
55
|
+
file.data.panels = []
|
|
56
|
+
let isFirst = true
|
|
57
|
+
visit(tree, 'element', (node) => {
|
|
58
|
+
if (node.tagName !== TabItemTagname || !node.properties) {
|
|
59
|
+
return CONTINUE
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const { dataLabel } = node.properties
|
|
63
|
+
const ids = getIDs()
|
|
64
|
+
const panel: Panel = {
|
|
65
|
+
...ids,
|
|
66
|
+
label: String(dataLabel)
|
|
67
|
+
}
|
|
68
|
+
file.data.panels?.push(panel)
|
|
69
|
+
|
|
70
|
+
// Remove `<TabItem>` props
|
|
71
|
+
delete node.properties.dataLabel
|
|
72
|
+
// Turn into `<div>` with required attributes
|
|
73
|
+
node.tagName = 'div'
|
|
74
|
+
node.properties.id = ids.panelId
|
|
75
|
+
node.properties['aria-labelledby'] = ids.tabId
|
|
76
|
+
node.properties.role = 'tabpanel'
|
|
77
|
+
|
|
78
|
+
const focusableChild = select(focusableElementSelectors, node)
|
|
79
|
+
// If the panel does not contain any focusable elements, include it in
|
|
80
|
+
// the tab sequence of the page.
|
|
81
|
+
if (!focusableChild) {
|
|
82
|
+
node.properties.tabindex = 0
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Hide all panels except the first
|
|
86
|
+
// TODO: make initially visible tab configurable
|
|
87
|
+
if (isFirst) {
|
|
88
|
+
isFirst = false
|
|
89
|
+
} else {
|
|
90
|
+
node.properties.hidden = true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Skip over the tab panel’s children.
|
|
94
|
+
return SKIP
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Process tab panel items to extract data for the tab links and format
|
|
101
|
+
* each tab panel correctly.
|
|
102
|
+
* @param html Inner HTML passed to the `<Tabs>` component.
|
|
103
|
+
*/
|
|
104
|
+
export const processPanels = (html: string) => {
|
|
105
|
+
const file = tabsProcessor.processSync({ value: html })
|
|
106
|
+
return {
|
|
107
|
+
/** Data for each tab panel. */
|
|
108
|
+
panels: file.data.panels,
|
|
109
|
+
/** Processed HTML for the tab panels. */
|
|
110
|
+
html: file.toString()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import { dirname, resolve } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import type { AstroConfig, ViteUserConfig } from 'astro'
|
|
5
|
+
|
|
6
|
+
import type { UserConfig } from '../types/user-config'
|
|
7
|
+
|
|
8
|
+
function resolveVirtualModuleId<T extends string>(id: T): `\0${T}` {
|
|
9
|
+
return `\0${id}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
13
|
+
const __dirname = dirname(__filename)
|
|
14
|
+
const virtualUtilsPath = resolve(__dirname, '../virtual.d.ts')
|
|
15
|
+
|
|
16
|
+
/** Vite plugin that exposes Starlight user config and project context via virtual modules. */
|
|
17
|
+
export function vitePluginUserConfig(
|
|
18
|
+
opts: UserConfig,
|
|
19
|
+
{
|
|
20
|
+
build,
|
|
21
|
+
root,
|
|
22
|
+
srcDir,
|
|
23
|
+
trailingSlash
|
|
24
|
+
}: Pick<AstroConfig, 'root' | 'srcDir' | 'trailingSlash'> & {
|
|
25
|
+
build: Pick<AstroConfig['build'], 'format'>
|
|
26
|
+
}
|
|
27
|
+
): NonNullable<ViteUserConfig['plugins']>[number] {
|
|
28
|
+
/**
|
|
29
|
+
* Resolves module IDs to a usable format:
|
|
30
|
+
* - Relative paths (e.g. `'./module.js'`) are resolved against `base` and formatted as an absolute path.
|
|
31
|
+
* - Package identifiers (e.g. `'module'`) are returned unchanged.
|
|
32
|
+
*
|
|
33
|
+
* By default, `base` is the project root directory.
|
|
34
|
+
*/
|
|
35
|
+
const resolveId = (id: string, base = root) =>
|
|
36
|
+
JSON.stringify(id.startsWith('.') ? resolve(fileURLToPath(base), id) : id)
|
|
37
|
+
|
|
38
|
+
/** Map of virtual module names to their code contents as strings. */
|
|
39
|
+
const modules = {
|
|
40
|
+
'virtual:config': `export default ${JSON.stringify(opts)}`,
|
|
41
|
+
'virtual:starlight/project-context': `export default ${JSON.stringify({
|
|
42
|
+
build: { format: build.format },
|
|
43
|
+
root,
|
|
44
|
+
srcDir,
|
|
45
|
+
trailingSlash
|
|
46
|
+
})}`,
|
|
47
|
+
'virtual:starlight/user-css': opts.customCss.map((id) => `import ${resolveId(id)};`).join(''),
|
|
48
|
+
'virtual:starlight/user-images': opts.logo
|
|
49
|
+
? 'src' in opts.logo
|
|
50
|
+
? `import src from ${resolveId(
|
|
51
|
+
opts.logo.src
|
|
52
|
+
)}; export const logos = { dark: src, light: src };`
|
|
53
|
+
: `import dark from ${resolveId(opts.logo.dark)}; import light from ${resolveId(
|
|
54
|
+
opts.logo.light
|
|
55
|
+
)}; export const logos = { dark, light };`
|
|
56
|
+
: 'export const logos = {};',
|
|
57
|
+
'virtual:starlight/collection-config': `let userCollections;
|
|
58
|
+
try {
|
|
59
|
+
userCollections = (await import(${resolveId('./content/config.ts', srcDir)})).collections;
|
|
60
|
+
} catch {}
|
|
61
|
+
export const collections = userCollections;`,
|
|
62
|
+
'../../utils': fs.readFileSync(virtualUtilsPath, 'utf-8')
|
|
63
|
+
} satisfies Record<string, string>
|
|
64
|
+
|
|
65
|
+
/** Mapping names prefixed with `\0` to their original form. */
|
|
66
|
+
const resolutionMap = Object.fromEntries(
|
|
67
|
+
(Object.keys(modules) as (keyof typeof modules)[]).map((key) => [
|
|
68
|
+
resolveVirtualModuleId(key),
|
|
69
|
+
key
|
|
70
|
+
])
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
name: 'vite-plugin-user-config',
|
|
75
|
+
resolveId(id): string | void {
|
|
76
|
+
if (id in modules) return resolveVirtualModuleId(id)
|
|
77
|
+
},
|
|
78
|
+
load(id): string | void {
|
|
79
|
+
const resolution = resolutionMap[id]
|
|
80
|
+
if (resolution) return modules[resolution]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { extname } from 'node:path'
|
|
2
|
+
import { z } from 'astro/zod'
|
|
3
|
+
|
|
4
|
+
const faviconTypeMap = {
|
|
5
|
+
'.ico': 'image/x-icon',
|
|
6
|
+
'.gif': 'image/gif',
|
|
7
|
+
'.jpeg': 'image/jpeg',
|
|
8
|
+
'.jpg': 'image/jpeg',
|
|
9
|
+
'.png': 'image/png',
|
|
10
|
+
'.svg': 'image/svg+xml'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const FaviconSchema = () =>
|
|
14
|
+
z
|
|
15
|
+
.string()
|
|
16
|
+
.default('/favicon.svg')
|
|
17
|
+
.transform((favicon, ctx) => {
|
|
18
|
+
// favicon can be absolute or relative url
|
|
19
|
+
const { pathname } = new URL(favicon, 'https://example.com')
|
|
20
|
+
const ext = extname(pathname).toLowerCase()
|
|
21
|
+
|
|
22
|
+
if (!isFaviconExt(ext)) {
|
|
23
|
+
ctx.addIssue({
|
|
24
|
+
code: z.ZodIssueCode.custom,
|
|
25
|
+
message: 'favicon must be a .ico, .gif, .jpg, .png, or .svg file'
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
return z.NEVER
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
href: favicon,
|
|
33
|
+
type: faviconTypeMap[ext]
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
.describe(
|
|
37
|
+
'The default favicon for your site which should be a path to an image in the `public/` directory.'
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
function isFaviconExt(ext: string): ext is keyof typeof faviconTypeMap {
|
|
41
|
+
return ext in faviconTypeMap
|
|
42
|
+
}
|
package/schemas/head.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
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(['title', 'base', 'link', 'style', 'meta', 'script', 'noscript', 'template']),
|
|
9
|
+
/** Attributes to set on the tag, e.g. `{ rel: 'stylesheet', href: '/custom.css' }`. */
|
|
10
|
+
attrs: z.record(z.union([z.string(), z.boolean(), z.undefined()])).default({}),
|
|
11
|
+
/** Content to place inside the tag (optional). */
|
|
12
|
+
content: z.string().default('')
|
|
13
|
+
})
|
|
14
|
+
)
|
|
15
|
+
.default([])
|
|
16
|
+
|
|
17
|
+
export type HeadUserConfig = z.input<ReturnType<typeof HeadConfigSchema>>
|
|
18
|
+
export type HeadConfig = z.output<ReturnType<typeof HeadConfigSchema>>
|
package/schemas/logo.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from 'astro/zod'
|
|
2
|
+
|
|
3
|
+
export const LogoConfigSchema = () =>
|
|
4
|
+
z
|
|
5
|
+
.union([
|
|
6
|
+
z.object({
|
|
7
|
+
/** Source of the image file to use. */
|
|
8
|
+
src: z.string(),
|
|
9
|
+
/** Alternative text description of the logo. */
|
|
10
|
+
alt: z.string().default(''),
|
|
11
|
+
/** Set to `true` to hide the site title text and only show the logo. */
|
|
12
|
+
replacesTitle: z.boolean().default(false)
|
|
13
|
+
}),
|
|
14
|
+
z.object({
|
|
15
|
+
/** Source of the image file to use in dark mode. */
|
|
16
|
+
dark: z.string(),
|
|
17
|
+
/** Source of the image file to use in light mode. */
|
|
18
|
+
light: z.string(),
|
|
19
|
+
/** Alternative text description of the logo. */
|
|
20
|
+
alt: z.string().default(''),
|
|
21
|
+
/** Set to `true` to hide the site title text and only show the logo. */
|
|
22
|
+
replacesTitle: z.boolean().default(false)
|
|
23
|
+
})
|
|
24
|
+
])
|
|
25
|
+
.optional()
|
|
26
|
+
|
|
27
|
+
export type LogoUserConfig = z.input<ReturnType<typeof LogoConfigSchema>>
|
|
28
|
+
export type LogoConfig = z.output<ReturnType<typeof LogoConfigSchema>>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from 'astro/zod'
|
|
2
|
+
|
|
3
|
+
export const socialLinks = [
|
|
4
|
+
'github',
|
|
5
|
+
'gitlab',
|
|
6
|
+
'discord',
|
|
7
|
+
'youtube',
|
|
8
|
+
'instagram',
|
|
9
|
+
'x',
|
|
10
|
+
'telegram',
|
|
11
|
+
'rss',
|
|
12
|
+
'facebook',
|
|
13
|
+
'email',
|
|
14
|
+
'reddit',
|
|
15
|
+
'blueSky',
|
|
16
|
+
'tiktok'
|
|
17
|
+
] as const
|
|
18
|
+
|
|
19
|
+
export const SocialLinksSchema = () =>
|
|
20
|
+
z
|
|
21
|
+
.record(
|
|
22
|
+
z.enum(socialLinks),
|
|
23
|
+
// Link to the respective social profile for this site
|
|
24
|
+
z.string().url()
|
|
25
|
+
)
|
|
26
|
+
.transform((links) => {
|
|
27
|
+
const labelledLinks: Partial<Record<keyof typeof links, { label: string; url: string }>> = {}
|
|
28
|
+
for (const _k in links) {
|
|
29
|
+
const key = _k as keyof typeof links
|
|
30
|
+
const url = links[key]
|
|
31
|
+
if (!url) continue
|
|
32
|
+
const label = {
|
|
33
|
+
github: 'GitHub',
|
|
34
|
+
gitlab: 'GitLab',
|
|
35
|
+
discord: 'Discord',
|
|
36
|
+
youtube: 'YouTube',
|
|
37
|
+
instagram: 'Instagram',
|
|
38
|
+
x: 'X',
|
|
39
|
+
telegram: 'Telegram',
|
|
40
|
+
rss: 'RSS',
|
|
41
|
+
facebook: 'Facebook',
|
|
42
|
+
email: 'Email',
|
|
43
|
+
reddit: 'Reddit',
|
|
44
|
+
blueSky: 'BlueSky',
|
|
45
|
+
tiktok: 'TikTok'
|
|
46
|
+
}[key]
|
|
47
|
+
labelledLinks[key] = { label, url }
|
|
48
|
+
}
|
|
49
|
+
return labelledLinks
|
|
50
|
+
})
|
|
51
|
+
.optional()
|