@chassis-ui/docs 0.5.1 → 0.6.0

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.
@@ -5,9 +5,9 @@
5
5
  * Main layout wrapper for all pages with configurable HTML attributes and layout modes.
6
6
  */
7
7
 
8
- import type { CollectionEntry } from 'astro:content'
9
- import { getConfig } from '@libs/config'
10
8
  import type { Layout, LayoutOverridesHTMLAttributes } from '../libs/layout'
9
+ import type { DocsFrontmatter } from '../libs/schema'
10
+ import { getConfig } from '../site'
11
11
  import Head from './head/Head.astro'
12
12
  import Header from './header/Header.astro'
13
13
  import Scripts from './footer/Scripts.astro'
@@ -42,11 +42,11 @@ type Props = {
42
42
  // The following props are automatically set by Astro (if defined) based on the markdown frontmatter when rendering a
43
43
  // markdown page. They can be accessed through the `Astro.props.frontmatter` object but note that they won't be set when
44
44
  // not rendering a markdown page.
45
- type MarkdownProps = { frontmatter?: Partial<CollectionEntry<'docs'>['data']> }
45
+ type MarkdownProps = { frontmatter?: DocsFrontmatter }
46
46
 
47
- // This type is used to define the shape of the `extra_js` frontmatter property which allows adding extra JavaScript
47
+ // This type is used to define the shape of the `extraJs` frontmatter property which allows adding extra JavaScript
48
48
  // files to a page by passing an array of objects containing the `src` and `async` properties.
49
- type ExtraJS = NonNullable<CollectionEntry<'docs'>['data']['extra_js']>[number]
49
+ type ExtraJS = NonNullable<DocsFrontmatter['extraJs']>[number]
50
50
 
51
51
  const { frontmatter, layout, overrides, robots } = Astro.props
52
52
 
@@ -96,10 +96,8 @@ const mainProps = overrides?.main ?? {}
96
96
  </Scripts>
97
97
 
98
98
  {
99
- frontmatter?.extra_js &&
100
- frontmatter.extra_js.map((js: ExtraJS) => (
101
- <script is:inline async={js.async} src={js.src} />
102
- ))
99
+ frontmatter?.extraJs &&
100
+ frontmatter.extraJs.map((js: ExtraJS) => <script is:inline async={js.async} src={js.src} />)
103
101
  }
104
102
 
105
103
  {
@@ -6,13 +6,10 @@
6
6
  */
7
7
 
8
8
  import type { MarkdownHeading } from 'astro'
9
- import type { CollectionEntry } from 'astro:content'
10
- import { getConfig } from '@libs/config'
11
- import { docsPages } from '@libs/content'
12
- import { getChassisDocsPath } from '@libs/path'
13
9
  import type { LayoutOverridesHTMLAttributes } from '../libs/layout'
14
- import { getSiteFileUrl } from '../libs/source'
10
+ import type { DocsFrontmatter } from '../libs/schema'
15
11
  import { getSlug, processMarkdownToHtml } from '../libs/utils'
12
+ import { getConfig, getDocsPages, getDocsPath, getSiteFileUrl } from '../site'
16
13
  import BaseLayout from './BaseLayout.astro'
17
14
  import DocsSidebar from '../components/DocsSidebar.astro'
18
15
  import TableOfContents from '../components/TableOfContents.astro'
@@ -20,11 +17,11 @@ import Icon from '../components/shortcodes/Icon.astro'
20
17
 
21
18
  interface Props {
22
19
  /** Frontmatter data from the docs collection */
23
- frontmatter: CollectionEntry<'docs'>['data']
20
+ frontmatter: DocsFrontmatter
24
21
  /** Markdown headings for table of contents */
25
22
  headings?: MarkdownHeading[]
26
23
  /** Document ID from the docs collection */
27
- id: CollectionEntry<'docs'>['id']
24
+ id: string
28
25
  /**
29
26
  * Path of the page's source file from the site's root. Looked up from the docs collection
30
27
  * by `id` when omitted.
@@ -36,7 +33,8 @@ const { frontmatter, headings, id } = Astro.props
36
33
 
37
34
  // "View on GitHub" links to the page's own source file. The old link, which assumed the
38
35
  // file sat at `<id>.mdx` in the repository root, is kept only when no file path is known.
39
- const filePath = Astro.props.filePath ?? docsPages.find((page) => page.id === id)?.filePath
36
+ const filePath =
37
+ Astro.props.filePath ?? (await getDocsPages()).find((page) => page.id === id)?.filePath
40
38
  const githubUrl = filePath ? getSiteFileUrl(filePath) : `${getConfig().repo}/blob/${id}.mdx`
41
39
 
42
40
  type DocSection = NonNullable<typeof frontmatter.sections>[number]
@@ -86,11 +84,11 @@ if (frontmatter.toc) {
86
84
  <div class="md:d-flex md:flex-row-reverse justify-content-between align-items-start">
87
85
  <div class="mb-md md:mb-0 d-flex text-nowrap">
88
86
  {
89
- // This is needed because we want to show the badge if show_badge isn't present or is set to false
87
+ // This is needed because we want to show the badge if showBadge isn't present or is set to false
90
88
  frontmatter.added &&
91
- ((frontmatter.added.show_badge !== undefined &&
92
- frontmatter.added.show_badge === true) ||
93
- frontmatter.added.show_badge === undefined) && (
89
+ ((frontmatter.added.showBadge !== undefined &&
90
+ frontmatter.added.showBadge === true) ||
91
+ frontmatter.added.showBadge === undefined) && (
94
92
  <span class="badge success smooth rounded-md me-xs">
95
93
  Added in v{frontmatter.added.version}
96
94
  </span>
@@ -153,7 +151,7 @@ if (frontmatter.toc) {
153
151
  <div class="md:col-6">
154
152
  <a
155
153
  class="d-block text-decoration-none"
156
- href={getChassisDocsPath(
154
+ href={getDocsPath(
157
155
  `${parentDirectory ? parentDirectory + '/' : ''}${section.slug ? section.slug : getSlug(section.title)}/`
158
156
  )}
159
157
  >
@@ -5,7 +5,7 @@
5
5
  * Minimal layout for redirect pages with meta refresh.
6
6
  */
7
7
 
8
- import { getConfig } from '@libs/config'
8
+ import { getConfig } from '../site'
9
9
 
10
10
  interface Props {
11
11
  /** Destination path for the redirect */
@@ -2,16 +2,18 @@
2
2
  /**
3
3
  * Analytics
4
4
  *
5
- * Analytics script tags for Fathom and Google Analytics.
5
+ * Google Analytics script tags. Rendered in production builds, when `analytics.googleId` is
6
+ * set in `config.yml`.
6
7
  */
7
8
 
8
- import { getConfig } from '@libs/config'
9
+ import { getConfig } from '../../site'
10
+
9
11
  const isProd = import.meta.env.PROD
10
- const googleId = getConfig().analytics.google_id
12
+ const { googleId } = getConfig().analytics
11
13
  ---
12
14
 
13
15
  {
14
- isProd && (
16
+ isProd && googleId && (
15
17
  <>
16
18
  <script is:inline async src={`https://www.googletagmanager.com/gtag/js?id=${googleId}`} />
17
19
  <script
@@ -5,13 +5,13 @@
5
5
  * HTML head section with meta tags, stylesheets, and SEO configuration.
6
6
  */
7
7
 
8
- import { getConfig } from '@libs/config'
8
+ import { getConfig } from '../../site'
9
9
  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
13
  import Analytics from './Analytics.astro'
14
- import '@scss/docs.scss'
14
+ import 'virtual:chassis-docs/styles'
15
15
 
16
16
  interface Props {
17
17
  /** Page description for meta tags */
@@ -5,11 +5,9 @@
5
5
  * Social media meta tags (Open Graph, Twitter Cards).
6
6
  */
7
7
 
8
- import path from 'node:path'
9
- import { getConfig } from '@libs/config'
10
- import { getDocsPublicFsPath } from '@libs/path'
11
8
  import { getRemoteImageSize, getStaticImageSize } from '../../libs/image'
12
9
  import type { Layout } from '../../libs/layout'
10
+ import { getConfig, getPublicFsPath } from '../../site'
13
11
 
14
12
  interface Props {
15
13
  /** Page description */
@@ -28,12 +26,13 @@ const socialImageUrl = new URL(thumbnail, Astro.site)
28
26
  const isRemoteThumbnail = /^https?:\/\//i.test(thumbnail)
29
27
  const socialImageSize = isRemoteThumbnail
30
28
  ? await getRemoteImageSize(thumbnail)
31
- : await getStaticImageSize(path.join(getDocsPublicFsPath(), thumbnail))
29
+ : await getStaticImageSize(getPublicFsPath(thumbnail))
30
+ const { xUsername } = getConfig()
32
31
  ---
33
32
 
34
33
  <meta name="twitter:card" content="summary_lg_image" />
35
- <meta name="twitter:site" content=`@${getConfig().x_username}` />
36
- <meta name="twitter:creator" content=`@${getConfig().x_username}` />
34
+ {xUsername && <meta name="twitter:site" content={`@${xUsername}`} />}
35
+ {xUsername && <meta name="twitter:creator" content={`@${xUsername}`} />}
37
36
  <meta name="twitter:title" content={title} />
38
37
  <meta name="twitter:description" content={description} />
39
38
  <meta name="twitter:image" content={socialImageUrl} />
@@ -5,8 +5,8 @@
5
5
  * Main site navigation bar with menu items and theme toggler.
6
6
  */
7
7
 
8
- import { getConfig } from '@libs/config'
9
8
  import type { Layout } from '../../libs/layout'
9
+ import { getConfig } from '../../site'
10
10
  import NavLink from '../../components/NavLink.astro'
11
11
  import ThemeToggler from '../../components/ThemeToggler.astro'
12
12
  import SearchTrigger from './SearchTrigger.astro'
@@ -19,6 +19,7 @@ interface Props {
19
19
  }
20
20
 
21
21
  const { layout } = Astro.props
22
+ const { figmaHandle, githubOrg } = getConfig()
22
23
  const { pathname } = Astro.url
23
24
  const isActive = (path: string) => {
24
25
  if (path === '/') {
@@ -106,24 +107,32 @@ const isActive = (path: string) => {
106
107
  <hr class="lg:d-none m-0" />
107
108
 
108
109
  <ul class="navbar-nav flex-row flex-wrap lg:ms-auto align-items-center">
109
- <NavLink
110
- class="px-0 lg:px-xs"
111
- href={`https://figma.com/@${getConfig().figma_handle}`}
112
- target="_blank"
113
- rel="noopener"
114
- >
115
- <Icon name="figma-brand" title="Figma" />
116
- <small class="lg:d-none ms-xs">Figma Community</small>
117
- </NavLink>
118
- <NavLink
119
- class="px-0 lg:px-xs"
120
- href={`https://github.com/${getConfig().github_org}`}
121
- target="_blank"
122
- rel="noopener"
123
- >
124
- <Icon name="github-brand" title="GitHub" />
125
- <small class="lg:d-none ms-xs">GitHub</small>
126
- </NavLink>
110
+ {
111
+ figmaHandle && (
112
+ <NavLink
113
+ class="px-0 lg:px-xs"
114
+ href={`https://figma.com/@${figmaHandle}`}
115
+ target="_blank"
116
+ rel="noopener"
117
+ >
118
+ <Icon name="figma-brand" title="Figma" />
119
+ <small class="lg:d-none ms-xs">Figma Community</small>
120
+ </NavLink>
121
+ )
122
+ }
123
+ {
124
+ githubOrg && (
125
+ <NavLink
126
+ class="px-0 lg:px-xs"
127
+ href={`https://github.com/${githubOrg}`}
128
+ target="_blank"
129
+ rel="noopener"
130
+ >
131
+ <Icon name="github-brand" title="GitHub" />
132
+ <small class="lg:d-none ms-xs">GitHub</small>
133
+ </NavLink>
134
+ )
135
+ }
127
136
  <li class="nav-item col-12 lg:col-auto">
128
137
  <div class="vr d-none lg:d-flex h-100 lg:mx-2xs"></div>
129
138
  <hr class="lg:d-none" />
@@ -0,0 +1,138 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { load } from 'js-yaml'
4
+ import { z } from 'astro/zod'
5
+ import {
6
+ configSchema,
7
+ removedConfigKeys,
8
+ renamedConfigKeys,
9
+ sidebarSchema,
10
+ type ChassisConfig,
11
+ type Sidebar
12
+ } from './schema'
13
+
14
+ export interface LoadConfigOptions<TSchema extends z.ZodType = typeof configSchema> {
15
+ /**
16
+ * Path of the config file. A relative path is resolved from `root`.
17
+ * @default 'config.yml'
18
+ */
19
+ file?: string
20
+ /**
21
+ * The site's root: the directory that holds `astro.config.ts`.
22
+ * @default process.cwd()
23
+ */
24
+ root?: string
25
+ /** `configSchema`, or an extension of it when the site has keys of its own. */
26
+ schema?: TSchema
27
+ }
28
+
29
+ function getValueAtPath(record: unknown, keyPath: string): unknown {
30
+ return keyPath.split('.').reduce<unknown>((value, part) => {
31
+ return value && typeof value === 'object' ? (value as Record<string, unknown>)[part] : undefined
32
+ }, record)
33
+ }
34
+
35
+ function formatIssues(error: z.ZodError): string {
36
+ return error.issues
37
+ .map((issue) => ` - ${issue.path.length ? issue.path.join('.') : '(root)'}: ${issue.message}`)
38
+ .join('\n')
39
+ }
40
+
41
+ // The schema is strict, so an old key would fail as "unrecognized key". These messages say
42
+ // what to do about it.
43
+ function findOutdatedKeys(raw: unknown): string[] {
44
+ const messages: string[] = []
45
+
46
+ for (const [oldKey, newKey] of Object.entries(renamedConfigKeys)) {
47
+ if (getValueAtPath(raw, oldKey) !== undefined) {
48
+ messages.push(` - \`${oldKey}\` was renamed to \`${newKey}\`.`)
49
+ }
50
+ }
51
+
52
+ for (const [oldKey, reason] of Object.entries(removedConfigKeys)) {
53
+ if (getValueAtPath(raw, oldKey) !== undefined) {
54
+ messages.push(` - \`${oldKey}\` was removed. ${reason}`)
55
+ }
56
+ }
57
+
58
+ return messages
59
+ }
60
+
61
+ function readYaml(file: string): unknown {
62
+ try {
63
+ return load(fs.readFileSync(file, 'utf8'))
64
+ } catch (error) {
65
+ throw new Error(`Failed to read \`${file}\`.`, { cause: error })
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Reads and validates the site's `config.yml`.
71
+ *
72
+ * For code that runs while Astro loads its configuration, such as `astro.config.ts` and a
73
+ * site's own integrations. Pages and components read the config with `getConfig()` from
74
+ * `@chassis-ui/docs/site`.
75
+ *
76
+ * @example
77
+ * ```ts
78
+ * // astro.config.ts
79
+ * const config = loadConfig({ root: import.meta.dirname, schema: siteConfigSchema })
80
+ * ```
81
+ */
82
+ export function loadConfig<TSchema extends z.ZodType = typeof configSchema>(
83
+ options: LoadConfigOptions<TSchema> = {}
84
+ ): z.infer<TSchema> {
85
+ const { file = 'config.yml', root = process.cwd(), schema = configSchema } = options
86
+ const configPath = path.resolve(root, file)
87
+ const raw = readYaml(configPath)
88
+
89
+ const outdatedKeys = findOutdatedKeys(raw)
90
+
91
+ if (outdatedKeys.length > 0) {
92
+ throw new Error(
93
+ `\`${configPath}\` has keys from before @chassis-ui/docs 0.6.0:\n${outdatedKeys.join('\n')}`
94
+ )
95
+ }
96
+
97
+ const result = schema.safeParse(raw)
98
+
99
+ if (!result.success) {
100
+ throw new Error(`\`${configPath}\` is invalid:\n${formatIssues(result.error)}`)
101
+ }
102
+
103
+ return result.data as z.infer<TSchema>
104
+ }
105
+
106
+ export interface LoadDataOptions<TSchema extends z.ZodType> {
107
+ /** Path of the data file. A relative path is resolved from `root`. */
108
+ file: string
109
+ /** @default process.cwd() */
110
+ root?: string
111
+ schema: TSchema
112
+ }
113
+
114
+ /** Reads and validates a YAML data file. */
115
+ export function loadData<TSchema extends z.ZodType>(
116
+ options: LoadDataOptions<TSchema>
117
+ ): z.infer<TSchema> {
118
+ const { file, root = process.cwd(), schema } = options
119
+ const dataPath = path.resolve(root, file)
120
+ const result = schema.safeParse(readYaml(dataPath))
121
+
122
+ if (!result.success) {
123
+ throw new Error(`\`${dataPath}\` is invalid:\n${formatIssues(result.error)}`)
124
+ }
125
+
126
+ return result.data as z.infer<TSchema>
127
+ }
128
+
129
+ /** Reads and validates the site's `data/sidebar.yml`. */
130
+ export function loadSidebar(options: { file?: string; root?: string } = {}): Sidebar {
131
+ return loadData({
132
+ file: options.file ?? 'data/sidebar.yml',
133
+ root: options.root,
134
+ schema: sidebarSchema
135
+ })
136
+ }
137
+
138
+ export type { ChassisConfig, Sidebar }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * URL path of a docs page.
3
+ *
4
+ * @param docsPath The `docsPath` of the site's `config.yml`, e.g. `"/css/docs"`.
5
+ * @param inputPath Path of the page inside the docs, e.g. `"/components/button/"`.
6
+ */
7
+ export function joinDocsPath(docsPath: string, inputPath: string): string {
8
+ const base = docsPath.replace(/\/+$/, '')
9
+ const page = inputPath.replace(/^\/+/, '')
10
+
11
+ return page ? `${base}/${page}` : base
12
+ }
@@ -14,32 +14,6 @@ const classTransformer: ShikiTransformer = {
14
14
  }
15
15
  }
16
16
 
17
- /**
18
- * Shiki transformer that wraps non-comment lines in a `<span class="line">`
19
- * element. Applied only to shell languages (bash, sh, powershell) where line
20
- * wrapping is needed for the copy-button to correctly extract individual commands.
21
- */
22
- const lineWrapperTransformer: ShikiTransformer = {
23
- name: 'line-wrapper',
24
- line(node) {
25
- const hasOnlyComments = node.children.every(
26
- (child) =>
27
- child.type === 'element' &&
28
- child.properties?.class &&
29
- Array.isArray(child.properties.class) &&
30
- child.properties.class.some((cls) => typeof cls === 'string' && cls.includes('comment'))
31
- )
32
-
33
- if (!hasOnlyComments) {
34
- node.properties = node.properties || {}
35
- const existing = node.properties.class ?? ''
36
- const classes = typeof existing === 'string' ? existing.split(' ') : []
37
- if (!classes.includes('line')) classes.push('line')
38
- node.properties.class = classes.join(' ').trim()
39
- }
40
- }
41
- }
42
-
43
17
  /**
44
18
  * Replaces any remaining `shiki` / `shiki-themes` class strings in the
45
19
  * serialized HTML that the transformer may have missed (e.g. in inline styles
@@ -53,38 +27,24 @@ function replaceShikiClasses(html: string): string {
53
27
  }
54
28
 
55
29
  /**
56
- * Highlights a code string using Shiki with the GitHub light/dark dual theme.
57
- *
58
- * Inline styles are converted to CSS classes via `transformerStyleToClass` so
59
- * that a single `<style>` block can be emitted per page rather than repeating
60
- * styles on every token. The returned `css` string must be injected into the
61
- * page (e.g. inside a `<style>` tag) for the highlighted output to render correctly.
62
- *
63
- * For shell languages (`bash`, `sh`, `powershell`) each non-comment line is
64
- * additionally wrapped in `<span class="line">` to support per-line copy.
30
+ * Highlights a code string using Shiki with the GitHub light/dark dual theme. Shiki wraps
31
+ * each line in a `<span class="line">`.
65
32
  *
66
33
  * @param code - Source code to highlight.
67
34
  * @param lang - Shiki language identifier (e.g. `"html"`, `"ts"`, `"bash"`).
68
35
  * @param extraTransformers - Additional Shiki transformers to apply before the built-ins.
69
- * @returns An object with `html` (the highlighted markup) and `css` (the extracted styles).
36
+ * @returns An object with `html`, the highlighted markup.
70
37
  */
71
38
  export async function highlightCode(
72
39
  code: string,
73
40
  lang: string,
74
41
  extraTransformers: ShikiTransformer[] = []
75
42
  ): Promise<{ html: string }> {
76
- const shouldWrapLines = ['bash', 'sh', 'powershell'].includes(lang)
77
-
78
- const transformers: ShikiTransformer[] = [...extraTransformers, classTransformer]
79
- if (shouldWrapLines) {
80
- transformers.push(lineWrapperTransformer)
81
- }
82
-
83
43
  const highlighted = await codeToHtml(code, {
84
44
  lang,
85
45
  themes: DOCS_SHIKI_THEMES,
86
46
  defaultColor: false,
87
- transformers
47
+ transformers: [...extraTransformers, classTransformer]
88
48
  })
89
49
 
90
50
  return { html: replaceShikiClasses(highlighted) }
@@ -0,0 +1,160 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ // Public paths of the package and the directories behind them. They match `exports` in
6
+ // `package.json`.
7
+ const publicDirectories: Record<string, string> = {
8
+ components: 'src/components',
9
+ js: 'src/js',
10
+ layouts: 'src/layouts',
11
+ scss: 'src/scss',
12
+ shortcodes: 'src/components/shortcodes'
13
+ }
14
+
15
+ /**
16
+ * Absolute path of the package on disk.
17
+ *
18
+ * Only correct in code that runs while Astro loads its configuration. Pages and components
19
+ * are bundled, so they use `getPackageFilePath()` from `@chassis-ui/docs/site`.
20
+ */
21
+ export function getPackageRoot(): string {
22
+ return fileURLToPath(new URL('../../', import.meta.url))
23
+ }
24
+
25
+ /**
26
+ * Absolute path of a file of the package, named by its public path.
27
+ *
28
+ * @param file Path as it is imported, without the package name, e.g. `"js/color-modes.js"`
29
+ * or `"shortcodes/Code.astro"`.
30
+ * @param packageRoot Absolute path of the package.
31
+ */
32
+ export function resolvePackageFilePath(file: string, packageRoot = getPackageRoot()): string {
33
+ const [directory, ...rest] = file.split('/')
34
+ const target = publicDirectories[directory]
35
+
36
+ if (!target) {
37
+ throw new Error(
38
+ `'${file}' is not a public path of @chassis-ui/docs. Expected a path that starts with one of: ${Object.keys(publicDirectories).join(', ')}.`
39
+ )
40
+ }
41
+
42
+ return path.join(packageRoot, target, ...rest)
43
+ }
44
+
45
+ // The repository root is the nearest ancestor with a `.git` entry or a pnpm workspace file.
46
+ function getSearchDirectories(root: string): string[] {
47
+ const directories: string[] = []
48
+ let current = path.resolve(root)
49
+
50
+ while (true) {
51
+ directories.push(current)
52
+
53
+ const isRepositoryRoot =
54
+ fs.existsSync(path.join(current, '.git')) ||
55
+ fs.existsSync(path.join(current, 'pnpm-workspace.yaml'))
56
+ const parent = path.dirname(current)
57
+
58
+ if (isRepositoryRoot || parent === current) {
59
+ return directories
60
+ }
61
+
62
+ current = parent
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Every `node_modules` directory between the site's root and the root of the repository,
68
+ * nearest first. For tools that resolve package names without Vite, such as the Sass API.
69
+ */
70
+ export function getNodeModulesFsPaths(root = process.cwd()): string[] {
71
+ return getSearchDirectories(root)
72
+ .map((directory) => path.join(directory, 'node_modules'))
73
+ .filter((directory) => fs.existsSync(directory))
74
+ }
75
+
76
+ function findUp(root: string, candidates: string[], description: string): string {
77
+ const searched = getSearchDirectories(root)
78
+
79
+ for (const directory of searched) {
80
+ for (const candidate of candidates) {
81
+ const candidatePath = path.join(directory, candidate)
82
+
83
+ if (fs.existsSync(candidatePath)) {
84
+ return candidatePath
85
+ }
86
+ }
87
+ }
88
+
89
+ throw new Error(
90
+ `Could not find ${description}. Looked for ${candidates.map((c) => `'${c}'`).join(' or ')} in:\n${searched.map((d) => ` - ${d}`).join('\n')}`
91
+ )
92
+ }
93
+
94
+ export interface ChassisPathOptions {
95
+ /**
96
+ * The site's root. The search starts here and stops at the root of the repository.
97
+ * @default process.cwd()
98
+ */
99
+ root?: string
100
+ /**
101
+ * The directory to use, when it is not where the search would find it. A relative path is
102
+ * resolved from `root`.
103
+ */
104
+ dir?: string
105
+ }
106
+
107
+ function resolveDir({ root = process.cwd(), dir }: ChassisPathOptions): string | undefined {
108
+ return dir ? path.resolve(root, dir) : undefined
109
+ }
110
+
111
+ /**
112
+ * Directory of an installed package. Finds the package whether `node_modules` is in the
113
+ * site's root or hoisted to the root of the repository.
114
+ */
115
+ export function getInstalledPackageFsPath(name: string, root = process.cwd()): string {
116
+ return findUp(root, [path.join('node_modules', name)], `the package '${name}'`)
117
+ }
118
+
119
+ /**
120
+ * The docs build of `@chassis-ui/tokens`. The folder moved from `dist/tokens/web/docs` to
121
+ * `dist/web/docs` between releases of the tokens package, and both are found.
122
+ */
123
+ export function getChassisTokensFsPath(options: ChassisPathOptions = {}): string {
124
+ const tokens =
125
+ resolveDir(options) ?? getInstalledPackageFsPath('@chassis-ui/tokens', options.root)
126
+
127
+ return findUp(
128
+ tokens,
129
+ ['dist/web/docs/chassis', 'dist/tokens/web/docs/chassis'],
130
+ 'the docs build of @chassis-ui/tokens'
131
+ )
132
+ }
133
+
134
+ /**
135
+ * The docs build of `chassis-assets`: in the `vendor/assets` submodule, or in `dist` for the
136
+ * site of `chassis-assets` itself.
137
+ */
138
+ export function getChassisAssetsFsPath(options: ChassisPathOptions = {}): string {
139
+ return (
140
+ resolveDir(options) ??
141
+ findUp(
142
+ options.root ?? process.cwd(),
143
+ ['vendor/assets/dist/web/docs/chassis', 'dist/web/docs/chassis'],
144
+ 'the docs build of chassis-assets'
145
+ )
146
+ )
147
+ }
148
+
149
+ /** The `dist` folder of `@chassis-ui/css`. */
150
+ export function getChassisCSSFsPath(options: ChassisPathOptions = {}): string {
151
+ return (
152
+ resolveDir(options) ??
153
+ path.join(getInstalledPackageFsPath('@chassis-ui/css', options.root), 'dist')
154
+ )
155
+ }
156
+
157
+ /** The folder of `@chassis-ui/icons`. */
158
+ export function getChassisIconsFsPath(options: ChassisPathOptions = {}): string {
159
+ return resolveDir(options) ?? getInstalledPackageFsPath('@chassis-ui/icons', options.root)
160
+ }