@chassis-ui/docs 0.11.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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # @chassis-ui/docs
2
2
 
3
+ ## 0.11.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Images of a media library on Cloudinary, for images that do not belong in the repository. `media.cloudinary` of `config.yml` names the account, and a page names an image by its path in the library. `<MediaImage>` of `@chassis-ui/docs/components/MediaImage.astro` renders it with a `srcset` of copies up to twice its width, in AVIF or WebP when the browser accepts them, cropped around the subject when it has a `height`. A `thumbnail` of `media:` and a path links a 1200 × 630 JPEG copy in the social meta tags, with its size and type and without loading it. `getMediaUrl()` and `getMediaSrcSet()` of `@chassis-ui/docs/site` return the URLs, the root entry exports the functions they use, and `zMediaPath` of `@chassis-ui/docs/schema` checks a path in a content schema. The social meta tags now name the large card for X, `summary_large_image`, which the misspelt `summary_lg_image` did not, and give the `og:image:type` of the thumbnail's file instead of `image/png` for every image.
8
+ - The search trigger of the header takes its accessible name from its label, "Search docs…" by default, instead of an `aria-label` that left out the shortcut hint the trigger shows, ⌘K or Ctrl K. axe-core 4.14 reports that as `label-content-name-mismatch`. The hint is `aria-hidden`, and `aria-keyshortcuts` still announces the shortcut. Below the `lg` breakpoint the label is visually hidden instead of `display: none`, so the icon-only trigger keeps its name. Nothing changes on screen.
9
+ - `<ResponsiveImage>` links both files of an image with `?v=` and the hash of the file in the site's `public` directory, like the shared stylesheets, so a host can let the browser keep them for good. A path without a file in `public` is linked as before. `getVersionedPublicPath(urlPath)` of `@chassis-ui/docs/site` versions a URL path that a page or a prop already holds, such as `/static/images/home/hero.webp`.
10
+
3
11
  ## 0.11.0
4
12
 
5
13
  ### Minor Changes
package/README.md CHANGED
@@ -106,24 +106,25 @@ repo: 'https://github.com/chassis-ui/css'
106
106
  currentVersion: '0.5.2'
107
107
  ```
108
108
 
109
- | Key | Required | Meaning |
110
- | --------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- |
111
- | `title`, `subtitle`, `description`, `authors` | yes | Used in the page title and the meta tags |
112
- | `baseURL` | yes | Canonical URL of the site. The page at its path is the home page: `/css/` for `https://chassis-ui.com/css/` |
113
- | `docsPath` | yes | URL path of the docs pages. Starts with `/` |
114
- | `staticPath` | no | URL path that the pages load the static files from. Default `/static`. See [A site under a prefix](#a-site-under-a-prefix) |
115
- | `repo` | yes | URL of the repository |
116
- | `currentVersion` | yes | Version that the site documents. Links to source files use the tag `v<currentVersion>` |
117
- | `githubOrg` | no | GitHub organisation name, not a URL. The header links to it |
118
- | `figmaHandle` | no | Figma Community handle. The header links to it |
119
- | `xUsername` | no | X handle. Used in the social meta tags |
120
- | `analytics.googleId` | no | Google Analytics ID. In production builds, loaded after the visitor accepts in a consent banner |
121
- | `anchors.min`, `anchors.max` | no | Heading levels that get an anchor link. Default 2 to 5 |
122
- | `toc.min`, `toc.max` | no | Heading levels in the table of contents. Default 2 to 6 |
123
- | `sourceDir` | no | Directory that `file` props are relative to, from the site's root, e.g. `"../css"`. Default `"."` |
124
- | `sourcePath` | no | The same directory from the root of the repository, e.g. `"packages/css"` |
125
- | `sitePath` | no | The site's root from the root of the repository, e.g. `"packages/site"` |
126
- | `siteBranch` | no | The branch that "View on GitHub" links to. Default `main` |
109
+ | Key | Required | Meaning |
110
+ | --------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
111
+ | `title`, `subtitle`, `description`, `authors` | yes | Used in the page title and the meta tags |
112
+ | `baseURL` | yes | Canonical URL of the site. The page at its path is the home page: `/css/` for `https://chassis-ui.com/css/` |
113
+ | `docsPath` | yes | URL path of the docs pages. Starts with `/` |
114
+ | `staticPath` | no | URL path that the pages load the static files from. Default `/static`. See [A site under a prefix](#a-site-under-a-prefix) |
115
+ | `repo` | yes | URL of the repository |
116
+ | `currentVersion` | yes | Version that the site documents. Links to source files use the tag `v<currentVersion>` |
117
+ | `githubOrg` | no | GitHub organisation name, not a URL. The header links to it |
118
+ | `figmaHandle` | no | Figma Community handle. The header links to it |
119
+ | `xUsername` | no | X handle. Used in the social meta tags |
120
+ | `media.cloudinary` | no | Cloud name of the Cloudinary account that holds the media library. See [Images of the media library](#images-of-the-media-library) |
121
+ | `analytics.googleId` | no | Google Analytics ID. In production builds, loaded after the visitor accepts in a consent banner |
122
+ | `anchors.min`, `anchors.max` | no | Heading levels that get an anchor link. Default 2 to 5 |
123
+ | `toc.min`, `toc.max` | no | Heading levels in the table of contents. Default 2 to 6 |
124
+ | `sourceDir` | no | Directory that `file` props are relative to, from the site's root, e.g. `"../css"`. Default `"."` |
125
+ | `sourcePath` | no | The same directory from the root of the repository, e.g. `"packages/css"` |
126
+ | `sitePath` | no | The site's root from the root of the repository, e.g. `"packages/site"` |
127
+ | `siteBranch` | no | The branch that "View on GitHub" links to. Default `main` |
127
128
 
128
129
  `file` props are those of `<ScssDocs>`, `<ScssDocsSimple>`, `<JsDocs>` and `<Code filePath>`.
129
130
 
@@ -286,6 +287,9 @@ chassisDocs({
286
287
  | `getPublicFsPath(file)` | Absolute path of a file in the `public` directory |
287
288
  | `getStaticPath(file)` | URL path of a static file, under `staticPath` of the config |
288
289
  | `getVersionedStaticPath(file)` | The same, with `?v=` and a hash of the file in `public` |
290
+ | `getVersionedPublicPath(urlPath)` | A URL path of a file in `public`, with `?v=` and the file's hash |
291
+ | `getMediaUrl(path, transform)` | URL of a copy of an image of the media library |
292
+ | `getMediaSrcSet(path, widths, transform)` | The `srcset` of an image of the media library |
289
293
  | `getSourceFsPath(file)` | Absolute path of a file that a `file` prop names |
290
294
  | `getSourceUrl(file)` | URL of a source file on GitHub, at the tag of the current version |
291
295
  | `getSiteFileUrl(filePath)` | URL of a file of the site on GitHub, on the site's branch |
@@ -295,6 +299,39 @@ chassisDocs({
295
299
 
296
300
  Code that runs while Astro loads its configuration uses the library functions of the root entry instead: `loadConfig()`, `loadData()`, `loadSidebar()` and the path helpers.
297
301
 
302
+ ## Images of the media library
303
+
304
+ Images that do not belong in the repository, such as the covers of blog posts, are in a media library on Cloudinary. `media.cloudinary` of `config.yml` is the cloud name of the account, and a page names an image by its path in the library, such as `blog/2026/color-system/cover.jpg`. The path is the public ID of the image, with or without an extension. The folders of the library do not take part in it unless the public ID has them. A URL, a path that starts with `/`, `..` and a query are refused.
305
+
306
+ ```astro
307
+ ---
308
+ import MediaImage from '@chassis-ui/docs/components/MediaImage.astro'
309
+ ---
310
+
311
+ <MediaImage
312
+ src="blog/2026/color-system/cover.jpg"
313
+ alt="Paint chips beside an empty floor plan"
314
+ width={1200}
315
+ height={630}
316
+ sizes="(width >= 64rem) 50vw, 100vw"
317
+ />
318
+ ```
319
+
320
+ | Prop | Meaning |
321
+ | -------- | -------------------------------------------------------------------------------------------------------------------- |
322
+ | `src` | Path of the image in the media library |
323
+ | `alt` | Alternative text |
324
+ | `width` | Largest width in CSS pixels that the image is shown at |
325
+ | `height` | Height at `width`. The image is cropped to that ratio, around its subject. Without it, the image keeps its own ratio |
326
+ | `widths` | Widths of the copies in the `srcset`. Default: the widths from 320 to 2400 up to twice `width`, and `width` |
327
+ | `sizes` | The `sizes` attribute. Default: `width` pixels, or the width of the viewport when smaller |
328
+
329
+ Other attributes go to the `<img>`. `loading` is `lazy` and `decoding` is `async` unless the page sets them. A browser that accepts AVIF or WebP gets that format.
330
+
331
+ A `thumbnail` of `media:` and a path, such as `media:blog/2026/color-system/cover.jpg`, links a 1200 × 630 JPEG copy, cropped around the subject, in the social meta tags. Its size is known, so the build does not load it. `zMediaPath` of `@chassis-ui/docs/schema` checks a path in a content schema of the site.
332
+
333
+ The page CSP of the host must allow images from `https://res.cloudinary.com`. Only `src/libs/media.ts` knows the URL format of Cloudinary, so a move to another provider changes that file and the config key, and no page.
334
+
298
335
  ## Commands
299
336
 
300
337
  The package installs a `chassis-docs` command with the build steps that every Chassis site shares. Call it from the scripts of `package.json`:
@@ -397,18 +434,18 @@ A rule or filter applies to every file that the command checks. To make an excep
397
434
 
398
435
  These import paths are the public API.
399
436
 
400
- | Path | Contents |
401
- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
402
- | `@chassis-ui/docs` | Library functions that work anywhere: config and data loaders, path helpers, `getSiteUrl`, `getDocsMarkdownConfig`, the remark and rehype plugins, `chassisAutoImport`, `generateToc`, `highlightCode`, image and string utilities |
403
- | `@chassis-ui/docs/integration` | `chassisDocs()` and the type of its options |
404
- | `@chassis-ui/docs/schema` | `z`, `configSchema`, `sidebarSchema`, `docsSchema`, `calloutsSchema`, the version validators, and the types `ChassisConfig`, `Sidebar`, `SidebarItem`, `DocsFrontmatter`, `DocsPage` |
405
- | `@chassis-ui/docs/site` | What pages read from the site. See above |
406
- | `@chassis-ui/docs/layouts/*` | `BaseLayout.astro`, `DocsLayout.astro`, `RedirectLayout.astro`, `SingleLayout.astro` |
407
- | `@chassis-ui/docs/components/*` | `DocsSidebar.astro`, `FeatureCard.astro`, `NavLink.astro`, `ResponsiveImage.astro`, `TableOfContents.astro`, `ThemeToggler.astro` |
408
- | `@chassis-ui/docs/shortcodes/*` | The MDX shortcodes |
409
- | `@chassis-ui/docs/js/*` | Client-side scripts: `example-mode.js`, `clipboard.ts`, `color-modes.js` and others |
410
- | `@chassis-ui/docs/scss/main` | The styles of the package. The integration loads them when the site has no stylesheet of its own |
411
- | `@chassis-ui/docs/scss/vars` | The Sass variables and custom properties of the docs, for a site's own stylesheets |
437
+ | Path | Contents |
438
+ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
439
+ | `@chassis-ui/docs` | Library functions that work anywhere: config and data loaders, path helpers, `getSiteUrl`, `getDocsMarkdownConfig`, the remark and rehype plugins, `chassisAutoImport`, `generateToc`, `highlightCode`, image, media and string utilities |
440
+ | `@chassis-ui/docs/integration` | `chassisDocs()` and the type of its options |
441
+ | `@chassis-ui/docs/schema` | `z`, `configSchema`, `sidebarSchema`, `docsSchema`, `calloutsSchema`, the version validators, `zMediaPath`, and the types `ChassisConfig`, `Sidebar`, `SidebarItem`, `DocsFrontmatter`, `DocsPage` |
442
+ | `@chassis-ui/docs/site` | What pages read from the site. See above |
443
+ | `@chassis-ui/docs/layouts/*` | `BaseLayout.astro`, `DocsLayout.astro`, `RedirectLayout.astro`, `SingleLayout.astro` |
444
+ | `@chassis-ui/docs/components/*` | `DocsSidebar.astro`, `FeatureCard.astro`, `MediaImage.astro`, `NavLink.astro`, `ResponsiveImage.astro`, `TableOfContents.astro`, `ThemeToggler.astro` |
445
+ | `@chassis-ui/docs/shortcodes/*` | The MDX shortcodes |
446
+ | `@chassis-ui/docs/js/*` | Client-side scripts: `example-mode.js`, `clipboard.ts`, `color-modes.js` and others |
447
+ | `@chassis-ui/docs/scss/main` | The styles of the package. The integration loads them when the site has no stylesheet of its own |
448
+ | `@chassis-ui/docs/scss/vars` | The Sass variables and custom properties of the docs, for a site's own stylesheets |
412
449
 
413
450
  No other path resolves: `exports` in `package.json` lists these and nothing else. The layouts and components are listed by name, so the folders inside `layouts/` and `components/` are not reachable.
414
451
 
@@ -420,6 +457,7 @@ The integration provides five modules: `virtual:chassis-docs/config`, `virtual:c
420
457
 
421
458
  | `@chassis-ui/docs` | Requires Astro | Requires `@chassis-ui/css` | Requires Node | Built with |
422
459
  | ------------------ | -------------------------- | -------------------------- | ------------- | -------------------------------------- |
460
+ | 0.11.1 | `^7.0.0` | `>=0.10.0` | `>=22.12.0` | Astro 7.4.2, css 0.11.1, tokens 0.10.1 |
423
461
  | 0.11.0 | `^7.0.0` | `>=0.10.0` | `>=22.12.0` | Astro 7.3.8, css 0.10.0, tokens 0.10.0 |
424
462
  | 0.10.0 | `^7.0.0` | `>=0.10.0` | `>=22.12.0` | Astro 7.3.8, css 0.10.0, tokens 0.10.0 |
425
463
  | 0.9.0 | `^7.0.0` | `>=0.10.0` | `>=22.12.0` | Astro 7.3.8, css 0.10.0, tokens 0.10.0 |
package/index.ts CHANGED
@@ -6,6 +6,7 @@ export * from './src/libs/highlight'
6
6
  export * from './src/libs/image'
7
7
  export * from './src/libs/layout'
8
8
  export * from './src/libs/markdown'
9
+ export * from './src/libs/media'
9
10
  export * from './src/libs/paths'
10
11
  export * from './src/libs/placeholder'
11
12
  export * from './src/libs/rehype'
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chassis-ui/docs",
3
3
  "description": "Shared Astro components and layouts for Chassis documentation sites.",
4
- "version": "0.11.0",
4
+ "version": "0.11.1",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -47,6 +47,7 @@
47
47
  "./layouts/SingleLayout.astro": "./src/layouts/SingleLayout.astro",
48
48
  "./components/DocsSidebar.astro": "./src/components/DocsSidebar.astro",
49
49
  "./components/FeatureCard.astro": "./src/components/FeatureCard.astro",
50
+ "./components/MediaImage.astro": "./src/components/MediaImage.astro",
50
51
  "./components/NavLink.astro": "./src/components/NavLink.astro",
51
52
  "./components/ResponsiveImage.astro": "./src/components/ResponsiveImage.astro",
52
53
  "./components/TableOfContents.astro": "./src/components/TableOfContents.astro",
@@ -95,7 +96,7 @@
95
96
  "clipboard": "^2.0.11",
96
97
  "github-slugger": "^2.0.0",
97
98
  "image-size": "^2.0.4",
98
- "js-yaml": "^5.4.3",
99
+ "js-yaml": "^5.5.0",
99
100
  "mdast-util-from-markdown": "^2.1.0",
100
101
  "mdast-util-mdx-jsx": "^3.2.0",
101
102
  "mdast-util-to-string": "^4.0.0",
@@ -106,16 +107,16 @@
106
107
  "unist-util-visit": "^5.1.0"
107
108
  },
108
109
  "devDependencies": {
109
- "@astrojs/markdown-remark": "^7.3.2",
110
- "@chassis-ui/css": "^0.10.0",
110
+ "@astrojs/markdown-remark": "^7.3.4",
111
+ "@chassis-ui/css": "^0.11.1",
111
112
  "@pagefind/component-ui": "^1.5.2",
112
113
  "@shikijs/transformers": "^4.5.0",
113
- "astro": "^7.3.8",
114
- "html-validate": "^11.16.2",
114
+ "astro": "^7.4.2",
115
+ "html-validate": "^11.17.0",
115
116
  "rehype-autolink-headings": "^7.1.0",
116
117
  "remark-mdx": "^3.1.1",
117
- "sass": "^1.105.1",
118
+ "sass": "^1.106.1",
118
119
  "vfile": "^6.0.3",
119
- "vnu-jar": "^26.10.9"
120
+ "vnu-jar": "^26.10.12"
120
121
  }
121
122
  }
@@ -0,0 +1,59 @@
1
+ ---
2
+ /**
3
+ * Media Image
4
+ *
5
+ * An image of the media library that `media` of the config names. The `srcset` has a copy
6
+ * of the image at several widths, and the browser loads the smallest that fills the image,
7
+ * in AVIF or WebP when it accepts them.
8
+ */
9
+
10
+ import type { HTMLAttributes } from 'astro/types'
11
+ import { getMediaImageWidths } from '../libs/media'
12
+ import { getMediaSrcSet, getMediaUrl } from '../site'
13
+
14
+ interface Props extends Omit<
15
+ HTMLAttributes<'img'>,
16
+ 'alt' | 'height' | 'sizes' | 'src' | 'srcset' | 'width'
17
+ > {
18
+ /** Path of the image in the media library, e.g. `"blog/2026/color-system/cover.jpg"` */
19
+ src: string
20
+ /** Alternative text for the image */
21
+ alt: string
22
+ /** Largest width in CSS pixels that the image is shown at */
23
+ width: number
24
+ /**
25
+ * Height in CSS pixels at `width`. The image is cropped to the ratio of `width` and
26
+ * `height`, around its subject. Without it, the image keeps its own ratio and the `height`
27
+ * attribute is left out
28
+ */
29
+ height?: number
30
+ /** Widths of the copies in the `srcset`. Default: the widths up to twice `width` */
31
+ widths?: number[]
32
+ /** The `sizes` attribute. Default: `width` pixels, or the width of the viewport when smaller */
33
+ sizes?: string
34
+ }
35
+
36
+ const {
37
+ src,
38
+ alt,
39
+ width,
40
+ height,
41
+ widths = getMediaImageWidths(width),
42
+ sizes = `(width >= ${width}px) ${width}px, 100vw`,
43
+ loading = 'lazy',
44
+ decoding = 'async',
45
+ ...rest
46
+ } = Astro.props
47
+ ---
48
+
49
+ <img
50
+ src={getMediaUrl(src, { width, height })}
51
+ srcset={getMediaSrcSet(src, widths, { width, height })}
52
+ sizes={sizes}
53
+ alt={alt}
54
+ width={width}
55
+ height={height}
56
+ loading={loading}
57
+ decoding={decoding}
58
+ {...rest}
59
+ />
@@ -2,9 +2,12 @@
2
2
  /**
3
3
  * Responsive Image
4
4
  *
5
- * Render responsive images with 1x and 2x resolution variants.
5
+ * Render responsive images with 1x and 2x resolution variants. A file of the `public`
6
+ * directory is linked with the version of its content, so the host can let the browser keep it.
6
7
  */
7
8
 
9
+ import { getVersionedPublicPath } from '../site'
10
+
8
11
  interface Props {
9
12
  /** Path to the image file */
10
13
  imgPath: string
@@ -30,8 +33,8 @@ const basePath = imgPath.split('/').slice(0, -1).join('/')
30
33
  const basename = imgPath.split('/').pop()?.split('.')[0] || ''
31
34
  const ext = imgPath.includes('.') ? `.${imgPath.split('.').pop()}` : ''
32
35
 
33
- const imgPath1x = `${basePath}/${basename}${ext}`
34
- const imgPath2x = `${basePath}/${basename}@2x${ext}`
36
+ const imgPath1x = getVersionedPublicPath(`${basePath}/${basename}${ext}`)
37
+ const imgPath2x = getVersionedPublicPath(`${basePath}/${basename}@2x${ext}`)
35
38
  ---
36
39
 
37
40
  <img
package/src/js/search.js CHANGED
@@ -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
  }
@@ -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)} />
@@ -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>
@@ -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. */
@@ -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,7 @@ 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'
17
18
  import { versionStaticPath } from './libs/static-version'
18
19
 
19
20
  /**
@@ -56,6 +57,41 @@ export function getVersionedStaticPath(file: string): string {
56
57
  return versionStaticPath(getStaticPath(file), paths.publicDir)
57
58
  }
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
+
59
95
  /** Absolute path of the site's root: the directory that holds `astro.config.ts`. */
60
96
  export function getSiteRoot(): string {
61
97
  return paths.root