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.
Files changed (50) hide show
  1. package/bun.lockb +0 -0
  2. package/bunfig.toml +2 -0
  3. package/components/advanced/Comment.astro +148 -0
  4. package/components/advanced/GithubCard.astro +148 -0
  5. package/components/advanced/LinkPreview.astro +82 -0
  6. package/components/advanced/MediumZoom.astro +50 -0
  7. package/components/advanced/QRCode.astro +35 -0
  8. package/components/advanced/Quote.astro +44 -0
  9. package/components/advanced/index.ts +11 -0
  10. package/components/user/Aside.astro +74 -0
  11. package/components/user/Button.astro +79 -0
  12. package/components/user/Card.astro +23 -0
  13. package/components/user/CardList.astro +28 -0
  14. package/components/user/CardListChildren.astro +24 -0
  15. package/components/user/Collapse.astro +84 -0
  16. package/components/user/FormattedDate.astro +21 -0
  17. package/components/user/Label.astro +18 -0
  18. package/components/user/Spoiler.astro +11 -0
  19. package/components/user/Steps.astro +84 -0
  20. package/components/user/TabItem.astro +18 -0
  21. package/components/user/Tabs.astro +266 -0
  22. package/components/user/Timeline.astro +38 -0
  23. package/components/user/index.ts +17 -0
  24. package/index.ts +74 -0
  25. package/package.json +38 -0
  26. package/plugins/link-preview.ts +110 -0
  27. package/plugins/rehype-steps.ts +98 -0
  28. package/plugins/rehype-tabs.ts +112 -0
  29. package/plugins/virtual-user-config.ts +83 -0
  30. package/schemas/favicon.ts +42 -0
  31. package/schemas/head.ts +18 -0
  32. package/schemas/logo.ts +28 -0
  33. package/schemas/social.ts +51 -0
  34. package/types/common.d.ts +48 -0
  35. package/types/index.d.ts +6 -0
  36. package/types/integrations-config.ts +43 -0
  37. package/types/theme-config.ts +125 -0
  38. package/types/user-config.ts +24 -0
  39. package/utils/clsx.ts +24 -0
  40. package/utils/collections.ts +48 -0
  41. package/utils/date.ts +17 -0
  42. package/utils/docsContents.ts +36 -0
  43. package/utils/index.ts +23 -0
  44. package/utils/module.d.ts +25 -0
  45. package/utils/server.ts +11 -0
  46. package/utils/tailwind.ts +7 -0
  47. package/utils/theme.ts +40 -0
  48. package/utils/toast.ts +3 -0
  49. package/utils/toc.ts +41 -0
  50. 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
+ }
@@ -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>>
@@ -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()