reallysimpledocs 1.0.0-beta.1 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,6 +61,16 @@ Welcome to the docs.
61
61
 
62
62
  RSD injects docs pages, Lunr search, per-page Markdown exports, `llms.txt`, and `llms-full.txt`.
63
63
 
64
+ Use `.md` for normal pages and `.mdx` when a page needs Astro components:
65
+
66
+ ```mdx
67
+ import { Callout, Preview } from "reallysimpledocs/components";
68
+
69
+ # Button
70
+
71
+ <Callout type="tip">MDX is optional. Plain Markdown stays the default.</Callout>
72
+ ```
73
+
64
74
  ## Use Basecoat on the rest of the site
65
75
 
66
76
  RSD styles and scripts its generated docs pages. For custom Astro pages outside the docs route, use Basecoat directly in your own layout.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reallysimpledocs",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.0.0-beta.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.js",
@@ -20,6 +20,7 @@
20
20
  "check": "npm run build && npm pack --dry-run && npm run smoke:consumer"
21
21
  },
22
22
  "dependencies": {
23
+ "@astrojs/mdx": "^6.0.3",
23
24
  "@tailwindcss/vite": "4.1.17",
24
25
  "astro": "^6.4.6",
25
26
  "github-slugger": "^2.0.0",
@@ -29,11 +30,11 @@
29
30
  "shiki": "^4.1.0"
30
31
  },
31
32
  "peerDependencies": {
32
- "basecoat-css": "^1.0.0-beta.1",
33
+ "basecoat-css": "^1.0.0-beta.2",
33
34
  "tailwindcss": ">=4.1.17 <5"
34
35
  },
35
36
  "devDependencies": {
36
- "basecoat-css": "^1.0.0-beta.1",
37
+ "basecoat-css": "1.0.0-beta.2",
37
38
  "tailwindcss": "4.1.17"
38
39
  },
39
40
  "overrides": {
@@ -1,6 +1,7 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import mdx from "@astrojs/mdx";
4
5
  import tailwindcss from "@tailwindcss/vite";
5
6
 
6
7
  const basecoatStyles = new Set(["vega", "nova", "maia", "lyra", "mira", "luma", "sera", "rhea"]);
@@ -23,6 +24,28 @@ const cssPath = (relativePath) => fileURLToPath(new URL(`../css/${relativePath}`
23
24
 
24
25
  const normalizeArray = (value) => (Array.isArray(value) ? value : value ? [value] : []);
25
26
 
27
+ const normalizeImportPath = (filePath) => filePath.replaceAll(path.sep, "/");
28
+
29
+ function collectMdxModules(docsDir) {
30
+ if (!fs.existsSync(docsDir)) return {};
31
+ const modules = {};
32
+ const walk = (dir) => {
33
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
34
+ const fullPath = path.join(dir, entry.name);
35
+ if (entry.isDirectory()) {
36
+ walk(fullPath);
37
+ continue;
38
+ }
39
+ if (!entry.isFile() || !entry.name.endsWith(".mdx")) continue;
40
+ const relative = path.relative(docsDir, fullPath).replaceAll(path.sep, "/");
41
+ const slug = relative.replace(/\.mdx$/, "");
42
+ modules[slug] = normalizeImportPath(fullPath);
43
+ }
44
+ };
45
+ walk(docsDir);
46
+ return modules;
47
+ }
48
+
26
49
  export default function reallySimpleDocs(options = {}) {
27
50
  const style = options.style || "vega";
28
51
  if (!basecoatStyles.has(style)) {
@@ -47,13 +70,15 @@ export default function reallySimpleDocs(options = {}) {
47
70
  const root = fileURLToPath(config.root);
48
71
  const generatedDir = path.join(root, ".astro", "reallysimpledocs");
49
72
  const generatedStyles = path.join(generatedDir, "styles.css");
73
+ const docsDir = path.resolve(root, normalizedOptions.docsDir);
50
74
  const virtualConfig = {
51
75
  ...normalizedOptions,
52
76
  root,
53
- docsDir: path.resolve(root, normalizedOptions.docsDir),
77
+ docsDir,
54
78
  siteFile: normalizedOptions.siteFile ? path.resolve(root, normalizedOptions.siteFile) : null,
55
79
  routeBase: normalizeRouteBase(normalizedOptions.routeBase),
56
80
  };
81
+ const mdxModules = collectMdxModules(docsDir);
57
82
  const resolveUserId = (id) => (id?.startsWith(".") ? path.resolve(root, id) : id);
58
83
  const components = {
59
84
  SidebarHeader:
@@ -71,7 +96,7 @@ export default function reallySimpleDocs(options = {}) {
71
96
  `@import ${JSON.stringify(cssPath("overrides.css"))};`,
72
97
  ...normalizedOptions.customCss.map((css) => `@import ${JSON.stringify(resolveUserId(css))};`),
73
98
  `@source ${JSON.stringify(runtimePath("**/*.{astro,js,ts}"))};`,
74
- `@source ${JSON.stringify(path.join(virtualConfig.docsDir, "**/*.md"))};`,
99
+ `@source ${JSON.stringify(path.join(virtualConfig.docsDir, "**/*.{md,mdx}"))};`,
75
100
  "",
76
101
  ].join("\n"),
77
102
  );
@@ -98,6 +123,7 @@ export default function reallySimpleDocs(options = {}) {
98
123
  });
99
124
 
100
125
  updateConfig({
126
+ integrations: [mdx()],
101
127
  vite: {
102
128
  build: {
103
129
  cssMinify: false,
@@ -108,6 +134,7 @@ export default function reallySimpleDocs(options = {}) {
108
134
  name: "reallysimpledocs-virtual-modules",
109
135
  resolveId(id) {
110
136
  if (id === "virtual:reallysimpledocs/config") return "\0virtual:reallysimpledocs/config";
137
+ if (id === "virtual:reallysimpledocs/mdx-pages") return "\0virtual:reallysimpledocs/mdx-pages";
111
138
  if (id === "virtual:reallysimpledocs/styles.css") return generatedStyles;
112
139
  if (id === "virtual:reallysimpledocs/components/SidebarHeader") {
113
140
  return "\0virtual:reallysimpledocs/components/SidebarHeader";
@@ -116,6 +143,22 @@ export default function reallySimpleDocs(options = {}) {
116
143
  },
117
144
  load(id) {
118
145
  if (id === "\0virtual:reallysimpledocs/config") return `export default ${JSON.stringify(virtualConfig)};`;
146
+ if (id === "\0virtual:reallysimpledocs/mdx-pages") {
147
+ const imports = Object.entries(mdxModules)
148
+ .map(([slug, filePath], index) => `import * as mdx${index} from ${JSON.stringify(filePath)};`)
149
+ .join("\n");
150
+ const moduleMap = Object.fromEntries(Object.keys(mdxModules).map((slug, index) => [slug, `mdx${index}`]));
151
+ return `${imports}
152
+ const modules = {
153
+ ${Object.entries(moduleMap)
154
+ .map(([slug, name]) => ` ${JSON.stringify(slug)}: ${name},`)
155
+ .join("\n")}
156
+ };
157
+ export async function getMdxPage(slug) {
158
+ return modules[slug || "index"] || null;
159
+ }
160
+ `;
161
+ }
119
162
  if (id === "\0virtual:reallysimpledocs/components/SidebarHeader") {
120
163
  return `export { default } from ${JSON.stringify(resolveUserId(components.SidebarHeader))};`;
121
164
  }
@@ -2,3 +2,14 @@ export { default as DocsLayout } from "../runtime/components/DocsLayout.astro";
2
2
  export { default as CommandDialog } from "../runtime/components/CommandDialog.astro";
3
3
  export { default as DropdownMenu } from "../runtime/components/DropdownMenu.astro";
4
4
  export { default as Sidebar } from "../runtime/components/Sidebar.astro";
5
+ export { default as Badge } from "../runtime/components/mdx/Badge.astro";
6
+ export { default as Callout } from "../runtime/components/mdx/Callout.astro";
7
+ export { default as Card } from "../runtime/components/mdx/Card.astro";
8
+ export { default as CardGrid } from "../runtime/components/mdx/CardGrid.astro";
9
+ export { default as Code } from "../runtime/components/mdx/Code.astro";
10
+ export { default as CodeGroup } from "../runtime/components/mdx/CodeGroup.astro";
11
+ export { default as LinkButton } from "../runtime/components/mdx/LinkButton.astro";
12
+ export { default as Preview } from "../runtime/components/mdx/Preview.astro";
13
+ export { default as Step } from "../runtime/components/mdx/Step.astro";
14
+ export { default as Steps } from "../runtime/components/mdx/Steps.astro";
15
+ export { default as Tabs } from "../runtime/components/mdx/Tabs.astro";
@@ -20,6 +20,9 @@
20
20
  > h3 {
21
21
  @apply text-lg sm:text-xl lg:mt-8;
22
22
  }
23
+ > h1:first-child {
24
+ @apply hidden;
25
+ }
23
26
  p > a,
24
27
  li > a,
25
28
  dt > a,
@@ -38,10 +41,7 @@
38
41
  @apply block min-w-full w-max px-4 py-3 text-[13px];
39
42
  }
40
43
  pre:has(> code) {
41
- @apply relative max-w-full overflow-x-auto;
42
- }
43
- pre:has(> code) > button {
44
- @apply flex items-center justify-center size-6 absolute right-2.5 top-2.5 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] rounded-md hover:bg-accent dark:hover:bg-accent/50 text-muted-foreground hover:text-foreground [&_svg]:size-4;
44
+ @apply max-w-full overflow-x-auto;
45
45
  }
46
46
  blockquote {
47
47
  @apply border-l-4 border-border pl-4;
@@ -140,18 +140,194 @@ html.dark .shiki span {
140
140
  @apply sm:max-w-2xl;
141
141
  }
142
142
 
143
- .command [data-rsd-search-result] {
143
+ [data-search] .command a[role='menuitem'] {
144
144
  @apply flex-col items-start gap-1;
145
145
  }
146
146
 
147
- .command [data-rsd-search-title] {
147
+ [data-search] .command a[role='menuitem'] > strong {
148
148
  @apply font-medium;
149
149
  }
150
150
 
151
- .command [data-rsd-search-snippet] {
151
+ [data-search] .command a[role='menuitem'] > span {
152
152
  @apply text-muted-foreground line-clamp-2 text-xs font-normal leading-snug;
153
153
  }
154
154
 
155
155
  .command mark {
156
156
  @apply bg-search-highlight/50 text-foreground rounded-sm px-0.5 dark:bg-search-highlight/25;
157
157
  }
158
+
159
+ .docs-card-link {
160
+ @apply transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50;
161
+ }
162
+
163
+ .alert[data-variant='warning'] > svg,
164
+ .alert[data-variant='warning'] > h3,
165
+ .alert[data-variant='warning'] > section {
166
+ @apply text-amber-700 dark:text-amber-300;
167
+ }
168
+
169
+ .alert-destructive[data-variant='danger'] > svg,
170
+ .alert-destructive[data-variant='danger'] > h3,
171
+ .alert-destructive[data-variant='danger'] > section {
172
+ @apply text-destructive;
173
+ }
174
+
175
+ .code-block {
176
+ @apply relative my-6 max-w-full overflow-hidden rounded-lg;
177
+ }
178
+
179
+ .code-block > header,
180
+ .code-group > header,
181
+ .preview > header {
182
+ @apply flex h-10 items-center gap-2 border-b border-border/50 bg-muted px-3 text-sm font-medium dark:bg-muted/50;
183
+ }
184
+
185
+ .code-block > header > :first-child,
186
+ .code-group > header > :first-child,
187
+ .preview > header > :first-child {
188
+ @apply mr-auto;
189
+ }
190
+
191
+ .code-block > pre {
192
+ @apply my-0 max-w-full overflow-x-auto rounded-none border-0;
193
+ }
194
+
195
+ .code-block > pre > code {
196
+ @apply block min-w-full w-max px-4 py-3 text-[13px];
197
+ }
198
+
199
+ .code-block > .shiki {
200
+ @apply my-0 rounded-none border-0;
201
+ }
202
+
203
+ .code-block > button {
204
+ @apply absolute right-2.5 top-2.5;
205
+ }
206
+
207
+ .code-block button,
208
+ .code-group button[aria-label='Copy code'],
209
+ .preview button[aria-label='Copy code'] {
210
+ @apply flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:hover:bg-accent/50 [&_svg]:size-4;
211
+ }
212
+
213
+ .code-block > header > button,
214
+ .code-group > header > button,
215
+ .preview > header > button {
216
+ @apply ml-auto;
217
+ }
218
+
219
+ .code-group {
220
+ @apply relative my-6 max-w-full overflow-hidden rounded-lg;
221
+ }
222
+
223
+ .code-group > header > .tabs {
224
+ @apply my-0 min-w-0 max-w-full gap-0;
225
+ }
226
+
227
+ .code-group > header > .tabs > nav {
228
+ @apply h-full justify-start rounded-none bg-transparent p-0;
229
+ }
230
+
231
+ .code-group > [role='tabpanel'] {
232
+ @apply border-t-0;
233
+ }
234
+
235
+ .code-group .code-block {
236
+ @apply my-0 min-w-0 rounded-none;
237
+ }
238
+
239
+ .code-group .code-block > header {
240
+ @apply hidden;
241
+ }
242
+
243
+ .preview {
244
+ @apply relative my-6 max-w-full overflow-hidden rounded-lg border border-border/50 bg-muted dark:bg-muted/50;
245
+ }
246
+
247
+ .preview > header > .tabs {
248
+ @apply my-0 min-w-0 max-w-full gap-0;
249
+ }
250
+
251
+ .preview > header > .tabs > nav {
252
+ @apply h-full justify-start rounded-none bg-transparent p-0;
253
+ }
254
+
255
+ .preview > [role='tabpanel'] {
256
+ @apply border-t-0;
257
+ }
258
+
259
+ .preview > [data-code-panel] .code-block {
260
+ @apply my-0 min-w-0 rounded-none;
261
+ }
262
+
263
+ .preview > header > button[aria-label='Copy code'] {
264
+ display: none;
265
+ }
266
+
267
+ .preview:has(> [data-code-panel]:not([hidden])) > header > button[aria-label='Copy code'] {
268
+ display: flex;
269
+ }
270
+
271
+ .preview > [data-preview-panel] > div {
272
+ @apply flex min-h-64 w-full items-center justify-center bg-muted p-6 dark:bg-muted/50;
273
+ }
274
+
275
+ .steps {
276
+ @apply mb-12 mt-6 md:ml-4 md:border-l md:pl-8;
277
+ counter-reset: step;
278
+ }
279
+
280
+ .steps > section {
281
+ counter-increment: step;
282
+ }
283
+
284
+ .steps > section > h3 {
285
+ @apply relative mt-12 scroll-m-28 text-lg font-medium tracking-tight;
286
+ }
287
+
288
+ .steps > section:first-child > h3 {
289
+ @apply mt-0;
290
+ }
291
+
292
+ .steps > section > h3::before {
293
+ @apply mr-4 inline-flex size-7 items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground;
294
+ content: counter(step);
295
+ }
296
+
297
+ @media (width >= 48rem) {
298
+ .steps > section > h3::before {
299
+ position: absolute;
300
+ left: 0;
301
+ top: 0;
302
+ margin-right: 0;
303
+ transform: translateX(-2.875rem);
304
+ }
305
+ }
306
+
307
+ .steps > section > * + * {
308
+ @apply mt-6;
309
+ }
310
+
311
+ .steps > section > h3 + * {
312
+ @apply mt-4;
313
+ }
314
+
315
+ .steps > section > * {
316
+ @apply leading-relaxed;
317
+ }
318
+
319
+ .steps > section > *:first-child {
320
+ @apply mt-0;
321
+ }
322
+
323
+ .steps > section > *:last-child {
324
+ @apply mb-0;
325
+ }
326
+
327
+ .prose > .alert,
328
+ .prose > .alert-destructive,
329
+ .prose > .card,
330
+ .prose > .grid,
331
+ .prose > .tabs {
332
+ @apply my-6;
333
+ }
@@ -4,7 +4,7 @@ import { renderCommandDialog } from "../lib/macros.js";
4
4
  const { indexUrl } = Astro.props;
5
5
  ---
6
6
 
7
- <div data-rsd-command-search data-index-url={indexUrl}>
7
+ <div data-search data-index-url={indexUrl}>
8
8
  <Fragment
9
9
  set:html={renderCommandDialog({
10
10
  id: "command-search",
@@ -18,7 +18,7 @@ const { indexUrl } = Astro.props;
18
18
  <script>
19
19
  import lunr from "lunr";
20
20
 
21
- const root = document.querySelector("[data-rsd-command-search]");
21
+ const root = document.querySelector("[data-search]");
22
22
  const indexUrl = root?.getAttribute("data-index-url");
23
23
  const dialog = document.getElementById("command-search");
24
24
  const command = dialog?.querySelector(".command");
@@ -178,9 +178,9 @@ const { indexUrl } = Astro.props;
178
178
  ${items
179
179
  .map((item, index) => {
180
180
  const view = resultView(item, query);
181
- return `<a id="command-search-result-${index}" role="menuitem" data-force data-rsd-search-result href="${escapeHtml(view.path)}">
182
- <span data-rsd-search-title>${view.title}</span>
183
- ${view.snippet ? `<span data-rsd-search-snippet>${view.snippet}</span>` : ""}
181
+ return `<a id="command-search-result-${index}" role="menuitem" data-force href="${escapeHtml(view.path)}">
182
+ <strong>${view.title}</strong>
183
+ ${view.snippet ? `<span>${view.snippet}</span>` : ""}
184
184
  </a>`;
185
185
  })
186
186
  .join("")}
@@ -0,0 +1,15 @@
1
+ ---
2
+ const { variant = "secondary", class: className = "" } = Astro.props;
3
+ const variants = {
4
+ primary: "badge-primary",
5
+ secondary: "badge-secondary",
6
+ destructive: "badge-destructive",
7
+ outline: "badge-outline",
8
+ ghost: "badge-ghost",
9
+ link: "badge-link",
10
+ };
11
+ ---
12
+
13
+ <span class:list={[variants[variant] || "badge-secondary", className]}>
14
+ <slot />
15
+ </span>
@@ -0,0 +1,54 @@
1
+ ---
2
+ import CircleAlert from "lucide-static/icons/circle-alert.svg?raw";
3
+ import Info from "lucide-static/icons/info.svg?raw";
4
+ import Lightbulb from "lucide-static/icons/lightbulb.svg?raw";
5
+ import TriangleAlert from "lucide-static/icons/triangle-alert.svg?raw";
6
+ import * as lucideIcons from "lucide-static";
7
+ import { addSvgClass } from "../../lib/html.js";
8
+
9
+ const { type, variant, title, icon: showIcon = true, class: className = "" } = Astro.props;
10
+ const normalizedType = String(variant || type || "note").toLowerCase();
11
+ const calloutType = normalizedType === "note" ? "info" : normalizedType;
12
+ const destructive = calloutType === "danger";
13
+ const labels = {
14
+ note: "Note",
15
+ info: "Info",
16
+ tip: "Tip",
17
+ warning: "Warning",
18
+ danger: "Danger",
19
+ };
20
+ const icons = {
21
+ note: Info,
22
+ info: Info,
23
+ tip: Lightbulb,
24
+ warning: TriangleAlert,
25
+ danger: CircleAlert,
26
+ };
27
+
28
+ function resolveNamedIcon(name) {
29
+ if (!name || typeof name !== "string") return "";
30
+ const trimmed = name.trim();
31
+ if (!trimmed) return "";
32
+ if (trimmed.startsWith("<svg")) return trimmed;
33
+ const pascalName = trimmed.includes("-")
34
+ ? trimmed
35
+ .split("-")
36
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
37
+ .join("")
38
+ : trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
39
+ return lucideIcons[pascalName] || "";
40
+ }
41
+
42
+ const iconSource =
43
+ showIcon === false ? "" : typeof showIcon === "string" ? resolveNamedIcon(showIcon) : icons[normalizedType] || Info;
44
+ const icon = addSvgClass(iconSource, "size-4");
45
+ const heading = title || labels[normalizedType] || labels.note;
46
+ ---
47
+
48
+ <div class:list={[destructive ? "alert-destructive" : "alert", className]} data-variant={calloutType}>
49
+ {icon && <Fragment set:html={icon} />}
50
+ <h3>{heading}</h3>
51
+ <section>
52
+ <slot />
53
+ </section>
54
+ </div>
@@ -0,0 +1,13 @@
1
+ ---
2
+ const { title, description, href, class: className = "" } = Astro.props;
3
+ const Tag = href ? "a" : "article";
4
+ ---
5
+
6
+ <Tag href={href} class:list={["card w-full no-underline", href && "docs-card-link", className]}>
7
+ {(title || description) && (
8
+ <header>
9
+ {title && <h3>{title}</h3>}
10
+ {description && <p>{description}</p>}
11
+ </header>
12
+ )}
13
+ </Tag>
@@ -0,0 +1,7 @@
1
+ ---
2
+ const { class: className = "" } = Astro.props;
3
+ ---
4
+
5
+ <div class:list={["grid w-full gap-4 sm:grid-cols-2", className]}>
6
+ <slot />
7
+ </div>
@@ -0,0 +1,19 @@
1
+ ---
2
+ import { codeToHtml } from "shiki";
3
+
4
+ const { code = "", lang = "text", title, class: className = "" } = Astro.props;
5
+ const highlightLang = String(lang || "text").toLowerCase() === "mdx" ? "tsx" : String(lang || "text");
6
+ const html = await codeToHtml(String(code || ""), {
7
+ lang: highlightLang,
8
+ themes: {
9
+ light: "github-light",
10
+ dark: "github-dark",
11
+ },
12
+ defaultColor: false,
13
+ });
14
+ ---
15
+
16
+ <div class:list={["code-block", className]}>
17
+ {title && <header><span>{title}</span></header>}
18
+ <Fragment set:html={html} />
19
+ </div>
@@ -0,0 +1,56 @@
1
+ ---
2
+ import Code from "./Code.astro";
3
+
4
+ const { tabs = [], defaultTab, class: className = "" } = Astro.props;
5
+ const id = `code-group-${Math.random().toString(36).slice(2)}`;
6
+ const normalizedTabs = tabs.map((tab, index) => ({
7
+ ...tab,
8
+ value: tab.value || tab.label || String(index),
9
+ lang: tab.lang || "text",
10
+ code: tab.code || "",
11
+ }));
12
+ const defaultValue = defaultTab || normalizedTabs[0]?.value;
13
+ ---
14
+
15
+ <div class:list={["code-group", className]}>
16
+ <header>
17
+ <div class="tabs">
18
+ <nav role="tablist" aria-orientation="horizontal" data-variant="line">
19
+ {
20
+ normalizedTabs.map((tab, index) => {
21
+ const selected = tab.value === defaultValue;
22
+ return (
23
+ <button
24
+ type="button"
25
+ role="tab"
26
+ id={`${id}-tab-${index + 1}`}
27
+ aria-controls={`${id}-panel-${index + 1}`}
28
+ aria-selected={selected ? "true" : "false"}
29
+ tabindex={selected ? "0" : "-1"}
30
+ >
31
+ {tab.label}
32
+ </button>
33
+ );
34
+ })
35
+ }
36
+ </nav>
37
+ </div>
38
+ </header>
39
+ {
40
+ normalizedTabs.map((tab, index) => {
41
+ const selected = tab.value === defaultValue;
42
+ return (
43
+ <div
44
+ role="tabpanel"
45
+ id={`${id}-panel-${index + 1}`}
46
+ aria-labelledby={`${id}-tab-${index + 1}`}
47
+ tabindex="-1"
48
+ aria-selected={selected ? "true" : "false"}
49
+ hidden={!selected}
50
+ >
51
+ <Code code={tab.code} lang={tab.lang} />
52
+ </div>
53
+ );
54
+ })
55
+ }
56
+ </div>
@@ -0,0 +1,14 @@
1
+ ---
2
+ const { href, variant = "default", class: className = "", target, rel } = Astro.props;
3
+ const variants = {
4
+ default: "btn",
5
+ secondary: "btn-secondary",
6
+ outline: "btn-outline",
7
+ ghost: "btn-ghost",
8
+ link: "btn-link",
9
+ };
10
+ ---
11
+
12
+ <a href={href} target={target} rel={rel} class:list={[variants[variant] || "btn", className]}>
13
+ <slot />
14
+ </a>
@@ -0,0 +1,58 @@
1
+ ---
2
+ import Code from "./Code.astro";
3
+
4
+ const { code = "", lang = "html", title = "Preview", class: className = "" } = Astro.props;
5
+ const id = `preview-${Math.random().toString(36).slice(2)}`;
6
+ ---
7
+
8
+ <section class="preview">
9
+ <header>
10
+ <div class="tabs">
11
+ <nav role="tablist" aria-orientation="horizontal" data-variant="line">
12
+ <button
13
+ type="button"
14
+ role="tab"
15
+ id={`${id}-preview-tab`}
16
+ aria-controls={`${id}-preview-panel`}
17
+ aria-selected="true"
18
+ tabindex="0"
19
+ >
20
+ {title}
21
+ </button>
22
+ <button
23
+ type="button"
24
+ role="tab"
25
+ id={`${id}-code-tab`}
26
+ aria-controls={`${id}-code-panel`}
27
+ aria-selected="false"
28
+ tabindex="-1"
29
+ >
30
+ Code
31
+ </button>
32
+ </nav>
33
+ </div>
34
+ </header>
35
+ <section
36
+ role="tabpanel"
37
+ data-preview-panel
38
+ id={`${id}-preview-panel`}
39
+ aria-labelledby={`${id}-preview-tab`}
40
+ tabindex="-1"
41
+ aria-selected="true"
42
+ >
43
+ <div class={className}>
44
+ <slot />
45
+ </div>
46
+ </section>
47
+ <section
48
+ role="tabpanel"
49
+ data-code-panel
50
+ id={`${id}-code-panel`}
51
+ aria-labelledby={`${id}-code-tab`}
52
+ tabindex="-1"
53
+ aria-selected="false"
54
+ hidden
55
+ >
56
+ <Code code={code} lang={lang} />
57
+ </section>
58
+ </section>
@@ -0,0 +1,8 @@
1
+ ---
2
+ const { title, class: className = "" } = Astro.props;
3
+ ---
4
+
5
+ <section role="listitem" class={className}>
6
+ {title && <h3>{title}</h3>}
7
+ <slot />
8
+ </section>
@@ -0,0 +1,7 @@
1
+ ---
2
+ const { class: className = "" } = Astro.props;
3
+ ---
4
+
5
+ <div role="list" class:list={["steps", className]}>
6
+ <slot />
7
+ </div>
@@ -0,0 +1,52 @@
1
+ ---
2
+ const { tabs = [], defaultTab, orientation = "horizontal", variant, class: className = "" } = Astro.props;
3
+ const id = `tabs-${Math.random().toString(36).slice(2)}`;
4
+ const normalizedTabs = tabs.map((tab, index) => ({
5
+ ...tab,
6
+ slot: tab.slot || String(index),
7
+ value: tab.value || tab.slot || String(index),
8
+ }));
9
+ const defaultValue = defaultTab || normalizedTabs.find((tab) => !tab.disabled)?.value || normalizedTabs[0]?.value;
10
+ ---
11
+
12
+ <div class:list={["tabs", className]}>
13
+ <nav role="tablist" aria-orientation={orientation} data-variant={variant}>
14
+ {
15
+ normalizedTabs.map((tab, index) => {
16
+ const selected = tab.value === defaultValue;
17
+ return (
18
+ <button
19
+ type="button"
20
+ role="tab"
21
+ id={`${id}-tab-${index + 1}`}
22
+ aria-controls={`${id}-panel-${index + 1}`}
23
+ aria-selected={selected ? "true" : "false"}
24
+ tabindex={selected && !tab.disabled ? "0" : "-1"}
25
+ disabled={tab.disabled}
26
+ >
27
+ {tab.label}
28
+ </button>
29
+ );
30
+ })
31
+ }
32
+ </nav>
33
+ {
34
+ await Promise.all(
35
+ normalizedTabs.map(async (tab, index) => {
36
+ const selected = tab.value === defaultValue;
37
+ return (
38
+ <div
39
+ role="tabpanel"
40
+ id={`${id}-panel-${index + 1}`}
41
+ aria-labelledby={`${id}-tab-${index + 1}`}
42
+ tabindex="-1"
43
+ aria-selected={selected ? "true" : "false"}
44
+ hidden={!selected}
45
+ >
46
+ <Fragment set:html={await Astro.slots.render(tab.slot)} />
47
+ </div>
48
+ );
49
+ }),
50
+ )
51
+ }
52
+ </div>
@@ -19,7 +19,16 @@ export const fallbackLabelFromSlug = (slug) => {
19
19
  return base.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
20
20
  };
21
21
 
22
- export const slugToFile = (slug) => path.join(...String(slug).split("/")) + ".md";
22
+ export const slugToFile = (slug, extension = ".md") => path.join(...String(slug).split("/")) + extension;
23
+
24
+ export function resolveDocFile(config, slug) {
25
+ for (const extension of [".md", ".mdx"]) {
26
+ const file = slugToFile(slug, extension);
27
+ const filePath = path.join(config.docsDir, file);
28
+ if (fs.existsSync(filePath)) return { file, filePath, extension };
29
+ }
30
+ throw new Error(`Missing docs page for slug "${slug}". Expected .md or .mdx in ${config.docsDir}.`);
31
+ }
23
32
 
24
33
  export function flattenMenuSlugs(menu) {
25
34
  const out = [];
@@ -44,13 +53,13 @@ export function flattenMenuSlugs(menu) {
44
53
  export function getPages(config) {
45
54
  const manifest = getManifest(config);
46
55
  return flattenMenuSlugs(manifest.menu || []).map((slug) => {
47
- const file = slugToFile(slug);
48
- const filePath = path.join(config.docsDir, file);
56
+ const { file, filePath, extension } = resolveDocFile(config, slug);
49
57
  const source = fs.readFileSync(filePath, "utf8");
50
58
  const parsed = parseDocMarkdown(source, fallbackLabelFromSlug(slug));
51
59
  return {
52
60
  slug,
53
61
  file,
62
+ extension,
54
63
  path: routePath(config.routeBase, slug),
55
64
  markdownPath: markdownPath(config.routeBase, slug),
56
65
  title: parsed.title,
@@ -113,6 +122,20 @@ export async function renderDoc(config, page) {
113
122
  return { page, html, headings };
114
123
  }
115
124
 
125
+ export function getDocHeadings(config, page) {
126
+ const parsed = parseDocMarkdown(getDocMarkdown(config, page), page.title);
127
+ const headings = [];
128
+ const slugger = new GithubSlugger();
129
+ const tokens = marked.lexer(parsed.body);
130
+ tokens.forEach((token) => {
131
+ if (token.type !== "heading" || token.depth <= 1 || token.depth >= 4) return;
132
+ const text = plainText(token.tokens || []).trim();
133
+ if (!text) return;
134
+ headings.push({ depth: token.depth, id: slugger.slug(text), text });
135
+ });
136
+ return headings;
137
+ }
138
+
116
139
  export function renderNunjucksCompat(content) {
117
140
  return String(content).replace(
118
141
  /\{%\s*lucide\s+["']([^"']+)["'](?:\s*,\s*[^%]+)?\s*%\}/g,
@@ -156,7 +179,7 @@ export function buildMenus(config, currentSlug) {
156
179
  type: "item",
157
180
  icon,
158
181
  url: page.path,
159
- label: page.title,
182
+ label: item.label || page.title,
160
183
  current: page.slug === currentSlug,
161
184
  keywords: page.title,
162
185
  };
@@ -231,7 +254,7 @@ export function getPageItemSlug(item) {
231
254
  }
232
255
 
233
256
  function parseDocMarkdown(source, fallbackTitle) {
234
- const content = String(source || "").trimStart();
257
+ const content = normalizeDocSource(source);
235
258
  const tokens = marked.lexer(content);
236
259
  const first = tokens[0];
237
260
 
@@ -249,7 +272,18 @@ function parseDocMarkdown(source, fallbackTitle) {
249
272
  }
250
273
 
251
274
  function markdownPlainText(markdown) {
252
- return tokensText(marked.lexer(renderNunjucksCompat(markdown)));
275
+ return tokensText(marked.lexer(renderNunjucksCompat(normalizeDocSource(markdown))));
276
+ }
277
+
278
+ function normalizeDocSource(source) {
279
+ const withoutFrontmatter = String(source || "")
280
+ .trimStart()
281
+ .replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "");
282
+ const firstHeading = withoutFrontmatter.search(/^#\s+/m);
283
+ if (firstHeading > 0) return withoutFrontmatter.slice(firstHeading).trimStart();
284
+ return withoutFrontmatter
285
+ .replace(/^(\s*(?:import|export)\s+[^\n]*\n)+/m, "")
286
+ .trimStart();
253
287
  }
254
288
 
255
289
  async function highlightCode(tokens) {
@@ -296,6 +330,7 @@ async function highlightToken(token) {
296
330
  function normalizeLanguage(lang) {
297
331
  const value = lang?.trim().split(/\s+/)[0].toLowerCase() || "text";
298
332
  if (value === "njk") return "html";
333
+ if (value === "mdx") return "tsx";
299
334
  if (value === "sh" || value === "shell") return "bash";
300
335
  return value;
301
336
  }
@@ -2,12 +2,18 @@ export const stripSlashes = (value) => String(value || "").replace(/^\/+|\/+$/g,
2
2
 
3
3
  export const routePath = (routeBase, slug) => {
4
4
  const base = stripSlashes(routeBase);
5
- const normalizedSlug = slug === "index" ? "" : stripSlashes(slug);
5
+ const strippedSlug = stripSlashes(slug);
6
+ const normalizedSlug =
7
+ strippedSlug === "index" ? "" : strippedSlug.endsWith("/index") ? strippedSlug.slice(0, -"/index".length) : strippedSlug;
6
8
  const parts = [base, normalizedSlug].filter(Boolean);
7
9
  return parts.length ? `/${parts.join("/")}/` : "/";
8
10
  };
9
11
 
10
- export const routeSlug = (slug) => (slug === "index" ? undefined : slug);
12
+ export const routeSlug = (slug) => {
13
+ const strippedSlug = stripSlashes(slug);
14
+ if (strippedSlug === "index") return undefined;
15
+ return strippedSlug.endsWith("/index") ? strippedSlug.slice(0, -"/index".length) : strippedSlug;
16
+ };
11
17
 
12
18
  export const markdownPath = (routeBase, slug) => {
13
19
  const base = stripSlashes(routeBase);
@@ -1,7 +1,8 @@
1
1
  ---
2
2
  import config from "virtual:reallysimpledocs/config";
3
+ import { getMdxPage } from "virtual:reallysimpledocs/mdx-pages";
3
4
  import DocsLayout from "../components/DocsLayout.astro";
4
- import { getPages, renderDoc } from "../lib/docs.js";
5
+ import { getDocHeadings, getPages, renderDoc } from "../lib/docs.js";
5
6
  import { routeSlug } from "../lib/paths.js";
6
7
 
7
8
  export function getStaticPaths() {
@@ -12,9 +13,12 @@ export function getStaticPaths() {
12
13
  }
13
14
 
14
15
  const { page } = Astro.props;
15
- const doc = await renderDoc(config, page);
16
+ const isMdx = page.extension === ".mdx";
17
+ const mdxModule = isMdx ? await getMdxPage(page.slug) : null;
18
+ const MdxContent = mdxModule?.Content || mdxModule?.default;
19
+ const doc = isMdx ? { page, headings: getDocHeadings(config, page), html: "" } : await renderDoc(config, page);
16
20
  ---
17
21
 
18
22
  <DocsLayout page={page} headings={doc.headings}>
19
- <Fragment set:html={doc.html} />
23
+ {MdxContent ? <MdxContent /> : <Fragment set:html={doc.html} />}
20
24
  </DocsLayout>
@@ -19,18 +19,49 @@ const copyText = async (text) => {
19
19
  if (!ok) throw new Error("Copy failed");
20
20
  };
21
21
 
22
+ const activePanelCode = (root) => root.querySelector(':scope > [role="tabpanel"]:not([hidden]) code');
23
+
22
24
  const initCopyCode = (codeBlock) => {
23
25
  const codeEl = codeBlock.querySelector(":scope > code");
24
26
  if (!codeEl) return;
25
27
 
28
+ let target = null;
29
+ let frame = codeBlock.closest(".code-group");
30
+ let getCode = frame ? () => activePanelCode(frame) || codeEl : () => codeEl;
31
+ if (frame) target = frame.querySelector(":scope > header");
32
+
33
+ const preview = codeBlock.closest(".preview");
34
+ const previewCodePanel = preview?.querySelector(":scope > [data-code-panel]");
35
+ if (!frame && previewCodePanel?.contains(codeBlock)) {
36
+ frame = preview;
37
+ target = frame.querySelector(":scope > header");
38
+ getCode = () => codeEl;
39
+ }
40
+
41
+ if (!frame) {
42
+ frame = codeBlock.parentElement?.classList.contains("code-block") ? codeBlock.parentElement : null;
43
+ if (!frame) {
44
+ frame = document.createElement("div");
45
+ frame.className = "code-block";
46
+ codeBlock.parentNode.insertBefore(frame, codeBlock);
47
+ frame.appendChild(codeBlock);
48
+ }
49
+ }
50
+
51
+ target ||= frame.querySelector(":scope > header") || frame;
52
+
53
+ if (target.querySelector(":scope > button[aria-label='Copy code']")) {
54
+ codeBlock.dataset.copyCodeInitialized = true;
55
+ return;
56
+ }
57
+
26
58
  const button = document.createElement("button");
27
59
  button.type = "button";
28
- button.className = "code-block-copy";
29
60
  button.setAttribute("aria-label", "Copy code");
30
61
  button.innerHTML = Copy;
31
62
 
32
63
  button.addEventListener("click", async () => {
33
- const text = (codeEl.textContent || "").replace(/\n$/, "");
64
+ const text = (getCode()?.textContent || "").replace(/\n$/, "");
34
65
  if (!text) return;
35
66
 
36
67
  button.disabled = true;
@@ -47,8 +78,8 @@ const initCopyCode = (codeBlock) => {
47
78
  }, 1200);
48
79
  });
49
80
 
50
- codeBlock.insertBefore(button, codeBlock.firstChild);
81
+ target.appendChild(button);
51
82
  codeBlock.dataset.copyCodeInitialized = true;
52
83
  };
53
84
 
54
- window.basecoat?.register("copy-code", "pre:not([data-copy-code-initialized])", initCopyCode);
85
+ window.basecoat?.register("copy-code", "pre:has(> code):not([data-copy-code-initialized])", initCopyCode);