@chassis-ui/docs 0.6.0 → 0.6.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,16 @@
1
1
  # @chassis-ui/docs
2
2
 
3
+ ## 0.6.1
4
+
5
+ ### Patch Changes
6
+
7
+ - A code block can take focus, so that a keyboard can scroll one that is wider than the page. The Google Fonts stylesheet is linked from the head instead of imported by the styles, so the browser requests it with the page, not after the stylesheet that imported it.
8
+ - Google Analytics loads only after the visitor accepts. When `analytics.googleId` is set, production builds show a consent banner at the bottom of the page, and nothing is sent to Google and no cookie is set before the visitor accepts. The choice is kept in `localStorage`, so the sites served from one host share it. The footer has a "Cookie settings" button that opens the banner again, and a "Privacy" link to `/privacy/`. Declining stops Google Analytics and deletes its cookies.
9
+ - The head no longer links `safari-pinned-tab.svg` as a `mask-icon`. The docs build of chassis-assets has no such file, so every page asked for one that returned 404. Safari has used the regular favicon for pinned tabs since version 12.
10
+ - The header and the footer link Chassis React: "React" in the navigation of the header, "React Components" among the docs of the footer and "Chassis React" among its GitHub links. The footer lists the projects in the order of the header. The "Privacy" link and the "Cookie settings" button moved from the last column of the footer to below the license.
11
+ - The package installs a `chassis-docs` command with the build steps that every Chassis site shares, so that a site no longer needs its own copies of them. `chassis-docs vendor` builds the `vendor/assets` submodule at the pinned commit. `chassis-docs sync-submodules` moves it to the latest `app/docs` and builds it, and works on a fresh CI checkout too. `chassis-docs html-validate` and `chassis-docs vnu` validate the built HTML. The two validators need `html-validate` and `vnu-jar`, which are optional peer dependencies. See [Commands](https://github.com/chassis-ui/website/tree/main/packages/docs#commands) in the README.
12
+ - The "Skip to main content" link of the header has a target on every page. `BaseLayout` gives its `<main>` the id `content`, which only the docs layout had set, on its title. A page that fills the `main` slot of `BaseLayout` sets the id on an element of its own.
13
+
3
14
  ## 0.6.0
4
15
 
5
16
  ### Minor Changes
package/README.md CHANGED
@@ -34,6 +34,8 @@ pnpm add @chassis-ui/docs
34
34
  }
35
35
  ```
36
36
 
37
+ Two more are optional. They are needed by the [commands](#commands) that validate HTML: `html-validate` `^11.0.0` and `vnu-jar` `>=26.0.0`.
38
+
37
39
  A site also installs `@astrojs/mdx` and the rest of the Chassis stack, `@chassis-ui/tokens` and `@chassis-ui/icons`.
38
40
 
39
41
  ## Setup
@@ -114,7 +116,7 @@ currentVersion: '0.5.2'
114
116
  | `githubOrg` | no | GitHub organisation name, not a URL. The header links to it |
115
117
  | `figmaHandle` | no | Figma Community handle. The header links to it |
116
118
  | `xUsername` | no | X handle. Used in the social meta tags |
117
- | `analytics.googleId` | no | Google Analytics ID. Loaded in production builds only |
119
+ | `analytics.googleId` | no | Google Analytics ID. In production builds, loaded after the visitor accepts in a consent banner |
118
120
  | `anchors.min`, `anchors.max` | no | Heading levels that get an anchor link. Default 2 to 5 |
119
121
  | `toc.min`, `toc.max` | no | Heading levels in the table of contents. Default 2 to 6 |
120
122
  | `sourceDir` | no | Directory that `file` props are relative to, from the site's root, e.g. `"../css"`. Default `"."` |
@@ -187,6 +189,8 @@ The layouts link to files under `/static/`, which the site puts into its `public
187
189
  | `/static/icons/chassis-icons.svg`, `/static/icons/chassis-icons.css` | the `icons` folder of `@chassis-ui/icons` |
188
190
  | `/static/images/*`: logo, favicons, social image | the docs build of `chassis-assets` |
189
191
 
192
+ `chassis-docs vendor` builds the docs build of `chassis-assets` in the `vendor/assets` submodule. See [Commands](#commands).
193
+
190
194
  These helpers find the folders, whether `node_modules` is in the site's root or in the root of the repository: `getChassisCSSFsPath()`, `getChassisIconsFsPath()`, `getChassisAssetsFsPath()` and `getChassisTokensFsPath()`. Each takes `{ root, dir }`.
191
195
 
192
196
  A site that type-checks with `astro check` declares the `@chassis-ui/css` module, which has no types: `declare module '@chassis-ui/css'`.
@@ -238,6 +242,88 @@ chassisDocs({
238
242
 
239
243
  Code that runs while Astro loads its configuration uses the library functions of the root entry instead: `loadConfig()`, `loadData()`, `loadSidebar()` and the path helpers.
240
244
 
245
+ ## Commands
246
+
247
+ The package installs a `chassis-docs` command with the build steps that every Chassis site shares. Call it from the scripts of `package.json`:
248
+
249
+ ```json
250
+ {
251
+ "scripts": {
252
+ "vendor": "chassis-docs vendor",
253
+ "sync-submodules": "chassis-docs sync-submodules",
254
+ "lint:html": "chassis-docs html-validate _site",
255
+ "lint:vnu": "chassis-docs vnu _site"
256
+ }
257
+ }
258
+ ```
259
+
260
+ | Command | What it does |
261
+ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
262
+ | `chassis-docs vendor` | Checks out the `vendor/assets` submodule at the commit that the repository pins, pulls its Git LFS files, installs its dependencies and runs its `pnpm assets:site` |
263
+ | `chassis-docs sync-submodules` | Moves `vendor/assets` to the latest commit of the `app/docs` branch and builds it. Commit the new pointer afterwards |
264
+ | `chassis-docs html-validate [paths]` | Validates the HTML files in `paths` with [html-validate](https://html-validate.org) |
265
+ | `chassis-docs vnu [paths]` | Validates `paths` with the [Nu Html Checker](https://validator.github.io/validator/). Skipped, with a warning, when Java is missing |
266
+ | `chassis-docs help` | Lists the commands and their options |
267
+
268
+ `vendor` and `sync-submodules` work from any folder of the repository. They need Git, Git LFS and pnpm. `sync-submodules --branch <name>`, or the `SUBMODULE_BRANCH` variable, follows another branch.
269
+
270
+ Each command exits with 1 when it fails, so a script or CI job stops there.
271
+
272
+ ### Validating HTML
273
+
274
+ `html-validate` and `vnu` take any number of files and folders, `_site` when none is given. Paths are relative to the folder the script runs in, which for a site in `packages/site` is that folder when the script is in its `package.json`, and the repository root when it is in the root one.
275
+
276
+ What a site can change:
277
+
278
+ | To | Use | Validator | Adds to the defaults or replaces them |
279
+ | ----------------------------------- | ------------------------------------- | ------------- | ------------------------------------- |
280
+ | Skip a file or folder | `--ignore <path>`, repeatable | both | adds |
281
+ | Check `static/icons` too | `--no-default-ignore` | both | removes the default |
282
+ | Turn a rule off or change it | `rules` in the file of `--config` | html-validate | overrides the rule |
283
+ | Use more presets or plugins | `extends` in the file of `--config` | html-validate | adds |
284
+ | Allow custom elements or attributes | `elements` in the file of `--config` | html-validate | replaces |
285
+ | Leave out a message | `--filter <regex>` or `--filter-file` | vnu | adds |
286
+
287
+ The defaults:
288
+
289
+ - Both skip `static/icons` in each path, where a site copies `@chassis-ui/icons`, whose preview page is not the site's.
290
+ - `html-validate` extends `html-validate:recommended` and `html-validate:document`. It turns off `void-style`, since Astro writes void elements with a slash, `no-inline-style`, since Shiki writes inline styles, and `require-sri`. `heading-level` lets a page start at `h2`, as the dialogs of the layouts do.
291
+ - `vnu` fails on warnings as well as errors, and leaves out "Trailing slash on void elements", which Astro writes.
292
+
293
+ A site with its own exceptions keeps them in files next to its `package.json`, and names them in its scripts:
294
+
295
+ ```json
296
+ {
297
+ "scripts": {
298
+ "lint:html": "chassis-docs html-validate _site --ignore _site/examples --config html-validate.json",
299
+ "lint:vnu": "chassis-docs vnu _site --ignore _site/examples --filter-file vnu-filters.txt"
300
+ }
301
+ }
302
+ ```
303
+
304
+ `html-validate.json`:
305
+
306
+ ```json
307
+ {
308
+ "rules": {
309
+ "prefer-native-element": ["error", { "exclude": ["region"] }],
310
+ "prefer-button": "off"
311
+ }
312
+ }
313
+ ```
314
+
315
+ `vnu-filters.txt`, one regular expression per line, where a line that starts with `#` is a comment:
316
+
317
+ ```text
318
+ # The docs show autocomplete on inputs of every type
319
+ Attribute “autocomplete” is only allowed when the input type is.*
320
+ Bad value “heading” for attribute “role” on element “summary”.
321
+ ```
322
+
323
+ A filter is matched against the message as the output shows it, curly quotes included: `“role”`. A line copied from the output works.
324
+
325
+ A rule or filter applies to every file that the command checks. To make an exception for one page only, skip the page with `--ignore`, or disable the rule inside the page's markup with a comment of html-validate, such as `<!-- [html-validate-disable-next prefer-button] -->`.
326
+
241
327
  ## Exports
242
328
 
243
329
  These import paths are the public API.
@@ -259,6 +345,24 @@ No other path resolves: `exports` in `package.json` lists these and nothing else
259
345
 
260
346
  The integration provides four modules: `virtual:chassis-docs/config`, `virtual:chassis-docs/sidebar`, `virtual:chassis-docs/paths` and `virtual:chassis-docs/styles`. Use the functions of `@chassis-ui/docs/site` instead of importing them.
261
347
 
348
+ ## Compatibility
349
+
350
+ "Requires" is what `package.json` of each version declares. "Built with" is what the website in this repository resolved and built when the version was released. CI also builds the [starter site](starter/) with the newest versions that its ranges accept.
351
+
352
+ | `@chassis-ui/docs` | Requires Astro | Requires `@chassis-ui/css` | Requires Node | Built with |
353
+ | ------------------ | -------------------------- | -------------------------- | ------------- | ------------------------------------ |
354
+ | 0.6.1 | `^7.0.0` | `^0.5.0-0` | `>=22.12.0` | Astro 7.3.5, css 0.5.2, tokens 0.6.0 |
355
+ | 0.6.0 | `^7.0.0` | `^0.5.0-0` | `>=22.12.0` | Astro 7.3.5, css 0.5.2, tokens 0.5.3 |
356
+ | 0.5.1 | `^7.0.0` | `^0.5.0-0` | `>=22.12.0` | Astro 7.3.5, css 0.5.2, tokens 0.5.3 |
357
+ | 0.5.0 | `^7.0.0` | `^0.5.0-0` | not declared | not recorded |
358
+ | 0.4.0 | `^7.0.0` | `^0.4.0` | not declared | not recorded |
359
+ | 0.3.0 to 0.3.10 | `^7.0.0` | `>=0.3.1` to `>=0.3.4` | not declared | not recorded |
360
+ | 0.1.0 to 0.2.0 | `^5.0.0`, 0.1.0–0.1.1 `^4` | not declared | not declared | not recorded |
361
+
362
+ `@chassis-ui/tokens` is not a dependency of the package. The styles take the design tokens that `@chassis-ui/css` carries: css 0.5 is built on tokens 0.5, css 0.4 on tokens 0.4. A site's own version of `@chassis-ui/tokens` matters only when the site puts it on the Sass load path in place of those. Tokens 0.6 renamed and removed some tokens, listed in its changelog.
363
+
364
+ Before a release, the [canary](https://github.com/chassis-ui/website/blob/main/build/canary.js) builds the site of each Chassis repository whose range accepts the new version.
365
+
262
366
  ## Versioning
263
367
 
264
368
  The package follows [Semantic Versioning](https://semver.org/). Before 1.0 that means:
@@ -270,6 +374,7 @@ A change is breaking when it changes something that this README documents:
270
374
 
271
375
  - the import paths listed under [Exports](#exports), and what they export
272
376
  - the options of the integration
377
+ - the commands of `chassis-docs`, their options and their defaults
273
378
  - the keys of `config.yml`, and the schemas of the sidebar and the content collections
274
379
  - the props and slots of the layouts, components and shortcodes
275
380
  - the `[[config:]]` and `[[docsref:]]` syntax
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ // The build and validation commands that every Chassis site shares. Run `chassis-docs help`.
4
+ import { main } from '../src/cli/main.js'
5
+
6
+ process.exitCode = await main(process.argv.slice(2))
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.6.0",
4
+ "version": "0.6.1",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -23,6 +23,7 @@
23
23
  "design-system"
24
24
  ],
25
25
  "files": [
26
+ "bin",
26
27
  "src",
27
28
  "index.ts",
28
29
  "virtual.d.ts",
@@ -31,6 +32,9 @@
31
32
  "LICENSE",
32
33
  "CHANGELOG.md"
33
34
  ],
35
+ "bin": {
36
+ "chassis-docs": "./bin/chassis-docs.js"
37
+ },
34
38
  "main": "index.ts",
35
39
  "exports": {
36
40
  ".": "./index.ts",
@@ -70,10 +74,20 @@
70
74
  "@pagefind/component-ui": "^1.0.0",
71
75
  "@shikijs/transformers": "^4.3.1",
72
76
  "astro": "^7.0.0",
77
+ "html-validate": "^11.0.0",
73
78
  "htmlparser2": "^12.0.0",
74
79
  "rehype-autolink-headings": "^7.0.0",
75
80
  "sass": "^1.101.0",
76
- "shiki": "^4.3.1"
81
+ "shiki": "^4.3.1",
82
+ "vnu-jar": ">=26.0.0"
83
+ },
84
+ "peerDependenciesMeta": {
85
+ "html-validate": {
86
+ "optional": true
87
+ },
88
+ "vnu-jar": {
89
+ "optional": true
90
+ }
77
91
  },
78
92
  "dependencies": {
79
93
  "@types/hast": "^3.0.5",
@@ -0,0 +1,111 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { CommandError, log, run as defaultRun } from './shared.js'
4
+
5
+ /** Where every Chassis site has chassis-assets as a submodule. */
6
+ export const ASSETS_PATH = 'vendor/assets'
7
+
8
+ /** The docs build of chassis-assets, inside the submodule. `getChassisAssetsFsPath()` reads it. */
9
+ export const ASSETS_OUTPUT = 'dist/web/docs/chassis'
10
+
11
+ /** The branch of chassis-assets that the docs sites follow. */
12
+ export const DEFAULT_BRANCH = 'app/docs'
13
+
14
+ /**
15
+ * The root of the repository that contains the working directory, so that the commands
16
+ * work from a site's folder too.
17
+ * @param {typeof defaultRun} run
18
+ */
19
+ function repositoryRoot(run) {
20
+ return run('git', ['rev-parse', '--show-toplevel'], { quiet: true }).trim()
21
+ }
22
+
23
+ /**
24
+ * Checks out `vendor/assets` at the commit that the repository pins, and builds it.
25
+ * @param {{ root?: string, run?: typeof defaultRun }} [options]
26
+ */
27
+ export function buildAssets({ run = defaultRun, root = repositoryRoot(run) } = {}) {
28
+ log(`Building ${ASSETS_PATH} at the pinned commit`)
29
+ run('git', ['submodule', 'update', '--init', ASSETS_PATH], { cwd: root })
30
+ buildCheckout(root, run)
31
+ }
32
+
33
+ /**
34
+ * Moves `vendor/assets` to the latest commit of `branch`, and builds it. The repository
35
+ * then has a changed submodule pointer to commit.
36
+ * @param {{ root?: string, branch?: string, run?: typeof defaultRun }} [options]
37
+ */
38
+ export function syncAssets({
39
+ run = defaultRun,
40
+ root = repositoryRoot(run),
41
+ branch = DEFAULT_BRANCH
42
+ } = {}) {
43
+ const dir = path.join(root, ASSETS_PATH)
44
+ const git = (args, quiet = true) => run('git', args, { cwd: dir, quiet })
45
+
46
+ log(`Moving ${ASSETS_PATH} to the latest ${branch}`)
47
+
48
+ if (!fs.existsSync(path.join(dir, '.git'))) {
49
+ run('git', ['submodule', 'update', '--init', ASSETS_PATH], { cwd: root })
50
+ }
51
+
52
+ if (git(['rev-parse', '--abbrev-ref', 'HEAD']).trim() !== branch) {
53
+ if (git(['status', '--porcelain']).trim()) {
54
+ throw new CommandError(
55
+ `${ASSETS_PATH} has uncommitted changes. Commit or discard them, then run the command again.`
56
+ )
57
+ }
58
+
59
+ // A fresh checkout, as on CI, has the submodule at a detached HEAD, fetched for the
60
+ // pinned commit only: there is neither a local branch nor a remote-tracking one.
61
+ git(['fetch', 'origin', branch])
62
+
63
+ if (hasLocalBranch(git, branch)) {
64
+ git(['checkout', branch])
65
+ } else {
66
+ git(['checkout', '-b', branch, 'FETCH_HEAD'])
67
+ }
68
+ }
69
+
70
+ git(['pull', '--ff-only', 'origin', branch])
71
+ buildCheckout(root, run)
72
+
73
+ const commit = git(['rev-parse', '--short', 'HEAD']).trim()
74
+ const moved = run('git', ['status', '--porcelain', '--', ASSETS_PATH], {
75
+ cwd: root,
76
+ quiet: true
77
+ }).trim()
78
+
79
+ if (moved) {
80
+ log(`${ASSETS_PATH} is at ${commit}. Check the site, then commit ${ASSETS_PATH} on its own.`)
81
+ } else {
82
+ log(`${ASSETS_PATH} is already at the latest ${branch}, ${commit}.`, 'success')
83
+ }
84
+ }
85
+
86
+ function hasLocalBranch(git, branch) {
87
+ try {
88
+ git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])
89
+ return true
90
+ } catch {
91
+ return false
92
+ }
93
+ }
94
+
95
+ function buildCheckout(root, run) {
96
+ const dir = path.join(root, ASSETS_PATH)
97
+
98
+ // The images are stored with Git LFS. Without it they are pointer files.
99
+ run('git', ['lfs', 'install', '--local'], { cwd: dir, quiet: true })
100
+ run('git', ['lfs', 'pull'], { cwd: dir })
101
+ run('pnpm', ['install', '--ignore-workspace'], { cwd: dir })
102
+ run('pnpm', ['assets:site'], { cwd: dir })
103
+
104
+ const output = path.join(dir, ASSETS_OUTPUT)
105
+
106
+ if (!fs.existsSync(output)) {
107
+ throw new CommandError(`The build of ${ASSETS_PATH} wrote nothing to ${ASSETS_OUTPUT}.`)
108
+ }
109
+
110
+ log(`Built ${path.join(ASSETS_PATH, ASSETS_OUTPUT)}`, 'success')
111
+ }
@@ -0,0 +1,97 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { CommandError } from './shared.js'
4
+
5
+ /** The default output folder of a Chassis site. */
6
+ export const DEFAULT_PATHS = ['_site']
7
+
8
+ /**
9
+ * The folders to skip when no `--ignore` is given: `static/icons` of each path, the files
10
+ * that a site copies from `@chassis-ui/icons`. They include a preview page of their own.
11
+ * @param {string[]} paths
12
+ */
13
+ export function defaultIgnores(paths) {
14
+ return paths.map((dir) => path.join(dir, 'static/icons'))
15
+ }
16
+
17
+ /**
18
+ * Whether `file` is one of `ignores` or inside one of them.
19
+ * @param {string} file
20
+ * @param {string[]} ignores
21
+ */
22
+ export function isIgnored(file, ignores) {
23
+ const target = path.resolve(file)
24
+
25
+ return ignores.some((ignore) => isInside(target, path.resolve(ignore)))
26
+ }
27
+
28
+ function isInside(target, dir) {
29
+ const relative = path.relative(dir, target)
30
+
31
+ return relative === '' || (relative.split(path.sep)[0] !== '..' && !path.isAbsolute(relative))
32
+ }
33
+
34
+ function checkExists(paths) {
35
+ for (const entry of paths) {
36
+ if (!fs.existsSync(entry)) {
37
+ throw new CommandError(`${entry} does not exist. Build the site first.`)
38
+ }
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Lists the HTML files in `paths`, which are files or folders, leaving out `ignores` and
44
+ * `node_modules`.
45
+ * @param {string[]} paths
46
+ * @param {string[]} ignores
47
+ * @returns {string[]}
48
+ */
49
+ export function findHtmlFiles(paths, ignores) {
50
+ const files = []
51
+
52
+ const visit = (entry) => {
53
+ if (isIgnored(entry, ignores)) return
54
+
55
+ if (fs.statSync(entry).isDirectory()) {
56
+ for (const name of fs.readdirSync(entry).sort()) {
57
+ if (name !== 'node_modules') visit(path.join(entry, name))
58
+ }
59
+ } else if (entry.endsWith('.html')) {
60
+ files.push(entry)
61
+ }
62
+ }
63
+
64
+ checkExists(paths)
65
+ paths.forEach(visit)
66
+
67
+ return files
68
+ }
69
+
70
+ /**
71
+ * Returns `paths` with `ignores` taken out. A folder that contains an ignored path is
72
+ * replaced by its entries, so that a checker that walks folders never reaches it.
73
+ * @param {string[]} paths
74
+ * @param {string[]} ignores
75
+ * @returns {string[]}
76
+ */
77
+ export function expandPaths(paths, ignores) {
78
+ const result = []
79
+
80
+ const visit = (entry) => {
81
+ if (isIgnored(entry, ignores)) return
82
+
83
+ const target = path.resolve(entry)
84
+ const containsIgnored = ignores.some((ignore) => isInside(path.resolve(ignore), target))
85
+
86
+ if (containsIgnored && fs.statSync(entry).isDirectory()) {
87
+ for (const name of fs.readdirSync(entry).sort()) visit(path.join(entry, name))
88
+ } else {
89
+ result.push(entry)
90
+ }
91
+ }
92
+
93
+ checkExists(paths)
94
+ paths.forEach(visit)
95
+
96
+ return result
97
+ }
@@ -0,0 +1,111 @@
1
+ import fs from 'node:fs'
2
+ import { CommandError, importPeer, log } from './shared.js'
3
+ import { findHtmlFiles } from './files.js'
4
+
5
+ /** What the layouts and shortcodes of the package need. A site adds its own on top. */
6
+ export const defaultConfig = {
7
+ extends: ['html-validate:recommended', 'html-validate:document'],
8
+ rules: {
9
+ // Astro writes void elements with a trailing slash
10
+ 'void-style': 'off',
11
+ // Shiki highlights code with inline styles
12
+ 'no-inline-style': 'off',
13
+ // The dialogs start at h2, and the `document` preset expects h1 in each sectioning root
14
+ 'heading-level': ['error', { minInitialRank: 'h2', sectioningRoots: [] }],
15
+ // The layouts load their files from the site itself
16
+ 'require-sri': 'off'
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Adds a site's config to the default one: `extends` is appended, `rules` are merged,
22
+ * and `elements` is replaced.
23
+ * @param {Record<string, any>} base
24
+ * @param {Record<string, any>} [extra]
25
+ * @returns {Record<string, any>}
26
+ */
27
+ export function mergeConfig(base, extra = {}) {
28
+ return {
29
+ ...base,
30
+ ...extra,
31
+ extends: [...(base.extends ?? []), ...(extra.extends ?? [])],
32
+ rules: { ...base.rules, ...extra.rules }
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Reads a config file of html-validate.
38
+ * @param {string} file
39
+ */
40
+ export function readConfig(file) {
41
+ try {
42
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
43
+ } catch (error) {
44
+ throw new CommandError(`Could not read ${file}: ${error.message}`, { cause: error })
45
+ }
46
+ }
47
+
48
+ // Validating hundreds of files at once runs out of file descriptors.
49
+ async function mapWithConcurrency(limit, items, fn) {
50
+ const results = []
51
+ const running = new Set()
52
+
53
+ for (const item of items) {
54
+ const promise = fn(item).finally(() => running.delete(promise))
55
+
56
+ running.add(promise)
57
+ results.push(promise)
58
+
59
+ if (running.size >= limit) await Promise.race(running)
60
+ }
61
+
62
+ return Promise.all(results)
63
+ }
64
+
65
+ /**
66
+ * Validates the HTML files in `paths` with html-validate. Returns the exit code.
67
+ * @param {{ paths: string[], ignores: string[], config?: string }} options
68
+ */
69
+ export async function htmlValidate({ paths, ignores, config }) {
70
+ const { HtmlValidate, Severity } = await importPeer('html-validate')
71
+ const validator = new HtmlValidate(mergeConfig(defaultConfig, config && readConfig(config)))
72
+ const files = findHtmlFiles(paths, ignores)
73
+
74
+ log(`Validating ${files.length} HTML files with html-validate`)
75
+
76
+ const reports = await mapWithConcurrency(10, files, async (file) => ({
77
+ file,
78
+ report: await validator.validateFile(file)
79
+ }))
80
+
81
+ let issueCount = 0
82
+ let fileCount = 0
83
+
84
+ for (const { file, report } of reports) {
85
+ const messages = (report.results[0]?.messages ?? []).filter(
86
+ (message) => message.severity >= Severity.WARN
87
+ )
88
+
89
+ if (messages.length === 0) continue
90
+
91
+ fileCount++
92
+ issueCount += messages.length
93
+ console.error(`\n${file}`)
94
+
95
+ for (const message of messages) {
96
+ const level = message.severity === Severity.ERROR ? 'error' : 'warning'
97
+
98
+ console.error(
99
+ ` ${message.line}:${message.column} ${level} ${message.message} (${message.ruleId})`
100
+ )
101
+ }
102
+ }
103
+
104
+ if (issueCount > 0) {
105
+ log(`\n${issueCount} issues in ${fileCount} files.`, 'error')
106
+ return 1
107
+ }
108
+
109
+ log('Every HTML file is valid.', 'success')
110
+ return 0
111
+ }
@@ -0,0 +1,131 @@
1
+ import { parseArgs } from 'node:util'
2
+ import { DEFAULT_BRANCH, buildAssets, syncAssets } from './assets.js'
3
+ import { DEFAULT_PATHS, defaultIgnores } from './files.js'
4
+ import { htmlValidate } from './html-validate.js'
5
+ import { CommandError, log } from './shared.js'
6
+ import { vnu } from './vnu.js'
7
+
8
+ export const usage = `Usage: chassis-docs <command> [options]
9
+
10
+ Paths are relative to the working directory. vendor and sync-submodules work from any
11
+ folder of the repository.
12
+
13
+ Commands:
14
+ vendor Build vendor/assets at the commit that the repository pins
15
+ sync-submodules Move vendor/assets to the latest commit of a branch, and build it
16
+ --branch <name> The branch of chassis-assets (default: $SUBMODULE_BRANCH or ${DEFAULT_BRANCH})
17
+ html-validate [paths] Validate the HTML files in paths (default: _site) with html-validate
18
+ --config <file> A JSON config of html-validate, added to the defaults
19
+ vnu [paths] Validate paths (default: _site) with the Nu Html Checker. Needs Java
20
+ --filter <regex> Leave out the messages that match. Repeatable
21
+ --filter-file <f> Read filters from a file, one per line
22
+
23
+ Both validators:
24
+ --ignore <path> Skip a file or folder as well. Repeatable
25
+ --no-default-ignore Check static/icons of each path, which is skipped by default
26
+ help Show this message
27
+
28
+ html-validate and vnu need the html-validate and vnu-jar packages in the site's devDependencies.`
29
+
30
+ const ignoreOptions = {
31
+ ignore: { type: 'string', multiple: true },
32
+ 'no-default-ignore': { type: 'boolean' }
33
+ }
34
+
35
+ // The static/icons folder of each path, unless left out, and the paths of --ignore.
36
+ function ignoresOf(values, paths) {
37
+ return [...(values['no-default-ignore'] ? [] : defaultIgnores(paths)), ...(values.ignore ?? [])]
38
+ }
39
+
40
+ const commands = {
41
+ vendor: {
42
+ options: {},
43
+ run: () => {
44
+ buildAssets()
45
+ return 0
46
+ }
47
+ },
48
+ 'sync-submodules': {
49
+ options: { branch: { type: 'string' } },
50
+ run: ({ values }) => {
51
+ syncAssets({ branch: values.branch ?? process.env.SUBMODULE_BRANCH ?? DEFAULT_BRANCH })
52
+ return 0
53
+ }
54
+ },
55
+ 'html-validate': {
56
+ options: { config: { type: 'string' }, ...ignoreOptions },
57
+ positionals: true,
58
+ run: ({ values, paths }) =>
59
+ htmlValidate({
60
+ paths,
61
+ ignores: ignoresOf(values, paths),
62
+ config: values.config
63
+ })
64
+ },
65
+ vnu: {
66
+ options: {
67
+ filter: { type: 'string', multiple: true },
68
+ 'filter-file': { type: 'string' },
69
+ ...ignoreOptions
70
+ },
71
+ positionals: true,
72
+ run: ({ values, paths }) =>
73
+ vnu({
74
+ paths,
75
+ ignores: ignoresOf(values, paths),
76
+ filters: values.filter,
77
+ filterFile: values['filter-file']
78
+ })
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Runs a command and returns its exit code.
84
+ * @param {string[]} argv The arguments after `chassis-docs`
85
+ * @returns {Promise<number>}
86
+ */
87
+ export async function main(argv) {
88
+ const [name, ...args] = argv
89
+
90
+ if (!name || ['help', '--help', '-h'].includes(name)) {
91
+ console.log(usage)
92
+ return 0
93
+ }
94
+
95
+ const command = commands[name]
96
+
97
+ try {
98
+ if (!Object.hasOwn(commands, name)) {
99
+ throw new CommandError(`Unknown command '${name}'. Run chassis-docs help for the list.`)
100
+ }
101
+
102
+ let parsed
103
+
104
+ try {
105
+ parsed = parseArgs({
106
+ args,
107
+ options: { ...command.options, help: { type: 'boolean', short: 'h' } },
108
+ allowPositionals: command.positionals ?? false,
109
+ strict: true
110
+ })
111
+ } catch (error) {
112
+ throw new CommandError(`${error.message}. Run chassis-docs help for the options.`, {
113
+ cause: error
114
+ })
115
+ }
116
+
117
+ if (parsed.values.help) {
118
+ console.log(usage)
119
+ return 0
120
+ }
121
+
122
+ const paths = parsed.positionals.length > 0 ? parsed.positionals : DEFAULT_PATHS
123
+
124
+ return await command.run({ values: parsed.values, paths })
125
+ } catch (error) {
126
+ if (!(error instanceof CommandError)) throw error
127
+
128
+ log(error.message, 'error')
129
+ return 1
130
+ }
131
+ }
@@ -0,0 +1,69 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { styleText } from 'node:util'
3
+
4
+ /** An error that the command reports in one line, without a stack trace. */
5
+ export class CommandError extends Error {}
6
+
7
+ const colors = { info: 'cyan', success: 'green', warning: 'yellow', error: 'red' }
8
+
9
+ /**
10
+ * Prints a message in the colour of its type. Errors and warnings go to stderr.
11
+ * @param {string} message
12
+ * @param {'info' | 'success' | 'warning' | 'error'} [type]
13
+ */
14
+ export function log(message, type = 'info') {
15
+ const stream = type === 'error' || type === 'warning' ? process.stderr : process.stdout
16
+
17
+ stream.write(`${styleText(colors[type], message)}\n`)
18
+ }
19
+
20
+ /**
21
+ * Runs a program without a shell, except on Windows, where `pnpm` is a `.cmd` file.
22
+ * Returns what it printed when `quiet` is set, and prints it otherwise.
23
+ * @param {string} command
24
+ * @param {string[]} args
25
+ * @param {{ cwd?: string, quiet?: boolean }} [options]
26
+ * @returns {string}
27
+ */
28
+ export function run(command, args, { cwd = process.cwd(), quiet = false } = {}) {
29
+ if (!quiet) {
30
+ log(`$ ${[command, ...args].join(' ')}`)
31
+ }
32
+
33
+ try {
34
+ return (
35
+ execFileSync(command, args, {
36
+ cwd,
37
+ encoding: 'utf8',
38
+ stdio: quiet ? 'pipe' : 'inherit',
39
+ shell: process.platform === 'win32'
40
+ }) ?? ''
41
+ )
42
+ } catch (error) {
43
+ const stderr = typeof error.stderr === 'string' ? error.stderr.trim() : ''
44
+
45
+ throw new CommandError(
46
+ `${[command, ...args].join(' ')} failed${stderr ? `:\n${stderr}` : ''}`,
47
+ { cause: error }
48
+ )
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Imports an optional peer dependency, or says how to install it.
54
+ * @param {string} name
55
+ */
56
+ export async function importPeer(name) {
57
+ try {
58
+ return await import(name)
59
+ } catch (error) {
60
+ if (error.code === 'ERR_MODULE_NOT_FOUND' && error.message.includes(`'${name}'`)) {
61
+ throw new CommandError(
62
+ `${name} is not installed. Add it to the devDependencies of the site: pnpm add -D ${name}`,
63
+ { cause: error }
64
+ )
65
+ }
66
+
67
+ throw error
68
+ }
69
+ }
package/src/cli/vnu.js ADDED
@@ -0,0 +1,94 @@
1
+ import { execFile, spawn } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import { CommandError, importPeer, log } from './shared.js'
4
+ import { expandPaths } from './files.js'
5
+
6
+ /** Messages that every Chassis site produces. Astro writes void elements with a slash. */
7
+ export const DEFAULT_FILTERS = ['Trailing slash on void elements.*']
8
+
9
+ /**
10
+ * Reads a filter file: one regular expression per line. Blank lines and lines that start
11
+ * with `#` are skipped.
12
+ * @param {string} file
13
+ */
14
+ export function readFilterFile(file) {
15
+ let text
16
+
17
+ try {
18
+ text = fs.readFileSync(file, 'utf8')
19
+ } catch (error) {
20
+ throw new CommandError(`Could not read ${file}: ${error.message}`, { cause: error })
21
+ }
22
+
23
+ return text
24
+ .split(/\r?\n/)
25
+ .map((line) => line.trim())
26
+ .filter((line) => line && !line.startsWith('#'))
27
+ }
28
+
29
+ /**
30
+ * The arguments for `java`.
31
+ * @param {{ jar: string, paths: string[], filters: string[], is32bit?: boolean }} options
32
+ */
33
+ export function vnuArgs({ jar, paths, filters, is32bit = false }) {
34
+ return [
35
+ // 32-bit Java needs a smaller thread stack
36
+ ...(is32bit ? ['-Xss512k'] : []),
37
+ '-jar',
38
+ jar,
39
+ '--skip-non-html',
40
+ '--Werror',
41
+ '--filterpattern',
42
+ filters.join('|'),
43
+ ...paths
44
+ ]
45
+ }
46
+
47
+ function javaVersion() {
48
+ return new Promise((resolve) => {
49
+ // `java -version` prints to stderr
50
+ execFile('java', ['-version'], (error, _stdout, stderr) => resolve(error ? null : stderr))
51
+ })
52
+ }
53
+
54
+ /**
55
+ * Validates `paths` with the Nu Html Checker. Skips, and succeeds, when Java is missing.
56
+ * Returns the exit code.
57
+ * @param {{ paths: string[], ignores: string[], filters?: string[], filterFile?: string }} options
58
+ */
59
+ export async function vnu({ paths, ignores, filters = [], filterFile }) {
60
+ const { default: jar } = await importPeer('vnu-jar')
61
+ const allFilters = [
62
+ ...DEFAULT_FILTERS,
63
+ ...filters,
64
+ ...(filterFile ? readFilterFile(filterFile) : [])
65
+ ]
66
+ const targets = expandPaths(paths, ignores)
67
+ const version = await javaVersion()
68
+
69
+ if (version === null) {
70
+ log('Java is not installed. Skipping the Nu Html Checker.', 'warning')
71
+ return 0
72
+ }
73
+
74
+ log('Validating HTML with the Nu Html Checker')
75
+
76
+ const args = vnuArgs({
77
+ jar,
78
+ paths: targets,
79
+ filters: allFilters,
80
+ is32bit: !/64-Bit/.test(version)
81
+ })
82
+
83
+ const code = await new Promise((resolve) => {
84
+ spawn('java', args, { stdio: 'inherit' }).on('exit', (exitCode) => resolve(exitCode ?? 1))
85
+ })
86
+
87
+ if (code === 0) {
88
+ log('Every HTML file is valid.', 'success')
89
+ } else {
90
+ log('The Nu Html Checker found errors.', 'error')
91
+ }
92
+
93
+ return code
94
+ }
@@ -226,7 +226,7 @@ if (codeToDisplay && detectedLang) {
226
226
  ) : (
227
227
  <div class:list={['cxd-code', { 'line-numbers': lineNumbers }]}>
228
228
  {/* prettier-ignore */}
229
- <pre class={className}><slot /></pre>
229
+ <pre class={className} tabindex="0"><slot /></pre>
230
230
  </div>
231
231
  )}
232
232
  </>
@@ -279,7 +279,7 @@ if (codeToDisplay && detectedLang) {
279
279
  ) : (
280
280
  <div class:list={['cxd-code', { 'line-numbers': lineNumbers }]}>
281
281
  {/* prettier-ignore */}
282
- <pre class={className}><slot /></pre>
282
+ <pre class={className} tabindex="0"><slot /></pre>
283
283
  </div>
284
284
  )}
285
285
  </div>
@@ -12,6 +12,7 @@ import Head from './head/Head.astro'
12
12
  import Header from './header/Header.astro'
13
13
  import Scripts from './footer/Scripts.astro'
14
14
  import Footer from './footer/Footer.astro'
15
+ import Consent from './footer/Consent.astro'
15
16
  import { stripMarkdown } from '../libs/utils'
16
17
 
17
18
  // The following props can be directly passed to the base layout component from any page or layout extending it,
@@ -25,7 +26,8 @@ type Props = {
25
26
  overrides?: {
26
27
  body?: LayoutOverridesHTMLAttributes<'body'>
27
28
  // Note that main can also be overridden by the Astro slot named "main" and that the slot will take precedence over
28
- // any override.
29
+ // any override. `<main>` has `id="content"`, the target of the skip link in the header. A page that fills the "main"
30
+ // slot gives that id to an element of its own.
29
31
  main?: LayoutOverridesHTMLAttributes<'main'>
30
32
  }
31
33
  // An override for the page title. If not defined, the title will either be the content of the `title` frontmatter
@@ -61,7 +63,7 @@ const thumbnail = frontmatter?.thumbnail
61
63
  : 'static/images/social-image.png'
62
64
 
63
65
  const bodyProps = overrides?.body ?? {}
64
- const mainProps = overrides?.main ?? {}
66
+ const mainProps = { id: 'content', ...overrides?.main }
65
67
  ---
66
68
 
67
69
  <!doctype html>
@@ -90,6 +92,7 @@ const mainProps = overrides?.main ?? {}
90
92
  }
91
93
 
92
94
  <Footer />
95
+ <Consent />
93
96
 
94
97
  <Scripts layout={layout}>
95
98
  <slot name="scripts" slot="scripts" />
@@ -0,0 +1,135 @@
1
+ ---
2
+ /**
3
+ * Consent
4
+ *
5
+ * Asks before Google Analytics loads. Rendered in production builds, when
6
+ * `analytics.googleId` is set in `config.yml`. Nothing is loaded and no cookie is set until
7
+ * the visitor accepts. The choice is kept in `localStorage`, so every Chassis site served
8
+ * from the same host shares it. The "Cookie settings" button of the footer opens the
9
+ * banner again.
10
+ */
11
+
12
+ import { getConfig } from '../../site'
13
+
14
+ const isProd = import.meta.env.PROD
15
+ const { googleId } = getConfig().analytics
16
+ ---
17
+
18
+ {
19
+ isProd && googleId && (
20
+ <section class="cxd-consent" aria-label="Cookie consent" data-google-id={googleId} hidden>
21
+ <div class="container 2xl">
22
+ <div class="notification default shadow-lg d-flex flex-wrap align-items-center gap-md p-md">
23
+ <p class="mb-0 flex-grow-1">
24
+ We would like to count visits with Google Analytics, which sets cookies. Nothing is sent
25
+ to Google unless you accept. <a href="/privacy/">Privacy notice</a>
26
+ </p>
27
+ <div class="d-flex gap-sm">
28
+ <button type="button" class="button primary" data-consent="granted">
29
+ Accept
30
+ </button>
31
+ <button type="button" class="button default" data-consent="denied">
32
+ Decline
33
+ </button>
34
+ </div>
35
+ </div>
36
+ </div>
37
+ </section>
38
+ )
39
+ }
40
+
41
+ <script>
42
+ type AnalyticsWindow = Window &
43
+ Record<`ga-disable-${string}`, boolean> & {
44
+ dataLayer?: unknown[]
45
+ gtag?: (...args: unknown[]) => void
46
+ }
47
+
48
+ const KEY = 'chassis-analytics-consent'
49
+ const analytics = window as unknown as AnalyticsWindow
50
+ const banner = document.querySelector<HTMLElement>('.cxd-consent')
51
+ const googleId = banner?.dataset.googleId
52
+
53
+ function readChoice(): string | null {
54
+ try {
55
+ return localStorage.getItem(KEY)
56
+ } catch {
57
+ return null
58
+ }
59
+ }
60
+
61
+ function saveChoice(choice: string) {
62
+ try {
63
+ localStorage.setItem(KEY, choice)
64
+ } catch {
65
+ // Without storage the banner shows again on the next page
66
+ }
67
+ }
68
+
69
+ function loadAnalytics(id: string) {
70
+ analytics[`ga-disable-${id}`] = false
71
+ if (analytics.gtag) return
72
+
73
+ analytics.dataLayer ??= []
74
+ analytics.gtag = function () {
75
+ // gtag.js reads the `arguments` object, not an array
76
+ // eslint-disable-next-line prefer-rest-params
77
+ analytics.dataLayer!.push(arguments)
78
+ }
79
+ analytics.gtag('js', new Date())
80
+ analytics.gtag('config', id)
81
+
82
+ const script = document.createElement('script')
83
+ script.async = true
84
+ script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`
85
+ document.head.append(script)
86
+ }
87
+
88
+ // Stops Google Analytics on this page and deletes its cookies, which it sets on the
89
+ // registrable domain, such as `.chassis-ui.com`
90
+ function stopAnalytics(id: string) {
91
+ analytics[`ga-disable-${id}`] = true
92
+
93
+ const labels = location.hostname.split('.')
94
+ const domains = labels.slice(0, -1).map((_, index) => labels.slice(index).join('.'))
95
+
96
+ for (const cookie of document.cookie.split(';')) {
97
+ const name = cookie.split('=')[0].trim()
98
+
99
+ if (!name.startsWith('_ga')) continue
100
+
101
+ for (const domain of ['', ...domains.map((d) => `; domain=.${d}`)]) {
102
+ document.cookie = `${name}=; max-age=0; path=/${domain}`
103
+ }
104
+ }
105
+ }
106
+
107
+ if (banner && googleId) {
108
+ const choice = readChoice()
109
+
110
+ if (choice === 'granted') loadAnalytics(googleId)
111
+ else if (choice !== 'denied') banner.hidden = false
112
+
113
+ banner.addEventListener('click', (event) => {
114
+ const button = (event.target as Element).closest<HTMLElement>('[data-consent]')
115
+
116
+ if (!button) return
117
+
118
+ const decision = button.dataset.consent!
119
+
120
+ saveChoice(decision)
121
+ banner.hidden = true
122
+
123
+ if (decision === 'granted') loadAnalytics(googleId)
124
+ else stopAnalytics(googleId)
125
+ })
126
+
127
+ for (const opener of document.querySelectorAll<HTMLElement>('[data-consent-open]')) {
128
+ opener.hidden = false
129
+ opener.addEventListener('click', () => {
130
+ banner.hidden = false
131
+ banner.querySelector<HTMLElement>('[data-consent]')?.focus()
132
+ })
133
+ }
134
+ }
135
+ </script>
@@ -17,18 +17,25 @@
17
17
  >
18
18
  <img src="/static/images/site-logo.svg" alt="Chassis UI" height="32" />
19
19
  </a>
20
- <p>
20
+ <p class="mb-xs">
21
21
  This project is licensed under the MIT License, which allows for free use, modification,
22
22
  and distribution of the software.
23
23
  </p>
24
+ <p>
25
+ <a href="/privacy/" class="fg-main">Privacy</a>
26
+ <button hidden data-consent-open type="button" class="cxd-footer-link fg-main ms-md"
27
+ >Cookie settings</button
28
+ >
29
+ </p>
24
30
  </div>
25
31
  <div class="sm:col-4 lg:col-2 lg:offset-1 mb-md">
26
32
  <p class="h6 mb-xs">Docs</p>
27
33
  <ul class="nav">
28
34
  <li><a href="/tokens">Design Tokens</a></li>
29
- <li><a href="/assets">Asset Distributor</a></li>
30
35
  <li><a href="/figma">Figma Library</a></li>
31
36
  <li><a href="/css">CSS Framework</a></li>
37
+ <li><a href="/react">React Components</a></li>
38
+ <li><a href="/assets">Asset Distributor</a></li>
32
39
  <li><a href="/icons">Icon Generator</a></li>
33
40
  </ul>
34
41
  </div>
@@ -36,9 +43,10 @@
36
43
  <p class="h6 mb-xs">Github</p>
37
44
  <ul class="nav">
38
45
  <li><a href="https://github.com/chassis-ui/tokens">Chassis Tokens</a></li>
39
- <li><a href="https://github.com/chassis-ui/assets">Chassis Assets</a></li>
40
46
  <li><a href="https://github.com/chassis-ui/figma">Chassis Figma</a></li>
41
47
  <li><a href="https://github.com/chassis-ui/css">Chassis CSS</a></li>
48
+ <li><a href="https://github.com/chassis-ui/react">Chassis React</a></li>
49
+ <li><a href="https://github.com/chassis-ui/assets">Chassis Assets</a></li>
42
50
  <li><a href="https://github.com/chassis-ui/icons">Chassis Icons</a></li>
43
51
  </ul>
44
52
  </div>
@@ -10,6 +10,5 @@
10
10
  <link rel="icon" href={'/static/images/favicon-32x32.png'} sizes="32x32" type="image/png" />
11
11
  <link rel="icon" href={'/static/images/favicon-16x16.png'} sizes="16x16" type="image/png" />
12
12
  <link rel="manifest" href={'/static/images/manifest.json'} />
13
- <link rel="mask-icon" href={'/static/images/safari-pinned-tab.svg'} color="#712cf9" />
14
13
  <link rel="icon" href={'/static/images/favicon.png'} />
15
14
  <meta name="theme-color" content="#00A4CC" />
@@ -10,7 +10,6 @@ import type { Layout } from '../../libs/layout'
10
10
  import Stylesheet from './Stylesheet.astro'
11
11
  import Favicons from './Favicons.astro'
12
12
  import Social from './Social.astro'
13
- import Analytics from './Analytics.astro'
14
13
  import 'virtual:chassis-docs/styles'
15
14
 
16
15
  interface Props {
@@ -41,6 +40,14 @@ const pageTitle = isHome
41
40
 
42
41
  <link rel="preconnect" href="https://fonts.googleapis.com" />
43
42
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
43
+ <!--
44
+ A link, not an `@import` in the styles, so that the browser finds it with the page and
45
+ not after the stylesheet that imports it
46
+ -->
47
+ <link
48
+ rel="stylesheet"
49
+ href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&family=Inter:wght@100..900&family=Archivo+Narrow:ital,wght@0,400..700;1,400..700&display=swap"
50
+ />
44
51
 
45
52
  <meta name="description" content={description} />
46
53
 
@@ -59,4 +66,3 @@ const pageTitle = isHome
59
66
  <slot name="styles" />
60
67
  <Favicons />
61
68
  <Social description={description} layout={layout} thumbnail={thumbnail} title={title} />
62
- <Analytics />
@@ -94,6 +94,7 @@ const isActive = (path: string) => {
94
94
  <NavLink active={isActive('/tokens')} href="/tokens">Tokens</NavLink>
95
95
  <NavLink active={isActive('/figma')} href="/figma">Figma</NavLink>
96
96
  <NavLink active={isActive('/css')} href="/css">CSS</NavLink>
97
+ <NavLink active={isActive('/react')} href="/react">React</NavLink>
97
98
  <NavLink active={isActive('/assets')} href="/assets">Assets</NavLink>
98
99
  <NavLink active={isActive('/icons')} href="/icons">Icons</NavLink>
99
100
  <li class="nav-item col-12 lg:col-auto lg:d-flex">
@@ -1,5 +1,5 @@
1
1
  import { codeToHtml, type ShikiTransformer } from 'shiki'
2
- import { DOCS_SHIKI_THEMES } from './markdown'
2
+ import { DOCS_SHIKI_THEMES, focusableTransformer } from './markdown'
3
3
 
4
4
  /**
5
5
  * Shiki transformer that renames the `shiki` CSS class on `<pre>` elements to
@@ -44,7 +44,7 @@ export async function highlightCode(
44
44
  lang,
45
45
  themes: DOCS_SHIKI_THEMES,
46
46
  defaultColor: false,
47
- transformers: [...extraTransformers, classTransformer]
47
+ transformers: [...extraTransformers, classTransformer, focusableTransformer]
48
48
  })
49
49
 
50
50
  return { html: replaceShikiClasses(highlighted) }
@@ -35,6 +35,17 @@ const addLanguageAttributeTransformer: ShikiTransformer = {
35
35
  }
36
36
  }
37
37
 
38
+ /**
39
+ * Shiki transformer that puts `<pre>` in the tab order. A code block that is wider than the
40
+ * page scrolls sideways, and a keyboard can scroll it only when it can take focus.
41
+ */
42
+ export const focusableTransformer: ShikiTransformer = {
43
+ name: 'focusable-pre',
44
+ pre(node) {
45
+ node.properties['tabindex'] = '0'
46
+ }
47
+ }
48
+
38
49
  /**
39
50
  * Options accepted by `getDocsMarkdownConfig`.
40
51
  */
@@ -121,7 +132,8 @@ export function getDocsMarkdownConfig(options: DocsMarkdownConfigOptions): Markd
121
132
  transformers: [
122
133
  transformerNotationDiff(),
123
134
  transformerNotationHighlight(),
124
- addLanguageAttributeTransformer
135
+ addLanguageAttributeTransformer,
136
+ focusableTransformer
125
137
  ]
126
138
  }
127
139
  }
@@ -38,7 +38,7 @@ const zHeadingRange = z.object({
38
38
  * ```
39
39
  */
40
40
  export const configSchema = z.strictObject({
41
- /** Google Analytics is loaded in production builds when `googleId` is set. */
41
+ /** Google Analytics is offered in production builds when `googleId` is set, and loads after the visitor accepts. */
42
42
  analytics: z.strictObject({ googleId: z.string().optional() }).default({}),
43
43
  /** Heading levels that get an anchor link. */
44
44
  anchors: zHeadingRange.default({ min: 2, max: 5 }),
@@ -0,0 +1,14 @@
1
+ //
2
+ // Consent
3
+ //
4
+
5
+ @layer custom {
6
+ // Above the sticky header, below dialogs and drawers
7
+ .cxd-consent {
8
+ position: fixed;
9
+ inset-block-end: 0;
10
+ inset-inline: 0;
11
+ z-index: 1030;
12
+ padding-block: var(--cx-space-md);
13
+ }
14
+ }
@@ -14,7 +14,8 @@
14
14
  }
15
15
  }
16
16
 
17
- a {
17
+ a,
18
+ .cxd-footer-link {
18
19
  color: var(--cx-fg-color);
19
20
  text-decoration: none;
20
21
 
@@ -24,5 +25,14 @@
24
25
  text-decoration: underline;
25
26
  }
26
27
  }
28
+
29
+ // A button that looks like the links next to it
30
+ .cxd-footer-link {
31
+ padding: 0;
32
+ font: inherit;
33
+ cursor: pointer;
34
+ background: none;
35
+ border: 0;
36
+ }
27
37
  }
28
38
  }
@@ -1,6 +1,5 @@
1
1
  @use "@chassis-ui/css/scss/mixins" as *;
2
2
 
3
- @forward "./fonts";
4
3
  @forward "./navbar";
5
4
  @forward "./content";
6
5
  @forward "./skippy";
@@ -8,6 +7,7 @@
8
7
  @forward "./layout";
9
8
  @forward "./toc";
10
9
  @forward "./footer";
10
+ @forward "./consent";
11
11
  @forward "./snippets";
12
12
  @forward "./callouts";
13
13
  @forward "./placeholder-img";
@@ -1,31 +0,0 @@
1
- ---
2
- /**
3
- * Analytics
4
- *
5
- * Google Analytics script tags. Rendered in production builds, when `analytics.googleId` is
6
- * set in `config.yml`.
7
- */
8
-
9
- import { getConfig } from '../../site'
10
-
11
- const isProd = import.meta.env.PROD
12
- const { googleId } = getConfig().analytics
13
- ---
14
-
15
- {
16
- isProd && googleId && (
17
- <>
18
- <script is:inline async src={`https://www.googletagmanager.com/gtag/js?id=${googleId}`} />
19
- <script
20
- is:inline
21
- set:html={`
22
- window.dataLayer = window.dataLayer || [];
23
- function gtag(){dataLayer.push(arguments);}
24
- gtag('js', new Date());
25
-
26
- gtag('config', '${googleId}');
27
- `}
28
- />
29
- </>
30
- )
31
- }
@@ -1 +0,0 @@
1
- @import "https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&family=Inter:wght@100..900&family=Archivo+Narrow:ital,wght@0,400..700;1,400..700&display=swap";