@chassis-ui/docs 0.2.0 → 0.3.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.
Files changed (74) hide show
  1. package/README.md +16 -16
  2. package/index.ts +6 -1
  3. package/package.json +20 -8
  4. package/src/components/DocsSidebar.astro +63 -23
  5. package/src/components/FeatureCard.astro +3 -3
  6. package/src/components/TableOfContents.astro +8 -7
  7. package/src/components/ThemeToggler.astro +15 -36
  8. package/src/components/shortcodes/Blockquote.astro +1 -1
  9. package/src/components/shortcodes/CSSOnly.astro +6 -2
  10. package/src/components/shortcodes/Callout.astro +6 -2
  11. package/src/components/shortcodes/Code.astro +289 -0
  12. package/src/components/shortcodes/DeprecatedIn.astro +5 -2
  13. package/src/components/shortcodes/Example.astro +132 -0
  14. package/src/components/shortcodes/Icon.astro +57 -0
  15. package/src/components/shortcodes/InFigma.astro +7 -2
  16. package/src/components/shortcodes/JsDocs.astro +64 -0
  17. package/src/components/shortcodes/Placeholder.astro +28 -0
  18. package/src/components/shortcodes/ResizableExample.astro +126 -0
  19. package/src/components/shortcodes/ScssDocs.astro +108 -0
  20. package/src/components/shortcodes/ScssDocsSimple.astro +66 -0
  21. package/src/js/color-modes.js +3 -3
  22. package/src/js/example-mode.js +75 -0
  23. package/src/js/search.js +501 -39
  24. package/src/js/theme-color.js +1 -1
  25. package/src/layouts/DocsLayout.astro +11 -14
  26. package/src/layouts/RedirectLayout.astro +1 -0
  27. package/src/layouts/footer/Scripts.astro +14 -9
  28. package/src/layouts/head/Analytics.astro +12 -6
  29. package/src/layouts/head/Favicons.astro +0 -2
  30. package/src/layouts/head/Head.astro +2 -7
  31. package/src/layouts/head/Stylesheet.astro +18 -11
  32. package/src/layouts/header/Header.astro +2 -5
  33. package/src/layouts/header/Navigation.astro +31 -33
  34. package/src/layouts/header/SearchDialog.astro +74 -0
  35. package/src/layouts/header/SearchTrigger.astro +41 -0
  36. package/src/libs/highlight.ts +91 -0
  37. package/src/libs/icon.ts +75 -0
  38. package/src/libs/image.ts +12 -1
  39. package/src/libs/layout.ts +18 -0
  40. package/src/libs/markdown.ts +128 -0
  41. package/src/libs/placeholder.ts +250 -0
  42. package/src/libs/rehype.ts +0 -17
  43. package/src/libs/shortcodes.ts +129 -0
  44. package/src/libs/site.ts +95 -0
  45. package/src/libs/toc.ts +40 -6
  46. package/src/libs/utils.ts +34 -82
  47. package/src/libs/vite.ts +45 -0
  48. package/src/scss/_anchor.scss +20 -16
  49. package/src/scss/_callouts.scss +29 -28
  50. package/src/scss/_content.scss +72 -103
  51. package/src/scss/_footer.scss +18 -19
  52. package/src/scss/_layout.scss +56 -63
  53. package/src/scss/_navbar.scss +19 -123
  54. package/src/scss/_placeholder-img.scss +11 -7
  55. package/src/scss/_scrolling.scss +14 -12
  56. package/src/scss/_search.scss +418 -0
  57. package/src/scss/_shiki.scss +120 -0
  58. package/src/scss/_sidebar.scss +137 -132
  59. package/src/scss/_skippy.scss +6 -4
  60. package/src/scss/_snippets.scss +181 -0
  61. package/src/scss/_toc.scss +97 -93
  62. package/src/scss/_vars.scss +89 -0
  63. package/src/scss/main.scss +75 -80
  64. package/src/js/icon-loader.js +0 -229
  65. package/src/libs/chassis.ts +0 -38
  66. package/src/scss/_brand.scss +0 -59
  67. package/src/scss/_buttons.scss +0 -33
  68. package/src/scss/_clipboard-js.scss +0 -63
  69. package/src/scss/_code.scss +0 -116
  70. package/src/scss/_colors.scss +0 -140
  71. package/src/scss/_docsearch.scss +0 -212
  72. package/src/scss/_settings.scss +0 -29
  73. package/src/scss/_syntax.scss +0 -160
  74. package/src/scss/_variables.scss +0 -73
@@ -0,0 +1,128 @@
1
+ import type { AstroUserConfig } from 'astro'
2
+ import type { Element, Text } from 'hast'
3
+ import type { ShikiTransformer } from 'shiki'
4
+ import { rehypeHeadingIds, unified } from '@astrojs/markdown-remark'
5
+ import type { UnifiedProcessorOptions } from '@astrojs/markdown-remark'
6
+ import { transformerNotationDiff, transformerNotationHighlight } from '@shikijs/transformers'
7
+ import rehypeAutolinkHeadings from 'rehype-autolink-headings'
8
+ import { rehypeCxTable } from './rehype'
9
+
10
+ type MarkdownConfig = NonNullable<AstroUserConfig['markdown']>
11
+
12
+ /**
13
+ * Shiki theme pair used for both programmatic highlighting (`highlightCode`) and
14
+ * the Astro markdown pipeline (`getDocsMarkdownConfig`). Defined once here to
15
+ * keep both in sync.
16
+ */
17
+ export const DOCS_SHIKI_THEMES = {
18
+ light: 'github-light' as const,
19
+ dark: 'github-dark' as const
20
+ }
21
+
22
+ /**
23
+ * Shiki transformer that exposes the current language as a `data-language`
24
+ * attribute on `<pre>` elements. The `Code` component reads this attribute when
25
+ * rendering inline markdown code blocks so it can display the correct language
26
+ * label without requiring an explicit `lang` prop.
27
+ */
28
+ const addLanguageAttributeTransformer: ShikiTransformer = {
29
+ name: 'add-language-attribute',
30
+ pre(node) {
31
+ const lang = this.options.lang
32
+ if (lang) {
33
+ node.properties['dataLanguage'] = lang
34
+ }
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Options accepted by `getDocsMarkdownConfig`.
40
+ */
41
+ export interface DocsMarkdownConfigOptions {
42
+ /**
43
+ * Heading depth range used to determine which headings receive an autolink
44
+ * anchor. Typically sourced from the project's `config.yml` `anchors` section.
45
+ *
46
+ * @example { min: 2, max: 4 } // anchors h2–h4
47
+ */
48
+ anchors: {
49
+ min: number
50
+ max: number
51
+ }
52
+ /** Additional project-specific rehype plugins, appended after the base set. */
53
+ rehypePlugins?: UnifiedProcessorOptions['rehypePlugins']
54
+ /** Additional project-specific remark plugins, appended after the base set. */
55
+ remarkPlugins?: UnifiedProcessorOptions['remarkPlugins']
56
+ /**
57
+ * Passed directly to `remark-rehype`. Shallow-merged so individual handler
58
+ * overrides don't discard base handlers.
59
+ */
60
+ remarkRehype?: UnifiedProcessorOptions['remarkRehype']
61
+ }
62
+
63
+ /**
64
+ * Returns the shared Astro markdown configuration for Chassis docs sites.
65
+ *
66
+ * Includes the following base rehype plugins (in order):
67
+ * - `rehypeHeadingIds` — adds `id` attributes to all headings.
68
+ * - `rehypeAutolinkHeadings` — appends anchor links to headings in the
69
+ * `anchors.min`–`anchors.max` depth range.
70
+ * - `rehypeCxTable` — applies CSS classes to `<CxTable>`-wrapped tables.
71
+ * - _(any `options.rehypePlugins`)_
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * // astro.config.ts
76
+ * import { getDocsMarkdownConfig } from '@chassis-ui/docs'
77
+ * import { getConfig } from './src/libs/config'
78
+ *
79
+ * export default defineConfig({
80
+ * markdown: getDocsMarkdownConfig({
81
+ * anchors: getConfig().anchors,
82
+ * rehypePlugins: [myExtraPlugin],
83
+ * remarkPlugins: [remarkCxConfig, remarkDefinitionList],
84
+ * remarkRehype: { handlers: defListHastHandlers }
85
+ * })
86
+ * })
87
+ * ```
88
+ */
89
+ export function getDocsMarkdownConfig(options: DocsMarkdownConfigOptions): MarkdownConfig {
90
+ const headingsRangeRegex = new RegExp(`^h[${options.anchors.min}-${options.anchors.max}]$`)
91
+
92
+ return {
93
+ processor: unified({
94
+ smartypants: false,
95
+ rehypePlugins: [
96
+ rehypeHeadingIds,
97
+ [
98
+ rehypeAutolinkHeadings,
99
+ {
100
+ behavior: 'append',
101
+ content: [{ type: 'text', value: ' ' }],
102
+ properties: (element: Element) => ({
103
+ class: 'anchor-link',
104
+ ariaLabel: `Link to this section: ${(element.children[0] as Text).value}`
105
+ }),
106
+ test: (element: Element) => element.tagName.match(headingsRangeRegex)
107
+ }
108
+ ],
109
+ rehypeCxTable,
110
+ ...(options.rehypePlugins ?? [])
111
+ ],
112
+ remarkPlugins: options.remarkPlugins ?? [],
113
+ ...(options.remarkRehype && {
114
+ remarkRehype: options.remarkRehype
115
+ })
116
+ }),
117
+ syntaxHighlight: 'shiki',
118
+ shikiConfig: {
119
+ themes: DOCS_SHIKI_THEMES,
120
+ defaultColor: false,
121
+ transformers: [
122
+ transformerNotationDiff(),
123
+ transformerNotationHighlight(),
124
+ addLanguageAttributeTransformer
125
+ ]
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,250 @@
1
+ import type { HTMLAttributes } from 'astro/types'
2
+ import * as htmlparser2 from 'htmlparser2'
3
+
4
+ const placeholderRegex = /<Placeholder\s+([^>]+)\/>/g
5
+
6
+ /**
7
+ * Generates all the placeholder attributes and options required to render a placeholder.
8
+ * @see src/components/shortcodes/Placeholder.astro
9
+ */
10
+ export function getPlaceholder(userOptions: Partial<PlaceholderOptions>): Placeholder {
11
+ const options = getOptionsWithDefaults(userOptions)
12
+ const { class: className, height, markup, text, title, width } = options
13
+
14
+ const showText = text !== false
15
+ const showTitle = title !== false
16
+
17
+ const placeholderClassList = ['cxd-placeholder-image', className].join(' ')
18
+ const placeholderRole = showTitle || showText ? 'img' : undefined
19
+ const placeholderAriaHidden = !showText && !showTitle ? 'true' : undefined
20
+
21
+ const placeholderLabel =
22
+ showText || showTitle
23
+ ? `${showTitle ? title : ''}${showTitle && showText ? ': ' : ''}${showText ? text : ''}`
24
+ : undefined
25
+
26
+ const optionsWithVisibilities = { ...options, showText, showTitle }
27
+
28
+ if (markup === 'img') {
29
+ return {
30
+ type: 'img',
31
+ options: optionsWithVisibilities,
32
+ props: {
33
+ alt: placeholderLabel,
34
+ class: placeholderClassList,
35
+ height,
36
+ src: getPlaceholderSrc(showTitle, showText, options),
37
+ width
38
+ }
39
+ }
40
+ }
41
+
42
+ return {
43
+ type: 'svg',
44
+ options: optionsWithVisibilities,
45
+ props: {
46
+ 'aria-hidden': placeholderAriaHidden,
47
+ 'aria-label': placeholderLabel,
48
+ class: placeholderClassList,
49
+ height,
50
+ preserveAspectRatio: 'xMidYMid slice',
51
+ role: placeholderRole,
52
+ width,
53
+ xmlns: 'http://www.w3.org/2000/svg'
54
+ }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Replaces placeholders described using the `<Placeholder />` component in HTML markup with the expected HTML content.
60
+ * This is useful to render examples that have a pretty large set of constraints:
61
+ *
62
+ * - The provided HTML code is not valid MDX (e.g. unclosed void elements like <img>) but can contain the
63
+ * `<Placeholder />` Astro component. This means that we cannot use an Astro slot for example that requires valid
64
+ * MDX.
65
+ * - The provided HTML code cannot be parsed in a forgiving way with XML mode enabled (to not lose the structure due
66
+ * to self-closing MDX or Astro components) and serialized back to a string while closing all known void elements
67
+ * in order to render it as MDX using `@mdx-js/mdx` & `astro/jsx-runtime`. This works perfectly (tested) but the
68
+ * DOM needs to contains the exact same HTML markup (even indentation) provided to the example as it is used on the
69
+ * client to send the example to StackBlitz with the same indentation as the original example.
70
+ *
71
+ * If you are not sure if you need to use this function, you probably don't.
72
+ */
73
+ export function replacePlaceholdersInHtml(html: string) {
74
+ return html.replace(placeholderRegex, (match) => {
75
+ const document = htmlparser2.parseDocument(match, { xmlMode: true })
76
+ const placeholderElement = document.firstChild
77
+
78
+ if (
79
+ document.children.length > 1 ||
80
+ !placeholderElement ||
81
+ placeholderElement.type !== htmlparser2.ElementType.Tag ||
82
+ placeholderElement.name !== 'Placeholder'
83
+ ) {
84
+ throw new Error('Invalid placeholder element.')
85
+ }
86
+
87
+ const placeholder = getPlaceholder(sanitizeHtmlAttributesFromMdx(placeholderElement.attribs))
88
+
89
+ return renderPlaceholderToString(placeholder)
90
+ })
91
+ }
92
+
93
+ function renderPlaceholderToString(placeholder: Placeholder) {
94
+ let placeholderStr = `<${placeholder.type}`
95
+
96
+ for (const [key, value] of Object.entries(placeholder.props)) {
97
+ if (value === undefined) {
98
+ continue
99
+ }
100
+
101
+ placeholderStr = `${placeholderStr} ${key}="${value}"`
102
+ }
103
+
104
+ if (placeholder.type === 'img') {
105
+ return `${placeholderStr} />`
106
+ }
107
+
108
+ placeholderStr = `${placeholderStr}>`
109
+
110
+ if (placeholder.options.showTitle) {
111
+ placeholderStr = `${placeholderStr}<title>${placeholder.options.title}</title>`
112
+ }
113
+
114
+ placeholderStr = `${placeholderStr}<rect width="100%" height="100%" fill="${placeholder.options.background}" />`
115
+
116
+ if (placeholder.options.showText) {
117
+ placeholderStr = `${placeholderStr}<text x="50%" y="50%" fill="${placeholder.options.color}" dy=".3em">${placeholder.options.text}</text>`
118
+ }
119
+
120
+ return `${placeholderStr}</${placeholder.type}>`
121
+ }
122
+
123
+ function getOptionsWithDefaults(options: Partial<PlaceholderOptions>) {
124
+ const optionsWithDefaults = Object.assign(
125
+ {},
126
+ {
127
+ background: 'var(--cx-neutral-bg-evident)',
128
+ color: 'var(--cx-neutral-fg-subtle)',
129
+ // width: '100%',
130
+ // height: '180',
131
+ markup: 'svg',
132
+ title: 'Placeholder'
133
+ },
134
+ options
135
+ )
136
+
137
+ if (optionsWithDefaults.text === undefined) {
138
+ optionsWithDefaults.text = `${optionsWithDefaults.width}x${optionsWithDefaults.height}`
139
+ }
140
+
141
+ return optionsWithDefaults as PlaceholderOptions
142
+ }
143
+
144
+ function getPlaceholderSrc(
145
+ showTitle: boolean,
146
+ showText: boolean,
147
+ { background, color, text, title }: PlaceholderOptions
148
+ ) {
149
+ // Sanitize the background and text colors by removing the leading hash if any.
150
+ const bgColor = background.replace(/^#/, '')
151
+ const textColor = color.replace(/^#/, '')
152
+
153
+ // Build the raw SVG string first
154
+ let svg = `<svg style='font-size: 1.125rem; font-family:system-ui; -webkit-user-select: none; -moz-user-select: none; user-select: none; text-anchor: middle;' width='200' height='200' xmlns='http://www.w3.org/2000/svg'>`
155
+
156
+ if (showTitle) {
157
+ svg += `<title>${title}</title>`
158
+ }
159
+
160
+ svg += `<rect width='100%' height='100%' fill='#${bgColor}'></rect>`
161
+
162
+ if (showText) {
163
+ svg += `<text x='50%' y='50%' fill='#${textColor}' dy='.3em'>${text}</text>`
164
+ }
165
+
166
+ svg += `</svg>`
167
+
168
+ const encodedSvg = encodeURIComponent(svg)
169
+
170
+ return `data:image/svg+xml,${encodedSvg}`
171
+ }
172
+
173
+ function sanitizeHtmlAttributesFromMdx(attributes: Record<string, unknown>) {
174
+ const sanitizedAttributes: typeof attributes = {}
175
+
176
+ for (const [key, value] of Object.entries(attributes)) {
177
+ if (value === undefined) {
178
+ continue
179
+ } else if (value === '{false}') {
180
+ sanitizedAttributes[key] = false
181
+ } else if (value === '{true}') {
182
+ sanitizedAttributes[key] = true
183
+ } else {
184
+ sanitizedAttributes[key] = value
185
+ }
186
+ }
187
+
188
+ return sanitizedAttributes
189
+ }
190
+
191
+ export interface PlaceholderOptions {
192
+ /**
193
+ * The SVG background color.
194
+ * @default "#868f96"
195
+ */
196
+ background: string
197
+ /**
198
+ * CSS classes to append to `cxd-placeholder-img` for the `svg` or `img` elements.
199
+ */
200
+ class?: string
201
+ /**
202
+ * The text color (foreground).
203
+ * @default "#dee2e6"
204
+ */
205
+ color: string
206
+ /**
207
+ * The placeholder height.
208
+ * @default "180"
209
+ */
210
+ height: string
211
+ /**
212
+ * If it should render `svg` or `img` tags.
213
+ * @default "svg"
214
+ */
215
+ markup: 'img' | 'svg'
216
+ /**
217
+ * The text to show in the image. You can explicitely pass the `false` boolean value (and not the string "false") to
218
+ * hide the text.
219
+ * @default "${width}x${height}"
220
+ */
221
+ text: string | false
222
+ /**
223
+ * Used in the SVG `title` tag. You can explicitely pass the `false` boolean value (and not the string "false") to
224
+ * hide the title.
225
+ * @default "Placeholder"
226
+ */
227
+ title: string | false
228
+ /**
229
+ * The placeholder width.
230
+ * @default "100%"
231
+ */
232
+ width: string
233
+ }
234
+
235
+ interface PlaceholderVisibilities {
236
+ showText: boolean
237
+ showTitle: boolean
238
+ }
239
+
240
+ type Placeholder =
241
+ | {
242
+ type: 'img'
243
+ options: PlaceholderOptions & PlaceholderVisibilities
244
+ props: HTMLAttributes<'img'>
245
+ }
246
+ | {
247
+ type: 'svg'
248
+ options: PlaceholderOptions & PlaceholderVisibilities
249
+ props: HTMLAttributes<'svg'>
250
+ }
@@ -41,20 +41,3 @@ export const rehypeCxTable: Plugin<[], Root> = function () {
41
41
  })
42
42
  }
43
43
  }
44
-
45
- /**
46
- * Rehype plugin to strip `is:raw` attributes from code elements.
47
- *
48
- * The `@astrojs/markdown-remark` rehype-prism plugin adds `is:raw` as an Astro
49
- * directive, but it leaks into the final HTML output because rehype processes
50
- * it as a plain attribute rather than an Astro compiler directive.
51
- */
52
- export const rehypeStripIsRaw: Plugin<[], Root> = function () {
53
- return function rehypeStripIsRawPlugin(tree) {
54
- visit(tree, 'element', (node) => {
55
- if (node.properties && 'is:raw' in node.properties) {
56
- delete node.properties['is:raw']
57
- }
58
- })
59
- }
60
- }
@@ -0,0 +1,129 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import type { AstroIntegration } from 'astro'
4
+
5
+ interface MdxjsEsmNode {
6
+ type: 'mdxjsEsm'
7
+ value: string
8
+ data: { estree: object }
9
+ }
10
+
11
+ function buildImportsNode(imports: string[]): MdxjsEsmNode {
12
+ const body = imports.map((filePath) => {
13
+ const name = path.parse(filePath).name.replaceAll(/[^\w\d]/g, '')
14
+ return {
15
+ type: 'ImportDeclaration',
16
+ specifiers: [{ type: 'ImportDefaultSpecifier', local: { type: 'Identifier', name } }],
17
+ source: { type: 'Literal', value: filePath, raw: JSON.stringify(filePath) }
18
+ }
19
+ })
20
+
21
+ return {
22
+ type: 'mdxjsEsm',
23
+ value: '',
24
+ data: { estree: { type: 'Program', sourceType: 'module', body } }
25
+ }
26
+ }
27
+
28
+ export interface ChassisAutoImportOptions {
29
+ /** Absolute path to the docs site root (e.g. `getDocsFsPath()`). Used for the
30
+ * site-local shortcodes directory and the type definitions output path. */
31
+ docsPath: string
32
+ /** Absolute path to the directory containing `node_modules`. Defaults to
33
+ * `docsPath` when omitted — override when `node_modules` lives at the repo root. */
34
+ modulesPath?: string
35
+ }
36
+
37
+ function scanComponents({ docsPath, modulesPath = docsPath }: ChassisAutoImportOptions) {
38
+ const dirs = [
39
+ path.join(modulesPath, 'node_modules/@chassis-ui/docs/src/components/shortcodes'),
40
+ path.join(docsPath, 'src/components/shortcodes')
41
+ ]
42
+
43
+ const components: string[] = []
44
+ const definitions: string[] = []
45
+
46
+ for (const dir of dirs) {
47
+ if (!fs.existsSync(dir)) continue
48
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
49
+ if (!entry.isFile()) continue
50
+ components.push(`${dir}/${entry.name}`)
51
+ if (entry.name.endsWith('.astro')) {
52
+ const importPath = dir.includes('@chassis-ui/docs')
53
+ ? `@chassis-ui/docs/shortcodes`
54
+ : `@shortcodes`
55
+ definitions.push(
56
+ `export const ${entry.name.replace('.astro', '')}: typeof import('${importPath}/${entry.name}').default`
57
+ )
58
+ }
59
+ }
60
+ }
61
+
62
+ return { components, definitions }
63
+ }
64
+
65
+ /**
66
+ * Astro integration that writes the auto-import type definitions file, and a
67
+ * remark plugin factory for injecting shortcode imports into every MDX file.
68
+ *
69
+ * Replaces `astro-auto-import`, which triggers a deprecation warning in Astro 7.
70
+ *
71
+ * @example
72
+ * ```ts
73
+ * // astro.ts
74
+ * const { integration, plugin } = chassisAutoImport({ docsPath: getDocsFsPath(), modulesPath: process.cwd() })
75
+ *
76
+ * // astro.config.ts
77
+ * markdown: getDocsMarkdownConfig({ remarkPlugins: [plugin()] })
78
+ * integrations: [integration()]
79
+ * ```
80
+ */
81
+ export function chassisAutoImport(options: ChassisAutoImportOptions): {
82
+ /** Astro integration that writes `src/types/auto-import.d.ts`. */
83
+ integration: () => AstroIntegration
84
+ /** Factory for the remark plugin that injects imports into every `.mdx` file. */
85
+ plugin: () => ReturnType<typeof createAutoImportPlugin>
86
+ } {
87
+ const { components, definitions } = scanComponents(options)
88
+ const typeDefinitionsPath = path.join(options.docsPath, 'src/types/auto-import.d.ts')
89
+
90
+ return {
91
+ integration: () => ({
92
+ name: 'chassis-auto-import',
93
+ hooks: {
94
+ 'astro:config:setup': () => {
95
+ fs.writeFileSync(
96
+ typeDefinitionsPath,
97
+ `/**
98
+ * DO NOT EDIT THIS FILE MANUALLY.
99
+ *
100
+ * This file is automatically generated by the Chassis Astro Integration.
101
+ * It contains the type definitions for the components that are auto imported in all pages.
102
+ */
103
+ export declare global {
104
+ ${definitions.join('\n ')}
105
+ }
106
+ `
107
+ )
108
+ }
109
+ }
110
+ }),
111
+ plugin: () => createAutoImportPlugin(components)
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Returns a remark plugin that prepends import statements for the given component
117
+ * paths into every `.mdx` file.
118
+ */
119
+ export function createAutoImportPlugin(imports: string[]) {
120
+ const importsNode = buildImportsNode(imports)
121
+
122
+ return function remarkAutoImport() {
123
+ return function (tree: any, vfile: any) {
124
+ if (!vfile.basename?.endsWith('.md')) {
125
+ tree.children.unshift(importsNode)
126
+ }
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,95 @@
1
+ import os from 'os'
2
+
3
+ /** Resolved host and port for the local Astro development server. */
4
+ interface DevHost {
5
+ /** Hostname or IP address (e.g. `"localhost"` or a LAN IP when `--host` is passed). */
6
+ host: string
7
+ /** Port number the dev server is listening on. */
8
+ port: number
9
+ }
10
+
11
+ /**
12
+ * Minimum site configuration shape required by `getSiteUrl`.
13
+ * The consuming project's full config object satisfies this interface as long
14
+ * as it exposes a `baseURL` string.
15
+ */
16
+ export interface SiteConfig {
17
+ /** Canonical base URL used as a fallback in production (e.g. `"https://chassis-ui.com/css/"`). */
18
+ baseURL: string
19
+ }
20
+
21
+ /**
22
+ * Resolves the host and port for the local Astro development server.
23
+ *
24
+ * Resolution order for port: `--port` CLI flag → `PORT` env var → `4321` (Astro default).
25
+ * When `--host` is passed, the first non-internal IPv4 network interface address
26
+ * is used as the host so the dev URL is reachable on the LAN.
27
+ */
28
+ function getDevHost(): DevHost {
29
+ const hasHostFlag = process.argv.includes('--host')
30
+ let host = 'localhost'
31
+ let port = 4321
32
+
33
+ const portFlagIndex = process.argv.findIndex((arg) => arg === '--port')
34
+ if (portFlagIndex !== -1 && process.argv[portFlagIndex + 1]) {
35
+ const cliPort = parseInt(process.argv[portFlagIndex + 1], 10)
36
+ if (!isNaN(cliPort) && cliPort > 0 && cliPort < 65536) {
37
+ port = cliPort
38
+ }
39
+ }
40
+
41
+ if (process.env.PORT) {
42
+ const envPort = parseInt(process.env.PORT, 10)
43
+ if (!isNaN(envPort) && envPort > 0 && envPort < 65536) {
44
+ port = envPort
45
+ }
46
+ }
47
+
48
+ if (hasHostFlag) {
49
+ const interfaces = os.networkInterfaces()
50
+ for (const iface of Object.values(interfaces).flat()) {
51
+ if (iface?.family === 'IPv4' && !iface.internal) {
52
+ host = iface.address
53
+ break
54
+ }
55
+ }
56
+ }
57
+
58
+ return { host, port }
59
+ }
60
+
61
+ /**
62
+ * Determines the canonical site URL for the current environment.
63
+ *
64
+ * Resolution order:
65
+ * 1. `SITE_URL` env var — explicit override, takes precedence everywhere.
66
+ * 2. `NODE_ENV=development` — constructs a local `http://` URL from `getDevHost()`.
67
+ * 3. `VERCEL_ENV=production` — uses `config.baseURL` (the path-prefixed canonical
68
+ * URL) rather than `VERCEL_PROJECT_PRODUCTION_URL` (the sub-project custom domain)
69
+ * so sitemaps always point to the correct public URL.
70
+ * 4. `VERCEL_URL` present — Vercel preview/branch deployment; uses the auto-generated
71
+ * `https://` preview URL.
72
+ * 5. Fallback — `config.baseURL` from the project's `config.yml`.
73
+ *
74
+ * @param config - Site configuration providing the `baseURL` fallback.
75
+ */
76
+ export function getSiteUrl(config: SiteConfig): string {
77
+ if (process.env.SITE_URL) {
78
+ return process.env.SITE_URL
79
+ }
80
+
81
+ if (process.env.NODE_ENV === 'development') {
82
+ const { host, port } = getDevHost()
83
+ return `http://${host}:${port}`
84
+ }
85
+
86
+ if (process.env.VERCEL_ENV === 'production') {
87
+ return config.baseURL || `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
88
+ }
89
+
90
+ if (process.env.VERCEL_URL) {
91
+ return `https://${process.env.VERCEL_URL}`
92
+ }
93
+
94
+ return config.baseURL
95
+ }
package/src/libs/toc.ts CHANGED
@@ -1,8 +1,28 @@
1
1
  import type { MarkdownHeading } from 'astro'
2
- // import { getConfig } from './config'
3
2
 
4
- // Generate a tree like structure from a list of headings.
5
- export function generateToc(allHeadings: MarkdownHeading[], config: any) {
3
+ /**
4
+ * Minimum configuration shape required by `generateToc`.
5
+ * The consuming project's full config object satisfies this interface as long as
6
+ * it contains a `toc` section with `min` and `max` depth values.
7
+ */
8
+ export interface TocConfig {
9
+ toc: {
10
+ /** Minimum heading depth to include (e.g. `2` to start at `<h2>`). */
11
+ min: number
12
+ /** Maximum heading depth to include (e.g. `4` to stop at `<h4>`). */
13
+ max: number
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Generates a nested tree of TOC entries from a flat list of Markdown headings.
19
+ *
20
+ * Headings outside the depth range defined in `config.toc` are excluded. When
21
+ * a heading skips one or more levels (e.g. an `<h4>` follows an `<h2>` with no
22
+ * `<h3>` in between) it is attached to the deepest available parent rather than
23
+ * being silently dropped.
24
+ */
25
+ export function generateToc(allHeadings: MarkdownHeading[], config: TocConfig): TocEntry[] {
6
26
  const headings = allHeadings.filter(
7
27
  (heading) => heading.depth >= config.toc.min && heading.depth <= config.toc.max
8
28
  )
@@ -29,14 +49,28 @@ export function generateToc(allHeadings: MarkdownHeading[], config: any) {
29
49
  return toc
30
50
  }
31
51
 
52
+ /**
53
+ * Walks down the TOC tree to find the children array at the given relative
54
+ * `depth` below `entry`. If an intermediate level is missing (skipped heading),
55
+ * falls back to the deepest available children array so the heading is not lost.
56
+ */
32
57
  function getEntryChildrenAtDepth(entry: TocEntry, depth: number): TocEntry['children'] {
33
58
  if (!entry) {
34
59
  return []
35
60
  }
36
61
 
37
- return depth === 1
38
- ? entry.children
39
- : getEntryChildrenAtDepth(entry.children[entry.children.length - 1], depth - 1)
62
+ if (depth === 1) {
63
+ return entry.children
64
+ }
65
+
66
+ const lastChild = entry.children[entry.children.length - 1]
67
+
68
+ if (!lastChild) {
69
+ // Heading skipped a level — attach to the current entry's children instead.
70
+ return entry.children
71
+ }
72
+
73
+ return getEntryChildrenAtDepth(lastChild, depth - 1)
40
74
  }
41
75
 
42
76
  export interface TocEntry extends MarkdownHeading {