@chassis-ui/docs 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +83 -41
- package/UPGRADING.md +144 -2
- package/index.ts +1 -0
- package/package.json +9 -8
- package/src/components/MediaImage.astro +59 -0
- package/src/components/ResponsiveImage.astro +6 -3
- package/src/components/shortcodes/Example.astro +7 -3
- package/src/components/shortcodes/Icon.astro +2 -2
- package/src/components/shortcodes/ResizableExample.astro +7 -3
- package/src/integration.ts +23 -5
- package/src/js/example-mode.js +3 -3
- package/src/js/search.js +5 -3
- package/src/layouts/head/Favicons.astro +1 -1
- package/src/layouts/head/Head.astro +2 -2
- package/src/layouts/head/Social.astro +23 -10
- package/src/layouts/head/Stylesheet.astro +3 -11
- package/src/layouts/header/SearchTrigger.astro +0 -1
- package/src/libs/icon.ts +11 -4
- package/src/libs/media.ts +158 -0
- package/src/libs/schema.ts +18 -0
- package/src/libs/static-version.ts +53 -0
- package/src/scss/_search.scss +12 -9
- package/src/site.ts +49 -0
- package/virtual.d.ts +2 -0
package/src/integration.ts
CHANGED
|
@@ -4,13 +4,14 @@ import { fileURLToPath } from 'node:url'
|
|
|
4
4
|
import type { AstroIntegration, AstroUserConfig, ViteUserConfig } from 'astro'
|
|
5
5
|
import type { z } from 'astro/zod'
|
|
6
6
|
import { loadConfig, loadSidebar } from './libs/config'
|
|
7
|
-
import { joinDocsPath } from './libs/docs-path'
|
|
7
|
+
import { joinDocsPath, joinStaticPath } from './libs/docs-path'
|
|
8
8
|
import { getDocsMarkdownConfig, type DocsMarkdownConfigOptions } from './libs/markdown'
|
|
9
9
|
import { getInstalledPackageFsPath, getPackageRoot } from './libs/paths'
|
|
10
10
|
import { remarkCxConfig, remarkCxDocsref } from './libs/remark'
|
|
11
11
|
import { configSchema, type ChassisConfig, type Sidebar } from './libs/schema'
|
|
12
12
|
import { chassisAutoImport, type ChassisAutoImportOptions } from './libs/shortcodes'
|
|
13
13
|
import { getSiteUrl } from './libs/site'
|
|
14
|
+
import { versionStaticPath } from './libs/static-version'
|
|
14
15
|
|
|
15
16
|
export interface ChassisDocsOptions<TSchema extends z.ZodType = typeof configSchema> {
|
|
16
17
|
/**
|
|
@@ -56,7 +57,8 @@ type VitePlugin = Extract<NonNullable<ViteUserConfig['plugins']>[number], { name
|
|
|
56
57
|
const virtualPrefix = 'virtual:chassis-docs/'
|
|
57
58
|
const resolvedPrefix = '\0' + virtualPrefix
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
// The code of each module, or a function that returns it when the module is loaded.
|
|
61
|
+
function virtualModules(modules: Record<string, string | (() => string)>): VitePlugin {
|
|
60
62
|
return {
|
|
61
63
|
name: 'chassis-docs:virtual',
|
|
62
64
|
resolveId(id) {
|
|
@@ -66,7 +68,9 @@ function virtualModules(modules: Record<string, string>): VitePlugin {
|
|
|
66
68
|
},
|
|
67
69
|
load(id) {
|
|
68
70
|
if (id.startsWith(resolvedPrefix)) {
|
|
69
|
-
|
|
71
|
+
const code = modules[id.slice(resolvedPrefix.length)]
|
|
72
|
+
|
|
73
|
+
return typeof code === 'function' ? code() : code
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
76
|
}
|
|
@@ -92,6 +96,7 @@ declare module 'virtual:chassis-docs/paths' {
|
|
|
92
96
|
|
|
93
97
|
declare module 'virtual:chassis-docs/static' {
|
|
94
98
|
export const staticPath: string
|
|
99
|
+
export const spritePath: string
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
declare module 'virtual:chassis-docs/styles' {}
|
|
@@ -238,8 +243,21 @@ export function chassisDocs<TSchema extends z.ZodType = typeof configSchema>(
|
|
|
238
243
|
paths: Object.entries(paths)
|
|
239
244
|
.map(([name, value]) => `export const ${name} = ${JSON.stringify(value)}`)
|
|
240
245
|
.join('\n'),
|
|
241
|
-
// A config that was not loaded with `loadConfig()` may lack the default.
|
|
242
|
-
|
|
246
|
+
// A config that was not loaded with `loadConfig()` may lack the default. The
|
|
247
|
+
// version of the sprite is read when a script loads the module: a site's own
|
|
248
|
+
// integration may copy the file into `public` after this hook.
|
|
249
|
+
static: () => {
|
|
250
|
+
const staticPath = config.staticPath ?? '/static'
|
|
251
|
+
const spritePath = versionStaticPath(
|
|
252
|
+
joinStaticPath(staticPath, 'icons/chassis-icons.svg'),
|
|
253
|
+
paths.publicDir
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
return [
|
|
257
|
+
`export const staticPath = ${JSON.stringify(staticPath)}`,
|
|
258
|
+
`export const spritePath = ${JSON.stringify(spritePath)}`
|
|
259
|
+
].join('\n')
|
|
260
|
+
},
|
|
243
261
|
styles: styles.map((file) => `import ${JSON.stringify(file)}`).join('\n')
|
|
244
262
|
})
|
|
245
263
|
]
|
package/src/js/example-mode.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Tooltip } from '@chassis-ui/css'
|
|
2
|
-
import {
|
|
2
|
+
import { spritePath } from 'virtual:chassis-docs/static'
|
|
3
3
|
|
|
4
4
|
// Helper to detect the user's preferred theme
|
|
5
5
|
function getPreferredTheme() {
|
|
@@ -44,7 +44,7 @@ function toggleTheme(button) {
|
|
|
44
44
|
const iconUse = button.querySelector('use')
|
|
45
45
|
if (iconUse) {
|
|
46
46
|
const iconHref = newTheme === 'dark' ? 'sun-solid' : 'moon-solid'
|
|
47
|
-
iconUse.setAttribute('href', `${
|
|
47
|
+
iconUse.setAttribute('href', `${spritePath}#${iconHref}`)
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -65,7 +65,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
65
65
|
const example = button.closest('.cxd-example-snippet')?.querySelector('.cxd-example')
|
|
66
66
|
const currentTheme = example?.getAttribute('data-cx-theme') || preferredTheme
|
|
67
67
|
const iconHref = currentTheme === 'dark' ? 'sun-solid' : 'moon-solid'
|
|
68
|
-
iconUse.setAttribute('href', `${
|
|
68
|
+
iconUse.setAttribute('href', `${spritePath}#${iconHref}`)
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// Add event listener if not already set through onclick
|
package/src/js/search.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// is needed here.
|
|
14
14
|
|
|
15
15
|
import { getInstanceManager } from '@pagefind/component-ui'
|
|
16
|
-
import {
|
|
16
|
+
import { spritePath } from 'virtual:chassis-docs/static'
|
|
17
17
|
|
|
18
18
|
const DIALOG_SELECTOR = '#cxdSearchDialog'
|
|
19
19
|
// The rows that are links. The loading skeleton reuses the class on a `<div>`,
|
|
@@ -237,7 +237,7 @@ const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) =>
|
|
|
237
237
|
const renderItem = ({ url, titleHtml, excerptHtml = '', icon, sub = false }) => `
|
|
238
238
|
<a class="cxd-search-item${sub ? ' cxd-search-subitem' : ''}" href="${escapeHtml(url)}">
|
|
239
239
|
<svg class="icon cxd-search-item-icon" width="16" height="16" aria-hidden="true">
|
|
240
|
-
<use href="${
|
|
240
|
+
<use href="${spritePath}#${icon}"></use>
|
|
241
241
|
</svg>
|
|
242
242
|
<div class="cxd-search-item-body">
|
|
243
243
|
<div class="cxd-search-item-title">${titleHtml}</div>
|
|
@@ -607,7 +607,8 @@ const isMac = () => {
|
|
|
607
607
|
|
|
608
608
|
// Fill in `.cxd-search-trigger-shortcut` slots with the platform-correct hint
|
|
609
609
|
// and reveal them. Rendered hidden by SearchTrigger.astro so non-JS visitors
|
|
610
|
-
// never see a misleading shortcut key.
|
|
610
|
+
// never see a misleading shortcut key. The hint stays out of the accessible
|
|
611
|
+
// name, which is the trigger's label; `aria-keyshortcuts` announces it.
|
|
611
612
|
const setupTriggerShortcuts = () => {
|
|
612
613
|
const mac = isMac()
|
|
613
614
|
const modifier = mac ? '⌘' : 'Ctrl '
|
|
@@ -615,6 +616,7 @@ const setupTriggerShortcuts = () => {
|
|
|
615
616
|
|
|
616
617
|
for (const slot of document.querySelectorAll('.cxd-search-trigger-shortcut')) {
|
|
617
618
|
slot.innerHTML = `<kbd class="cxd-search-trigger-key">${modifier}K</kbd>`
|
|
619
|
+
slot.setAttribute('aria-hidden', 'true')
|
|
618
620
|
slot.hidden = false
|
|
619
621
|
slot.closest('.cxd-search-trigger')?.setAttribute('aria-keyshortcuts', ariaKeyshortcut)
|
|
620
622
|
}
|
|
@@ -11,6 +11,6 @@ import { getStaticPath } from '../../site'
|
|
|
11
11
|
<link rel="apple-touch-icon" href={getStaticPath('images/apple-touch-icon.png')} sizes="180x180" />
|
|
12
12
|
<link rel="icon" href={getStaticPath('images/favicon-32x32.png')} sizes="32x32" type="image/png" />
|
|
13
13
|
<link rel="icon" href={getStaticPath('images/favicon-16x16.png')} sizes="16x16" type="image/png" />
|
|
14
|
-
<link rel="manifest" href={getStaticPath('
|
|
14
|
+
<link rel="manifest" href={getStaticPath('other/manifest.json')} />
|
|
15
15
|
<link rel="icon" href={getStaticPath('images/favicon.png')} />
|
|
16
16
|
<meta name="theme-color" content="#00A4CC" />
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* HTML head section with meta tags, stylesheets, and SEO configuration.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { getConfig,
|
|
8
|
+
import { getConfig, getVersionedStaticPath } from '../../site'
|
|
9
9
|
import { isHomePath } from '../../libs/docs-path'
|
|
10
10
|
import type { Layout } from '../../libs/layout'
|
|
11
11
|
import Stylesheet from './Stylesheet.astro'
|
|
@@ -46,7 +46,7 @@ const pageTitle = isHome
|
|
|
46
46
|
family of every role. A link, not an `@import` in the styles, so that the browser finds it
|
|
47
47
|
with the page and not after the stylesheet that imports it
|
|
48
48
|
-->
|
|
49
|
-
<link rel="stylesheet" href={
|
|
49
|
+
<link rel="stylesheet" href={getVersionedStaticPath('fonts/fonts.css')} />
|
|
50
50
|
|
|
51
51
|
<meta name="description" content={description} />
|
|
52
52
|
|
|
@@ -7,14 +7,18 @@
|
|
|
7
7
|
|
|
8
8
|
import { getRemoteImageSize, getStaticImageSize } from '../../libs/image'
|
|
9
9
|
import type { Layout } from '../../libs/layout'
|
|
10
|
-
import {
|
|
10
|
+
import { getImageType } from '../../libs/media'
|
|
11
|
+
import { getConfig, getMediaUrl, getPublicFsPath, getStaticPath } from '../../site'
|
|
11
12
|
|
|
12
13
|
interface Props {
|
|
13
14
|
/** Page description */
|
|
14
15
|
description: string
|
|
15
16
|
/** Current layout context */
|
|
16
17
|
layout: Layout
|
|
17
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Social media thumbnail image: a file in `static/` of the `public` directory, a URL, or
|
|
20
|
+
* `media:` and the path of an image in the media library
|
|
21
|
+
*/
|
|
18
22
|
thumbnail: string
|
|
19
23
|
/** Page title */
|
|
20
24
|
title: string
|
|
@@ -22,18 +26,27 @@ interface Props {
|
|
|
22
26
|
|
|
23
27
|
const { description, layout, thumbnail, title } = Astro.props
|
|
24
28
|
|
|
25
|
-
//
|
|
26
|
-
// `
|
|
29
|
+
// An image of the media library is linked as a JPEG copy at the size that the social
|
|
30
|
+
// networks show, so its size is known without loading it. A file in `static/` of the
|
|
31
|
+
// `public` directory is read from there, and linked under `staticPath` of the config.
|
|
32
|
+
const mediaFile = thumbnail.match(/^media:(.+)$/)?.[1]
|
|
27
33
|
const staticFile = thumbnail.match(/^\/?static\/(.+)$/)?.[1]
|
|
28
|
-
const socialImageUrl = new URL(staticFile ? getStaticPath(staticFile) : thumbnail, Astro.site)
|
|
29
34
|
const isRemoteThumbnail = /^https?:\/\//i.test(thumbnail)
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
const mediaSize = { width: 1200, height: 630 }
|
|
36
|
+
|
|
37
|
+
const socialImageUrl = mediaFile
|
|
38
|
+
? getMediaUrl(mediaFile, { ...mediaSize, format: 'jpg' })
|
|
39
|
+
: new URL(staticFile ? getStaticPath(staticFile) : thumbnail, Astro.site).href
|
|
40
|
+
const socialImageType = mediaFile ? 'image/jpeg' : getImageType(thumbnail)
|
|
41
|
+
const socialImageSize = mediaFile
|
|
42
|
+
? mediaSize
|
|
43
|
+
: isRemoteThumbnail
|
|
44
|
+
? await getRemoteImageSize(thumbnail)
|
|
45
|
+
: await getStaticImageSize(getPublicFsPath(thumbnail))
|
|
33
46
|
const { xUsername } = getConfig()
|
|
34
47
|
---
|
|
35
48
|
|
|
36
|
-
<meta name="twitter:card" content="
|
|
49
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
37
50
|
{xUsername && <meta name="twitter:site" content={`@${xUsername}`} />}
|
|
38
51
|
{xUsername && <meta name="twitter:creator" content={`@${xUsername}`} />}
|
|
39
52
|
<meta name="twitter:title" content={title} />
|
|
@@ -46,6 +59,6 @@ const { xUsername } = getConfig()
|
|
|
46
59
|
<meta property="og:type" content={layout === 'docs' ? 'article' : 'website'} />
|
|
47
60
|
<meta property="og:site_name" content={getConfig().title} />
|
|
48
61
|
<meta property="og:image" content={socialImageUrl} />
|
|
49
|
-
<meta property="og:image:type" content=
|
|
62
|
+
{socialImageType && <meta property="og:image:type" content={socialImageType} />}
|
|
50
63
|
<meta property="og:image:width" content={String(socialImageSize.width)} />
|
|
51
64
|
<meta property="og:image:height" content={String(socialImageSize.height)} />
|
|
@@ -6,19 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { HTMLAttributes } from 'astro/types'
|
|
9
|
-
import {
|
|
9
|
+
import { getVersionedStaticPath } from '../../site'
|
|
10
10
|
|
|
11
11
|
function getChassisCssProps() {
|
|
12
|
-
let cxCssLinkHref = getStaticPath('css/chassis')
|
|
13
|
-
|
|
14
|
-
if (import.meta.env.PROD) {
|
|
15
|
-
cxCssLinkHref = `${cxCssLinkHref}.min`
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
cxCssLinkHref = `${cxCssLinkHref}.css`
|
|
19
|
-
|
|
20
12
|
const cxCssLinkProps: HTMLAttributes<'link'> = {
|
|
21
|
-
href:
|
|
13
|
+
href: getVersionedStaticPath(import.meta.env.PROD ? 'css/chassis.min.css' : 'css/chassis.css'),
|
|
22
14
|
rel: 'stylesheet'
|
|
23
15
|
}
|
|
24
16
|
|
|
@@ -27,4 +19,4 @@ function getChassisCssProps() {
|
|
|
27
19
|
---
|
|
28
20
|
|
|
29
21
|
<link {...getChassisCssProps()} />
|
|
30
|
-
<link rel="stylesheet" href={
|
|
22
|
+
<link rel="stylesheet" href={getVersionedStaticPath('icons/chassis-icons.css')} />
|
|
@@ -28,7 +28,6 @@ const { class: className, placeholder = 'Search docs…', target = '#cxdSearchDi
|
|
|
28
28
|
data-cx-toggle="dialog"
|
|
29
29
|
data-cx-target={target}
|
|
30
30
|
aria-haspopup="dialog"
|
|
31
|
-
aria-label={placeholder}
|
|
32
31
|
>
|
|
33
32
|
<Icon name="search-solid" size={16} />
|
|
34
33
|
<span class="cxd-search-trigger-label">{placeholder}</span>
|
package/src/libs/icon.ts
CHANGED
|
@@ -18,9 +18,16 @@ interface IconOptions {
|
|
|
18
18
|
* This is needed because the `<Example />` shortcode renders its `code` prop as raw HTML via `set:html`
|
|
19
19
|
* rather than as MDX, so nested Astro components (like `<Icon />`, similar to `<Placeholder />`) are never
|
|
20
20
|
* actually invoked — they need to be expanded to their rendered markup ahead of time.
|
|
21
|
+
*
|
|
22
|
+
* @param staticPath The `staticPath` of the site.
|
|
23
|
+
* @param sprite URL of the sprite that an icon without a `sprite` attribute uses.
|
|
21
24
|
* @see src/components/shortcodes/Icon.astro
|
|
22
25
|
*/
|
|
23
|
-
export function replaceIconsInHtml(
|
|
26
|
+
export function replaceIconsInHtml(
|
|
27
|
+
html: string,
|
|
28
|
+
staticPath = '/static',
|
|
29
|
+
sprite = `${staticPath}/icons/chassis-icons.svg`
|
|
30
|
+
) {
|
|
24
31
|
return html.replace(iconRegex, (match) => {
|
|
25
32
|
const document = htmlparser2.parseDocument(match, { xmlMode: true })
|
|
26
33
|
const iconElement = document.firstChild
|
|
@@ -34,11 +41,11 @@ export function replaceIconsInHtml(html: string, staticPath = '/static') {
|
|
|
34
41
|
throw new Error('Invalid icon element.')
|
|
35
42
|
}
|
|
36
43
|
|
|
37
|
-
return renderIconToString(getOptionsWithDefaults(iconElement.attribs,
|
|
44
|
+
return renderIconToString(getOptionsWithDefaults(iconElement.attribs, sprite))
|
|
38
45
|
})
|
|
39
46
|
}
|
|
40
47
|
|
|
41
|
-
function getOptionsWithDefaults(attribs: Record<string, string>,
|
|
48
|
+
function getOptionsWithDefaults(attribs: Record<string, string>, sprite: string): IconOptions {
|
|
42
49
|
if (!attribs.name) {
|
|
43
50
|
throw new Error('Icon element is missing the required "name" attribute.')
|
|
44
51
|
}
|
|
@@ -48,7 +55,7 @@ function getOptionsWithDefaults(attribs: Record<string, string>, staticPath: str
|
|
|
48
55
|
class: attribs.class,
|
|
49
56
|
size: attribs.size ? Number(attribs.size.replace(/[{}]/g, '')) : 24,
|
|
50
57
|
font: attribs.font !== undefined && attribs.font !== '{false}',
|
|
51
|
-
sprite: attribs.sprite ||
|
|
58
|
+
sprite: attribs.sprite || sprite,
|
|
52
59
|
title: attribs.title
|
|
53
60
|
}
|
|
54
61
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Images of the media library, which is a Cloudinary account. A page names an image by its
|
|
2
|
+
// path in the library, such as `blog/2026/color-system/cover.jpg`, and these functions build
|
|
3
|
+
// the URL of a resized copy. Only this file knows the URL format of the provider, so a move
|
|
4
|
+
// to another provider changes this file and `media` of the config, and no page.
|
|
5
|
+
|
|
6
|
+
/** How the copy of an image is resized and encoded. */
|
|
7
|
+
export interface MediaTransform {
|
|
8
|
+
/** Width of the copy in pixels. */
|
|
9
|
+
width?: number
|
|
10
|
+
/**
|
|
11
|
+
* Height of the copy in pixels. With `width`, the copy is cropped to that ratio around the
|
|
12
|
+
* subject of the image. Without, the copy keeps the ratio of the image.
|
|
13
|
+
*/
|
|
14
|
+
height?: number
|
|
15
|
+
/** `auto` serves AVIF or WebP to a browser that accepts them. Default `auto`. */
|
|
16
|
+
format?: 'auto' | 'avif' | 'jpg' | 'png' | 'webp'
|
|
17
|
+
/** A number from 1 to 100, or `auto` for the lowest that looks the same. Default `auto`. */
|
|
18
|
+
quality?: 'auto' | number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MEDIA_FORMAT_TYPES: Record<string, string> = {
|
|
22
|
+
avif: 'image/avif',
|
|
23
|
+
gif: 'image/gif',
|
|
24
|
+
jpeg: 'image/jpeg',
|
|
25
|
+
jpg: 'image/jpeg',
|
|
26
|
+
png: 'image/png',
|
|
27
|
+
svg: 'image/svg+xml',
|
|
28
|
+
webp: 'image/webp'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The media type of an image file from its extension, e.g. `image/png` for `social.png`.
|
|
33
|
+
* `undefined` when the extension is not one of an image.
|
|
34
|
+
*/
|
|
35
|
+
export function getImageType(file: string): string | undefined {
|
|
36
|
+
const extension = file
|
|
37
|
+
.split(/[?#]/)[0]
|
|
38
|
+
.match(/\.([a-z0-9]+)$/i)?.[1]
|
|
39
|
+
?.toLowerCase()
|
|
40
|
+
|
|
41
|
+
return extension ? MEDIA_FORMAT_TYPES[extension] : undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function assertPositiveInteger(name: string, value: number | undefined): void {
|
|
45
|
+
if (value !== undefined && (!Number.isInteger(value) || value < 1)) {
|
|
46
|
+
throw new Error(`The ${name} of a media image must be a positive integer, not ${value}.`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Checks the path of an image in the media library and returns it encoded for a URL.
|
|
52
|
+
*
|
|
53
|
+
* @throws For a URL, a path that starts with `/`, an empty segment, `.` or `..`, a query or
|
|
54
|
+
* a hash.
|
|
55
|
+
*/
|
|
56
|
+
export function encodeMediaPath(file: string): string {
|
|
57
|
+
const segments = file.split('/')
|
|
58
|
+
|
|
59
|
+
if (
|
|
60
|
+
/^[a-z][\w+.-]*:/i.test(file) ||
|
|
61
|
+
/[?#\\]/.test(file) ||
|
|
62
|
+
segments.some((segment) => segment === '' || segment === '.' || segment === '..')
|
|
63
|
+
) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Expected the path of an image in the media library, such as "blog/cover.jpg", not "${file}".`
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return segments.map((segment) => encodeURIComponent(segment)).join('/')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether `file` is the path of an image in the media library, as `encodeMediaPath()` takes it. */
|
|
73
|
+
export function isMediaPath(file: string): boolean {
|
|
74
|
+
try {
|
|
75
|
+
encodeMediaPath(file)
|
|
76
|
+
return true
|
|
77
|
+
} catch {
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* URL of a copy of an image of the media library.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```ts
|
|
87
|
+
* getMediaImageUrl('chassis', 'blog/cover.jpg', { width: 800, height: 420 })
|
|
88
|
+
* // https://res.cloudinary.com/chassis/image/upload/c_fill,g_auto,w_800,h_420,f_auto,q_auto/blog/cover.jpg
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export function getMediaImageUrl(
|
|
92
|
+
cloudName: string,
|
|
93
|
+
file: string,
|
|
94
|
+
transform: MediaTransform = {}
|
|
95
|
+
): string {
|
|
96
|
+
const { format = 'auto', height, quality = 'auto', width } = transform
|
|
97
|
+
|
|
98
|
+
assertPositiveInteger('width', width)
|
|
99
|
+
assertPositiveInteger('height', height)
|
|
100
|
+
|
|
101
|
+
if (typeof quality === 'number' && (!Number.isInteger(quality) || quality < 1 || quality > 100)) {
|
|
102
|
+
throw new Error(`The quality of a media image must be from 1 to 100, not ${quality}.`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// `c_fill` crops to the ratio of both sizes, around the subject (`g_auto`). `c_limit` keeps
|
|
106
|
+
// the ratio and never makes the image larger than it is.
|
|
107
|
+
const parts =
|
|
108
|
+
width && height
|
|
109
|
+
? ['c_fill', 'g_auto', `w_${width}`, `h_${height}`]
|
|
110
|
+
: width || height
|
|
111
|
+
? ['c_limit', width ? `w_${width}` : `h_${height}`]
|
|
112
|
+
: []
|
|
113
|
+
|
|
114
|
+
parts.push(`f_${format}`, `q_${quality}`)
|
|
115
|
+
|
|
116
|
+
return `https://res.cloudinary.com/${cloudName}/image/upload/${parts.join(',')}/${encodeMediaPath(file)}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Widths that `getMediaImageWidths()` picks from. */
|
|
120
|
+
export const MEDIA_IMAGE_WIDTHS = [320, 480, 640, 800, 1024, 1280, 1600, 1920, 2400]
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The widths of the copies of an image that is shown at most `width` pixels wide: the widths
|
|
124
|
+
* up to twice `width`, for screens with two device pixels per CSS pixel, and `width` itself.
|
|
125
|
+
*/
|
|
126
|
+
export function getMediaImageWidths(width: number, widths = MEDIA_IMAGE_WIDTHS): number[] {
|
|
127
|
+
assertPositiveInteger('width', width)
|
|
128
|
+
|
|
129
|
+
return [...new Set([...widths.filter((candidate) => candidate <= width * 2), width])].sort(
|
|
130
|
+
(a, b) => a - b
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The `srcset` of an image of the media library: a copy at each of `widths`. With `height`,
|
|
136
|
+
* every copy is cropped to the ratio of `width` and `height`.
|
|
137
|
+
*/
|
|
138
|
+
export function getMediaImageSrcSet(
|
|
139
|
+
cloudName: string,
|
|
140
|
+
file: string,
|
|
141
|
+
widths: number[],
|
|
142
|
+
transform: MediaTransform = {}
|
|
143
|
+
): string {
|
|
144
|
+
const { height, width } = transform
|
|
145
|
+
|
|
146
|
+
return widths
|
|
147
|
+
.map((copyWidth) => {
|
|
148
|
+
const copyHeight = width && height ? Math.round((copyWidth * height) / width) : undefined
|
|
149
|
+
const url = getMediaImageUrl(cloudName, file, {
|
|
150
|
+
...transform,
|
|
151
|
+
width: copyWidth,
|
|
152
|
+
height: copyHeight
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
return `${url} ${copyWidth}w`
|
|
156
|
+
})
|
|
157
|
+
.join(', ')
|
|
158
|
+
}
|
package/src/libs/schema.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'astro/zod'
|
|
2
|
+
import { isMediaPath } from './media'
|
|
2
3
|
|
|
3
4
|
// Sites extend the schemas below. They must use this `z`, so that the site and the package
|
|
4
5
|
// share one copy of Zod.
|
|
@@ -17,6 +18,14 @@ export const zPrefixedVersionSemver = z
|
|
|
17
18
|
|
|
18
19
|
export const zLanguageCode = z.string().regex(/^[a-z]{2}(?:-[a-zA-Z]{2})?$/)
|
|
19
20
|
|
|
21
|
+
/** The path of an image in the media library, e.g. `blog/2026/color-system/cover.jpg`. */
|
|
22
|
+
export const zMediaPath = z
|
|
23
|
+
.string()
|
|
24
|
+
.refine(
|
|
25
|
+
isMediaPath,
|
|
26
|
+
'Expected the path of an image in the media library, such as "blog/cover.jpg"'
|
|
27
|
+
)
|
|
28
|
+
|
|
20
29
|
const zHeadingRange = z.object({
|
|
21
30
|
min: z.number().int().min(1).max(6),
|
|
22
31
|
max: z.number().int().min(1).max(6)
|
|
@@ -57,6 +66,15 @@ export const configSchema = z.strictObject({
|
|
|
57
66
|
.string()
|
|
58
67
|
.regex(/^[\w.-]+$/, 'Expected a GitHub organisation name, not a URL')
|
|
59
68
|
.optional(),
|
|
69
|
+
/**
|
|
70
|
+
* The media library that `<MediaImage>` and a `media:` thumbnail load images from: the
|
|
71
|
+
* cloud name of a Cloudinary account, e.g. `{ cloudinary: "chassis" }`.
|
|
72
|
+
*/
|
|
73
|
+
media: z
|
|
74
|
+
.strictObject({
|
|
75
|
+
cloudinary: z.string().regex(/^[\w-]+$/, 'Expected a Cloudinary cloud name, not a URL')
|
|
76
|
+
})
|
|
77
|
+
.optional(),
|
|
60
78
|
/** Repository URL, e.g. `"https://github.com/chassis-ui/css"`. */
|
|
61
79
|
repo: z.url(),
|
|
62
80
|
/** The branch that "View on GitHub" links to. */
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
// The hash of each file, kept until its size or time of change differs: a page of the build
|
|
6
|
+
// reads the same stylesheet as every other page, and the dev server sees a file copied anew.
|
|
7
|
+
const versions = new Map<string, { mtimeMs: number; size: number; version: string }>()
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The first 8 characters of the MD5 hash of a file's content, or `undefined` when there is
|
|
11
|
+
* no file at `fsPath`.
|
|
12
|
+
*/
|
|
13
|
+
export function getFileVersion(fsPath: string): string | undefined {
|
|
14
|
+
let stats: fs.Stats
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
stats = fs.statSync(fsPath)
|
|
18
|
+
} catch {
|
|
19
|
+
return undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!stats.isFile()) return undefined
|
|
23
|
+
|
|
24
|
+
const cached = versions.get(fsPath)
|
|
25
|
+
|
|
26
|
+
if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) {
|
|
27
|
+
return cached.version
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const version = createHash('md5').update(fs.readFileSync(fsPath)).digest('hex').slice(0, 8)
|
|
31
|
+
versions.set(fsPath, { mtimeMs: stats.mtimeMs, size: stats.size, version })
|
|
32
|
+
|
|
33
|
+
return version
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Adds `?v=` and the version of the file to the URL path of a file of the public directory:
|
|
38
|
+
* `/static/css/chassis.min.css?v=8e2106f6`. A new content is a new URL, so the file can be
|
|
39
|
+
* kept by the browser until the page names another. A path that the public directory has no
|
|
40
|
+
* file at is returned as it is.
|
|
41
|
+
*
|
|
42
|
+
* @param urlPath URL path of the file, e.g. `"/static/css/chassis.min.css"`.
|
|
43
|
+
* @param publicDir Absolute path of the site's `public` directory.
|
|
44
|
+
*/
|
|
45
|
+
export function versionStaticPath(urlPath: string, publicDir: string): string {
|
|
46
|
+
const fsPath = path.join(publicDir, urlPath)
|
|
47
|
+
|
|
48
|
+
if (path.relative(publicDir, fsPath).startsWith('..')) return urlPath
|
|
49
|
+
|
|
50
|
+
const version = getFileVersion(fsPath)
|
|
51
|
+
|
|
52
|
+
return version ? `${urlPath}?v=${version}` : urlPath
|
|
53
|
+
}
|
package/src/scss/_search.scss
CHANGED
|
@@ -77,25 +77,28 @@
|
|
|
77
77
|
color: var(--cx-fg-subtle);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
.
|
|
82
|
-
display: none;
|
|
83
|
-
|
|
84
|
-
@include media-breakpoint-up(lg) {
|
|
85
|
-
display: inline-flex;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
80
|
+
// The label is the accessible name of the trigger, so on phones it is
|
|
81
|
+
// hidden from view only.
|
|
89
82
|
.cxd-search-trigger-label {
|
|
83
|
+
display: inline-flex;
|
|
90
84
|
flex: 1;
|
|
91
85
|
overflow: hidden;
|
|
92
86
|
text-overflow: ellipsis;
|
|
93
87
|
white-space: nowrap;
|
|
88
|
+
|
|
89
|
+
@include media-breakpoint-down(lg) {
|
|
90
|
+
@include visually-hidden();
|
|
91
|
+
}
|
|
94
92
|
}
|
|
95
93
|
|
|
96
94
|
.cxd-search-trigger-shortcut {
|
|
95
|
+
display: none;
|
|
97
96
|
flex-shrink: 0;
|
|
98
97
|
gap: .125rem;
|
|
98
|
+
|
|
99
|
+
@include media-breakpoint-up(lg) {
|
|
100
|
+
display: inline-flex;
|
|
101
|
+
}
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
.cxd-search-trigger-key {
|
package/src/site.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { getNodeModulesFsPaths, resolvePackageFilePath } from './libs/paths'
|
|
|
14
14
|
import { replaceConfigInText, replaceDocsrefInText } from './libs/remark'
|
|
15
15
|
import type { ChassisConfig, DocsPage, Sidebar } from './libs/schema'
|
|
16
16
|
import { resolveSiteFileUrl, resolveSourceFsPath, resolveSourceUrl } from './libs/source'
|
|
17
|
+
import { getMediaImageSrcSet, getMediaImageUrl, type MediaTransform } from './libs/media'
|
|
18
|
+
import { versionStaticPath } from './libs/static-version'
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* The parsed `config.yml`.
|
|
@@ -43,6 +45,53 @@ export function getStaticPath(file = ''): string {
|
|
|
43
45
|
return joinStaticPath(config.staticPath ?? '/static', file)
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
/**
|
|
49
|
+
* URL path of a static file with the version of its content:
|
|
50
|
+
* `getVersionedStaticPath('css/chassis.min.css')` is
|
|
51
|
+
* `/static/css/chassis.min.css?v=8e2106f6`. The version is read from the file of the site's
|
|
52
|
+
* `public` directory, so a page links the file of its own build, and a browser that keeps
|
|
53
|
+
* the file fetches it again when it changes. Without that file, the path is the one of
|
|
54
|
+
* `getStaticPath()`.
|
|
55
|
+
*/
|
|
56
|
+
export function getVersionedStaticPath(file: string): string {
|
|
57
|
+
return versionStaticPath(getStaticPath(file), paths.publicDir)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A URL path of a file of the site's `public` directory with the version of its content:
|
|
62
|
+
* `getVersionedPublicPath('/static/images/home/hero.webp')` is
|
|
63
|
+
* `/static/images/home/hero.webp?v=1a2b3c4d`. For a path that a page or a prop already holds.
|
|
64
|
+
* Without that file, the path is returned as it is.
|
|
65
|
+
*/
|
|
66
|
+
export function getVersionedPublicPath(urlPath: string): string {
|
|
67
|
+
return versionStaticPath(urlPath, paths.publicDir)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getMediaCloudName(): string {
|
|
71
|
+
const cloudName = config.media?.cloudinary
|
|
72
|
+
|
|
73
|
+
if (!cloudName) {
|
|
74
|
+
throw new Error('An image of the media library needs `media.cloudinary` in `config.yml`.')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return cloudName
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* URL of a copy of an image of the media library, which `media` of the config names:
|
|
82
|
+
* `getMediaUrl('blog/cover.jpg', { width: 800 })`.
|
|
83
|
+
*
|
|
84
|
+
* @throws When the config has no `media`.
|
|
85
|
+
*/
|
|
86
|
+
export function getMediaUrl(file: string, transform?: MediaTransform): string {
|
|
87
|
+
return getMediaImageUrl(getMediaCloudName(), file, transform)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The `srcset` of an image of the media library, with a copy at each of `widths`. */
|
|
91
|
+
export function getMediaSrcSet(file: string, widths: number[], transform?: MediaTransform): string {
|
|
92
|
+
return getMediaImageSrcSet(getMediaCloudName(), file, widths, transform)
|
|
93
|
+
}
|
|
94
|
+
|
|
46
95
|
/** Absolute path of the site's root: the directory that holds `astro.config.ts`. */
|
|
47
96
|
export function getSiteRoot(): string {
|
|
48
97
|
return paths.root
|
package/virtual.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ declare module 'virtual:chassis-docs/paths' {
|
|
|
25
25
|
declare module 'virtual:chassis-docs/static' {
|
|
26
26
|
/** `staticPath` of the config, for scripts that run in the browser. */
|
|
27
27
|
export const staticPath: string
|
|
28
|
+
/** URL of the icon sprite, with the version of its content. */
|
|
29
|
+
export const spritePath: string
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
declare module 'virtual:chassis-docs/styles' {}
|