@chassis-ui/docs 0.5.1 → 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.
- package/CHANGELOG.md +256 -0
- package/README.md +227 -71
- package/UPGRADING.md +243 -0
- package/index.ts +7 -1
- package/package.json +31 -8
- package/src/components/DocsSidebar.astro +9 -9
- package/src/components/shortcodes/Callout.astro +4 -4
- package/src/components/shortcodes/Code.astro +3 -3
- package/src/components/shortcodes/JsDocs.astro +6 -7
- package/src/components/shortcodes/ScssDocs.astro +7 -9
- package/src/components/shortcodes/ScssDocsSimple.astro +6 -7
- package/src/integration.ts +271 -0
- package/src/js/clipboard.ts +65 -0
- package/src/layouts/BaseLayout.astro +7 -9
- package/src/layouts/DocsLayout.astro +11 -13
- package/src/layouts/RedirectLayout.astro +1 -1
- package/src/layouts/head/Analytics.astro +6 -4
- package/src/layouts/head/Head.astro +2 -2
- package/src/layouts/head/Social.astro +5 -6
- package/src/layouts/header/Navigation.astro +28 -19
- package/src/libs/config.ts +138 -0
- package/src/libs/docs-path.ts +12 -0
- package/src/libs/highlight.ts +4 -44
- package/src/libs/paths.ts +160 -0
- package/src/libs/remark.ts +184 -0
- package/src/libs/schema.ts +211 -0
- package/src/libs/shortcodes.ts +86 -71
- package/src/libs/snippet.ts +21 -0
- package/src/libs/source.ts +17 -40
- package/src/libs/toc.ts +3 -1
- package/src/scss/_vars.scss +4 -1
- package/src/site.ts +150 -0
- package/virtual.d.ts +25 -0
|
@@ -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
|
+
}
|
package/src/libs/highlight.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|