@chassis-ui/docs 0.5.0 → 0.6.0-next.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.
@@ -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
+ }
@@ -0,0 +1,184 @@
1
+ import type { Root } from 'mdast'
2
+ import type { MdxJsxAttribute, MdxJsxExpressionAttribute } from 'mdast-util-mdx-jsx'
3
+ import type { Plugin } from 'unified'
4
+ import { visit } from 'unist-util-visit'
5
+ import { joinDocsPath } from './docs-path'
6
+
7
+ // [[config:foo]]
8
+ // [[config:foo.bar]]
9
+ const configRegExp = /\[\[config:(?<name>[\w.]+)\]\]/g
10
+ // [[docsref:/foo]]
11
+ // [[docsref:/foo/bar#baz]]
12
+ const docsrefRegExp = /\[\[docsref:(?<path>[\w./#-]+)\]\]/g
13
+
14
+ type Replacer = (_text: string) => string
15
+ type MdxAttributes = (MdxJsxAttribute | MdxJsxExpressionAttribute)[]
16
+
17
+ export interface RemarkCxConfigOptions {
18
+ /** The parsed `config.yml`. */
19
+ config: object
20
+ }
21
+
22
+ export interface RemarkCxDocsrefOptions {
23
+ /** The `docsPath` of the site's `config.yml`. */
24
+ docsPath: string
25
+ /** Called with each docs path that a page links to. */
26
+ onDocsPath?: (_path: string) => void
27
+ }
28
+
29
+ /** Replaces each `[[config:key]]` in `text` with the value of that key. */
30
+ export function replaceConfigInText(text: string, config: object): string {
31
+ return text.replace(configRegExp, (_match, keyPath: string) => {
32
+ const value = keyPath.split('.').reduce<unknown>((values, part) => {
33
+ return values && typeof values === 'object'
34
+ ? (values as Record<string, unknown>)[part]
35
+ : undefined
36
+ }, config)
37
+
38
+ if (typeof value !== 'string' || !value) {
39
+ throw new Error(`Failed to find a valid configuration value for '${keyPath}'.`)
40
+ }
41
+
42
+ return value
43
+ })
44
+ }
45
+
46
+ /** Replaces each `[[docsref:/path]]` in `text` with the URL path of that docs page. */
47
+ export function replaceDocsrefInText(text: string, options: RemarkCxDocsrefOptions): string {
48
+ return text.replace(docsrefRegExp, (_match, inputPath: string) => {
49
+ options.onDocsPath?.(inputPath)
50
+
51
+ return joinDocsPath(options.docsPath, inputPath)
52
+ })
53
+ }
54
+
55
+ function replaceInAttributes(attributes: MdxAttributes, replacer: Replacer) {
56
+ return attributes.map((attribute) => {
57
+ if (attribute.type === 'mdxJsxAttribute' && typeof attribute.value === 'string') {
58
+ attribute.value = replacer(attribute.value)
59
+ }
60
+
61
+ return attribute
62
+ })
63
+ }
64
+
65
+ function replaceInFrontmatter(record: Record<string, unknown>, replacer: Replacer) {
66
+ for (const [key, value] of Object.entries(record)) {
67
+ if (typeof value === 'string') {
68
+ record[key] = replacer(value)
69
+ } else if (Array.isArray(value)) {
70
+ record[key] = value.map((arrayValue) => {
71
+ return typeof arrayValue === 'string'
72
+ ? replacer(arrayValue)
73
+ : typeof arrayValue === 'object'
74
+ ? replaceInFrontmatter(arrayValue, replacer)
75
+ : arrayValue
76
+ })
77
+ }
78
+ }
79
+
80
+ return record
81
+ }
82
+
83
+ function containsFrontmatter(data: unknown): data is { frontmatter: Record<string, unknown> } {
84
+ return data != undefined && typeof data === 'object' && 'frontmatter' in data
85
+ }
86
+
87
+ /**
88
+ * A remark plugin that replaces config values in Markdown and MDX files. `[[config:foo]]`
89
+ * becomes the value of the `foo` key of `config.yml`, and `[[config:foo.bar]]` reads a nested
90
+ * key. It also works in frontmatter.
91
+ */
92
+ export const remarkCxConfig: Plugin<[RemarkCxConfigOptions], Root> = function ({ config }) {
93
+ const replacer: Replacer = (text) => replaceConfigInText(text, config)
94
+
95
+ return function remarkCxConfigPlugin(ast, file) {
96
+ if (containsFrontmatter(file.data.astro)) {
97
+ replaceInFrontmatter(file.data.astro.frontmatter, replacer)
98
+ }
99
+
100
+ // https://github.com/syntax-tree/mdast#nodes
101
+ // https://github.com/syntax-tree/mdast-util-mdx-jsx#nodes
102
+ visit(
103
+ ast,
104
+ ['code', 'definition', 'image', 'inlineCode', 'link', 'mdxJsxFlowElement', 'text'],
105
+ (node) => {
106
+ switch (node.type) {
107
+ case 'code':
108
+ case 'inlineCode':
109
+ case 'text': {
110
+ node.value = replacer(node.value)
111
+ break
112
+ }
113
+ case 'image': {
114
+ if (node.alt) {
115
+ node.alt = replacer(node.alt)
116
+ }
117
+
118
+ node.url = replacer(node.url)
119
+ break
120
+ }
121
+ case 'definition':
122
+ case 'link': {
123
+ node.url = replacer(node.url)
124
+ break
125
+ }
126
+ case 'mdxJsxFlowElement': {
127
+ node.attributes = replaceInAttributes(node.attributes, replacer)
128
+ break
129
+ }
130
+ }
131
+ }
132
+ )
133
+ }
134
+ }
135
+
136
+ /**
137
+ * A remark plugin that turns `[[docsref:/foo]]` into a link to the docs page `/foo`, under
138
+ * the `docsPath` of `config.yml`. It also works in frontmatter.
139
+ */
140
+ export const remarkCxDocsref: Plugin<[RemarkCxDocsrefOptions], Root> = function (options) {
141
+ const replacer: Replacer = (text) => replaceDocsrefInText(text, options)
142
+
143
+ return function remarkCxDocsrefPlugin(ast, file) {
144
+ if (containsFrontmatter(file.data.astro)) {
145
+ replaceInFrontmatter(file.data.astro.frontmatter, replacer)
146
+ }
147
+
148
+ // https://github.com/syntax-tree/mdast#nodes
149
+ // https://github.com/syntax-tree/mdast-util-mdx-jsx#nodes
150
+ visit(
151
+ ast,
152
+ [
153
+ 'code',
154
+ 'definition',
155
+ 'image',
156
+ 'inlineCode',
157
+ 'link',
158
+ 'mdxJsxFlowElement',
159
+ 'mdxJsxTextElement',
160
+ 'text'
161
+ ],
162
+ (node) => {
163
+ switch (node.type) {
164
+ case 'code':
165
+ case 'inlineCode':
166
+ case 'text': {
167
+ node.value = replacer(node.value)
168
+ break
169
+ }
170
+ case 'definition':
171
+ case 'link': {
172
+ node.url = replacer(node.url)
173
+ break
174
+ }
175
+ case 'mdxJsxFlowElement':
176
+ case 'mdxJsxTextElement': {
177
+ node.attributes = replaceInAttributes(node.attributes, replacer)
178
+ break
179
+ }
180
+ }
181
+ }
182
+ )
183
+ }
184
+ }