@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 +8 -0
- package/README.md +68 -30
- 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/js/search.js +3 -1
- package/src/layouts/head/Social.astro +23 -10
- package/src/layouts/header/SearchTrigger.astro +0 -1
- package/src/libs/media.ts +158 -0
- package/src/libs/schema.ts +18 -0
- package/src/scss/_search.scss +12 -9
- package/src/site.ts +36 -0
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
|
-
| `
|
|
121
|
-
| `
|
|
122
|
-
| `
|
|
123
|
-
| `
|
|
124
|
-
| `
|
|
125
|
-
| `
|
|
126
|
-
| `
|
|
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.
|
|
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.
|
|
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.
|
|
110
|
-
"@chassis-ui/css": "^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.
|
|
114
|
-
"html-validate": "^11.
|
|
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.
|
|
118
|
+
"sass": "^1.106.1",
|
|
118
119
|
"vfile": "^6.0.3",
|
|
119
|
-
"vnu-jar": "^26.10.
|
|
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 {
|
|
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)} />
|
|
@@ -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
|
+
}
|
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. */
|
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,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
|