@chassis-ui/docs 0.6.0 → 0.6.2

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,22 @@
1
1
  # @chassis-ui/docs
2
2
 
3
+ ## 0.6.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Add the `staticPath` key of `config.yml`: the URL path that the pages load the static files from, `/static` by default. A site that is served under a path of another host, such as `chassis-ui.com/css`, sets it to `/css/static` and rewrites that path to `/static`, so that its files no longer depend on the `Referer` header to be found. The layouts, the `Icon` shortcode and the scripts of the package use it, and `getStaticPath()` of `@chassis-ui/docs/site` returns a URL under it for the site's own files. See [A site under a prefix](https://github.com/chassis-ui/website/tree/main/packages/docs#a-site-under-a-prefix) in the README.
8
+
9
+ ## 0.6.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 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.
14
+ - 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.
15
+ - 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.
16
+ - 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.
17
+ - 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.
18
+ - 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.
19
+
3
20
  ## 0.6.0
4
21
 
5
22
  ### 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
@@ -104,23 +106,24 @@ repo: 'https://github.com/chassis-ui/css'
104
106
  currentVersion: '0.5.2'
105
107
  ```
106
108
 
107
- | Key | Required | Meaning |
108
- | --------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------- |
109
- | `title`, `subtitle`, `description`, `authors` | yes | Used in the page title and the meta tags |
110
- | `baseURL` | yes | Canonical URL of the site |
111
- | `docsPath` | yes | URL path of the docs pages. Starts with `/` |
112
- | `repo` | yes | URL of the repository |
113
- | `currentVersion` | yes | Version that the site documents. Links to source files use the tag `v<currentVersion>` |
114
- | `githubOrg` | no | GitHub organisation name, not a URL. The header links to it |
115
- | `figmaHandle` | no | Figma Community handle. The header links to it |
116
- | `xUsername` | no | X handle. Used in the social meta tags |
117
- | `analytics.googleId` | no | Google Analytics ID. Loaded in production builds only |
118
- | `anchors.min`, `anchors.max` | no | Heading levels that get an anchor link. Default 2 to 5 |
119
- | `toc.min`, `toc.max` | no | Heading levels in the table of contents. Default 2 to 6 |
120
- | `sourceDir` | no | Directory that `file` props are relative to, from the site's root, e.g. `"../css"`. Default `"."` |
121
- | `sourcePath` | no | The same directory from the root of the repository, e.g. `"packages/css"` |
122
- | `sitePath` | no | The site's root from the root of the repository, e.g. `"packages/site"` |
123
- | `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 |
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` |
124
127
 
125
128
  `file` props are those of `<ScssDocs>`, `<ScssDocsSimple>`, `<JsDocs>` and `<Code filePath>`.
126
129
 
@@ -179,7 +182,7 @@ export const collections = {
179
182
 
180
183
  ### 4. Provide the static files
181
184
 
182
- The layouts link to files under `/static/`, which the site puts into its `public` directory:
185
+ The layouts link to files under `/static/`, which the site puts into `static/` of its `public` directory:
183
186
 
184
187
  | URL | From |
185
188
  | -------------------------------------------------------------------- | ----------------------------------------- |
@@ -187,10 +190,33 @@ The layouts link to files under `/static/`, which the site puts into its `public
187
190
  | `/static/icons/chassis-icons.svg`, `/static/icons/chassis-icons.css` | the `icons` folder of `@chassis-ui/icons` |
188
191
  | `/static/images/*`: logo, favicons, social image | the docs build of `chassis-assets` |
189
192
 
193
+ `chassis-docs vendor` builds the docs build of `chassis-assets` in the `vendor/assets` submodule. See [Commands](#commands).
194
+
190
195
  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
196
 
192
197
  A site that type-checks with `astro check` declares the `@chassis-ui/css` module, which has no types: `declare module '@chassis-ui/css'`.
193
198
 
199
+ #### A site under a prefix
200
+
201
+ A site that is served under a path of another host, such as `chassis-ui.com/css`, cannot use `/static/` there: that path belongs to the host's own site. It loads its static files under a path of its own instead:
202
+
203
+ ```yaml
204
+ # config.yml
205
+ staticPath: '/css/static'
206
+ ```
207
+
208
+ The layouts, the `Icon` shortcode and the scripts of the package then link `/css/static/css/chassis.min.css` and so on. The files do not move: they stay in `static/` of the `public` directory. Two rewrites make the path answer:
209
+
210
+ - on the host, from `/css/static/*` to `/static/*` of the site's deployment. chassis-ui.com has one for each Chassis site.
211
+ - in the site's own `vercel.json`, from `/css/static/(.*)` to `/static/$1`, so that the deployment also works when it is opened directly.
212
+
213
+ Two kinds of URL are the site's own to move:
214
+
215
+ - URLs that the site writes itself, in its pages and components. `getStaticPath('images/hero.png')` of `@chassis-ui/docs/site` returns the URL under `staticPath`.
216
+ - The files that Astro builds, when the site puts them under `static/` with `build.assets`. Astro's `build.assetsPrefix` adds the prefix to them, `'/css'` here.
217
+
218
+ A social image named in the frontmatter as `static/images/…` is read from the `public` directory and linked under `staticPath`.
219
+
194
220
  ### 5. Use the layouts
195
221
 
196
222
  ```astro
@@ -229,6 +255,7 @@ chassisDocs({
229
255
  | `getCallout(name)` | An entry of the `callouts` collection |
230
256
  | `getSiteRoot()`, `getSiteFsPath(file)` | Absolute path of the site's root, or of a file in it |
231
257
  | `getPublicFsPath(file)` | Absolute path of a file in the `public` directory |
258
+ | `getStaticPath(file)` | URL path of a static file, under `staticPath` of the config |
232
259
  | `getSourceFsPath(file)` | Absolute path of a file that a `file` prop names |
233
260
  | `getSourceUrl(file)` | URL of a source file on GitHub, at the tag of the current version |
234
261
  | `getSiteFileUrl(filePath)` | URL of a file of the site on GitHub, on the site's branch |
@@ -238,6 +265,88 @@ chassisDocs({
238
265
 
239
266
  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
267
 
268
+ ## Commands
269
+
270
+ The package installs a `chassis-docs` command with the build steps that every Chassis site shares. Call it from the scripts of `package.json`:
271
+
272
+ ```json
273
+ {
274
+ "scripts": {
275
+ "vendor": "chassis-docs vendor",
276
+ "sync-submodules": "chassis-docs sync-submodules",
277
+ "lint:html": "chassis-docs html-validate _site",
278
+ "lint:vnu": "chassis-docs vnu _site"
279
+ }
280
+ }
281
+ ```
282
+
283
+ | Command | What it does |
284
+ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
285
+ | `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` |
286
+ | `chassis-docs sync-submodules` | Moves `vendor/assets` to the latest commit of the `app/docs` branch and builds it. Commit the new pointer afterwards |
287
+ | `chassis-docs html-validate [paths]` | Validates the HTML files in `paths` with [html-validate](https://html-validate.org) |
288
+ | `chassis-docs vnu [paths]` | Validates `paths` with the [Nu Html Checker](https://validator.github.io/validator/). Skipped, with a warning, when Java is missing |
289
+ | `chassis-docs help` | Lists the commands and their options |
290
+
291
+ `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.
292
+
293
+ Each command exits with 1 when it fails, so a script or CI job stops there.
294
+
295
+ ### Validating HTML
296
+
297
+ `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.
298
+
299
+ What a site can change:
300
+
301
+ | To | Use | Validator | Adds to the defaults or replaces them |
302
+ | ----------------------------------- | ------------------------------------- | ------------- | ------------------------------------- |
303
+ | Skip a file or folder | `--ignore <path>`, repeatable | both | adds |
304
+ | Check `static/icons` too | `--no-default-ignore` | both | removes the default |
305
+ | Turn a rule off or change it | `rules` in the file of `--config` | html-validate | overrides the rule |
306
+ | Use more presets or plugins | `extends` in the file of `--config` | html-validate | adds |
307
+ | Allow custom elements or attributes | `elements` in the file of `--config` | html-validate | replaces |
308
+ | Leave out a message | `--filter <regex>` or `--filter-file` | vnu | adds |
309
+
310
+ The defaults:
311
+
312
+ - Both skip `static/icons` in each path, where a site copies `@chassis-ui/icons`, whose preview page is not the site's.
313
+ - `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.
314
+ - `vnu` fails on warnings as well as errors, and leaves out "Trailing slash on void elements", which Astro writes.
315
+
316
+ A site with its own exceptions keeps them in files next to its `package.json`, and names them in its scripts:
317
+
318
+ ```json
319
+ {
320
+ "scripts": {
321
+ "lint:html": "chassis-docs html-validate _site --ignore _site/examples --config html-validate.json",
322
+ "lint:vnu": "chassis-docs vnu _site --ignore _site/examples --filter-file vnu-filters.txt"
323
+ }
324
+ }
325
+ ```
326
+
327
+ `html-validate.json`:
328
+
329
+ ```json
330
+ {
331
+ "rules": {
332
+ "prefer-native-element": ["error", { "exclude": ["region"] }],
333
+ "prefer-button": "off"
334
+ }
335
+ }
336
+ ```
337
+
338
+ `vnu-filters.txt`, one regular expression per line, where a line that starts with `#` is a comment:
339
+
340
+ ```text
341
+ # The docs show autocomplete on inputs of every type
342
+ Attribute “autocomplete” is only allowed when the input type is.*
343
+ Bad value “heading” for attribute “role” on element “summary”.
344
+ ```
345
+
346
+ A filter is matched against the message as the output shows it, curly quotes included: `“role”`. A line copied from the output works.
347
+
348
+ 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] -->`.
349
+
241
350
  ## Exports
242
351
 
243
352
  These import paths are the public API.
@@ -257,7 +366,26 @@ These import paths are the public API.
257
366
 
258
367
  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.
259
368
 
260
- 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.
369
+ The integration provides five modules: `virtual:chassis-docs/config`, `virtual:chassis-docs/sidebar`, `virtual:chassis-docs/paths`, `virtual:chassis-docs/static` and `virtual:chassis-docs/styles`. Use the functions of `@chassis-ui/docs/site` instead of importing them.
370
+
371
+ ## Compatibility
372
+
373
+ "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.
374
+
375
+ | `@chassis-ui/docs` | Requires Astro | Requires `@chassis-ui/css` | Requires Node | Built with |
376
+ | ------------------ | -------------------------- | -------------------------- | ------------- | ------------------------------------ |
377
+ | 0.6.2 | `^7.0.0` | `^0.5.0-0` | `>=22.12.0` | Astro 7.3.5, css 0.5.2, tokens 0.6.0 |
378
+ | 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 |
379
+ | 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 |
380
+ | 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 |
381
+ | 0.5.0 | `^7.0.0` | `^0.5.0-0` | not declared | not recorded |
382
+ | 0.4.0 | `^7.0.0` | `^0.4.0` | not declared | not recorded |
383
+ | 0.3.0 to 0.3.10 | `^7.0.0` | `>=0.3.1` to `>=0.3.4` | not declared | not recorded |
384
+ | 0.1.0 to 0.2.0 | `^5.0.0`, 0.1.0–0.1.1 `^4` | not declared | not declared | not recorded |
385
+
386
+ `@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.
387
+
388
+ 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.
261
389
 
262
390
  ## Versioning
263
391
 
@@ -270,10 +398,11 @@ A change is breaking when it changes something that this README documents:
270
398
 
271
399
  - the import paths listed under [Exports](#exports), and what they export
272
400
  - the options of the integration
401
+ - the commands of `chassis-docs`, their options and their defaults
273
402
  - the keys of `config.yml`, and the schemas of the sidebar and the content collections
274
403
  - the props and slots of the layouts, components and shortcodes
275
404
  - the `[[config:]]` and `[[docsref:]]` syntax
276
- - the URLs under `/static/` that the layouts link to
405
+ - the URLs under `staticPath`, `/static/` by default, that the layouts link to
277
406
  - the supported versions of Node, Astro and `@chassis-ui/css`
278
407
 
279
408
  Everything else may change in a patch release: the rendered HTML, class names, the content of the styles, and every file that is not reachable through a documented import path.
@@ -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.2",
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
+ }