@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.
@@ -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
- function virtualModules(modules: Record<string, string>): VitePlugin {
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
- return modules[id.slice(resolvedPrefix.length)]
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
- static: `export const staticPath = ${JSON.stringify(config.staticPath ?? '/static')}`,
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
  ]
@@ -1,5 +1,5 @@
1
1
  import { Tooltip } from '@chassis-ui/css'
2
- import { staticPath } from 'virtual:chassis-docs/static'
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', `${staticPath}/icons/chassis-icons.svg#${iconHref}`)
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', `${staticPath}/icons/chassis-icons.svg#${iconHref}`)
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 { staticPath } from 'virtual:chassis-docs/static'
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="${staticPath}/icons/chassis-icons.svg#${icon}"></use>
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('images/manifest.json')} />
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, getStaticPath } from '../../site'
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={getStaticPath('fonts/fonts.css')} />
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 { getConfig, getPublicFsPath, getStaticPath } from '../../site'
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
- /** Social media thumbnail image */
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
- // A file in `static/` of the `public` directory is read from there, and linked under
26
- // `staticPath` of the config.
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 socialImageSize = isRemoteThumbnail
31
- ? await getRemoteImageSize(thumbnail)
32
- : await getStaticImageSize(getPublicFsPath(thumbnail))
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="summary_lg_image" />
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="image/png" />
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 { getStaticPath } from '../../site'
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: cxCssLinkHref,
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={getStaticPath('icons/chassis-icons.css')} />
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(html: string, staticPath = '/static') {
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, staticPath))
44
+ return renderIconToString(getOptionsWithDefaults(iconElement.attribs, sprite))
38
45
  })
39
46
  }
40
47
 
41
- function getOptionsWithDefaults(attribs: Record<string, string>, staticPath: string): IconOptions {
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 || `${staticPath}/icons/chassis-icons.svg`,
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
+ }
@@ -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
+ }
@@ -77,25 +77,28 @@
77
77
  color: var(--cx-fg-subtle);
78
78
  }
79
79
 
80
- .cxd-search-trigger-label,
81
- .cxd-search-trigger-shortcut {
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' {}