reallysimpledocs 1.0.0-beta.1 → 1.0.0-beta.3

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  A simple documentation system for Astro, built with **Basecoat** and **Tailwind CSS**.
4
4
 
5
- RSD keeps docs portable: content lives in `docs/`, navigation lives in `docs/docs.json`, and the Astro integration owns the docs UI.
5
+ reallysimpledocs keeps docs portable: content lives in `docs/`, navigation lives in `docs/docs.json`, and the Astro integration owns the docs UI.
6
6
 
7
7
  ## Add to an Astro site
8
8
 
@@ -59,11 +59,19 @@ docs/
59
59
  Welcome to the docs.
60
60
  ```
61
61
 
62
- RSD injects docs pages, Lunr search, per-page Markdown exports, `llms.txt`, and `llms-full.txt`.
62
+ reallysimpledocs injects docs pages, Lunr search, per-page Markdown exports, `llms.txt`, and `llms-full.txt`.
63
+
64
+ Use `.mdx` when a page needs reallysimpledocs components. Common reallysimpledocs components are available by default, so you do not need to import them in each page:
65
+
66
+ ```mdx
67
+ # Button
68
+
69
+ <Callout title="MDX works">MDX is optional. Plain Markdown stays the default.</Callout>
70
+ ```
63
71
 
64
72
  ## Use Basecoat on the rest of the site
65
73
 
66
- RSD styles and scripts its generated docs pages. For custom Astro pages outside the docs route, use Basecoat directly in your own layout.
74
+ reallysimpledocs styles and scripts its generated docs pages. For custom Astro pages outside the docs route, use Basecoat directly in your own layout.
67
75
 
68
76
  Install stays the same:
69
77
 
@@ -93,7 +101,7 @@ import "basecoat-css/all.min";
93
101
  <slot />
94
102
  ```
95
103
 
96
- Use the same Basecoat `style` in your RSD config and your site stylesheet if you want the docs and custom pages to match.
104
+ Use the same Basecoat `style` in your reallysimpledocs config and your site stylesheet if you want the docs and custom pages to match.
97
105
 
98
106
  ## Local development (this repo)
99
107
 
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.3",
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,41 @@ 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
+ const isInsideDir = (dir, file) => {
30
+ const relative = path.relative(dir, file);
31
+ return relative && !relative.startsWith("..") && !path.isAbsolute(relative);
32
+ };
33
+
34
+ const shikiConfig = {
35
+ themes: {
36
+ light: "github-light",
37
+ dark: "github-dark",
38
+ },
39
+ defaultColor: false,
40
+ };
41
+
42
+ function collectMdxModules(docsDir) {
43
+ if (!fs.existsSync(docsDir)) return {};
44
+ const modules = {};
45
+ const walk = (dir) => {
46
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
47
+ const fullPath = path.join(dir, entry.name);
48
+ if (entry.isDirectory()) {
49
+ walk(fullPath);
50
+ continue;
51
+ }
52
+ if (!entry.isFile() || !entry.name.endsWith(".mdx")) continue;
53
+ const relative = path.relative(docsDir, fullPath).replaceAll(path.sep, "/");
54
+ const slug = relative.replace(/\.mdx$/, "");
55
+ modules[slug] = normalizeImportPath(fullPath);
56
+ }
57
+ };
58
+ walk(docsDir);
59
+ return modules;
60
+ }
61
+
26
62
  export default function reallySimpleDocs(options = {}) {
27
63
  const style = options.style || "vega";
28
64
  if (!basecoatStyles.has(style)) {
@@ -47,10 +83,11 @@ export default function reallySimpleDocs(options = {}) {
47
83
  const root = fileURLToPath(config.root);
48
84
  const generatedDir = path.join(root, ".astro", "reallysimpledocs");
49
85
  const generatedStyles = path.join(generatedDir, "styles.css");
86
+ const docsDir = path.resolve(root, normalizedOptions.docsDir);
50
87
  const virtualConfig = {
51
88
  ...normalizedOptions,
52
89
  root,
53
- docsDir: path.resolve(root, normalizedOptions.docsDir),
90
+ docsDir,
54
91
  siteFile: normalizedOptions.siteFile ? path.resolve(root, normalizedOptions.siteFile) : null,
55
92
  routeBase: normalizeRouteBase(normalizedOptions.routeBase),
56
93
  };
@@ -71,7 +108,7 @@ export default function reallySimpleDocs(options = {}) {
71
108
  `@import ${JSON.stringify(cssPath("overrides.css"))};`,
72
109
  ...normalizedOptions.customCss.map((css) => `@import ${JSON.stringify(resolveUserId(css))};`),
73
110
  `@source ${JSON.stringify(runtimePath("**/*.{astro,js,ts}"))};`,
74
- `@source ${JSON.stringify(path.join(virtualConfig.docsDir, "**/*.md"))};`,
111
+ `@source ${JSON.stringify(path.join(virtualConfig.docsDir, "**/*.{md,mdx}"))};`,
75
112
  "",
76
113
  ].join("\n"),
77
114
  );
@@ -98,6 +135,15 @@ export default function reallySimpleDocs(options = {}) {
98
135
  });
99
136
 
100
137
  updateConfig({
138
+ integrations: [
139
+ mdx({
140
+ syntaxHighlight: "shiki",
141
+ shikiConfig,
142
+ }),
143
+ ],
144
+ markdown: {
145
+ shikiConfig,
146
+ },
101
147
  vite: {
102
148
  build: {
103
149
  cssMinify: false,
@@ -106,8 +152,24 @@ export default function reallySimpleDocs(options = {}) {
106
152
  tailwindcss(),
107
153
  {
108
154
  name: "reallysimpledocs-virtual-modules",
155
+ configureServer(server) {
156
+ const invalidateMdxPages = () => {
157
+ const module = server.moduleGraph.getModuleById("\0virtual:reallysimpledocs/mdx-pages");
158
+ if (module) server.moduleGraph.invalidateModule(module);
159
+ server.ws.send({ type: "full-reload" });
160
+ };
161
+
162
+ server.watcher.add(path.join(docsDir, "**/*.mdx"));
163
+ server.watcher.on("add", (file) => {
164
+ if (file.endsWith(".mdx") && isInsideDir(docsDir, file)) invalidateMdxPages();
165
+ });
166
+ server.watcher.on("unlink", (file) => {
167
+ if (file.endsWith(".mdx") && isInsideDir(docsDir, file)) invalidateMdxPages();
168
+ });
169
+ },
109
170
  resolveId(id) {
110
171
  if (id === "virtual:reallysimpledocs/config") return "\0virtual:reallysimpledocs/config";
172
+ if (id === "virtual:reallysimpledocs/mdx-pages") return "\0virtual:reallysimpledocs/mdx-pages";
111
173
  if (id === "virtual:reallysimpledocs/styles.css") return generatedStyles;
112
174
  if (id === "virtual:reallysimpledocs/components/SidebarHeader") {
113
175
  return "\0virtual:reallysimpledocs/components/SidebarHeader";
@@ -116,6 +178,23 @@ export default function reallySimpleDocs(options = {}) {
116
178
  },
117
179
  load(id) {
118
180
  if (id === "\0virtual:reallysimpledocs/config") return `export default ${JSON.stringify(virtualConfig)};`;
181
+ if (id === "\0virtual:reallysimpledocs/mdx-pages") {
182
+ const mdxModules = collectMdxModules(docsDir);
183
+ const imports = Object.entries(mdxModules)
184
+ .map(([slug, filePath], index) => `import * as mdx${index} from ${JSON.stringify(filePath)};`)
185
+ .join("\n");
186
+ const moduleMap = Object.fromEntries(Object.keys(mdxModules).map((slug, index) => [slug, `mdx${index}`]));
187
+ return `${imports}
188
+ const modules = {
189
+ ${Object.entries(moduleMap)
190
+ .map(([slug, name]) => ` ${JSON.stringify(slug)}: ${name},`)
191
+ .join("\n")}
192
+ };
193
+ export async function getMdxPage(slug) {
194
+ return modules[slug || "index"] || null;
195
+ }
196
+ `;
197
+ }
119
198
  if (id === "\0virtual:reallysimpledocs/components/SidebarHeader") {
120
199
  return `export { default } from ${JSON.stringify(resolveUserId(components.SidebarHeader))};`;
121
200
  }
@@ -2,3 +2,12 @@ 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 Callout } from "../runtime/components/mdx/Callout.astro";
6
+ export { default as Card } from "../runtime/components/mdx/Card.astro";
7
+ export { default as CardGrid } from "../runtime/components/mdx/CardGrid.astro";
8
+ export { default as Code } from "../runtime/components/mdx/Code.astro";
9
+ export { default as CodeGroup } from "../runtime/components/mdx/CodeGroup.astro";
10
+ export { default as Preview } from "../runtime/components/mdx/Preview.astro";
11
+ export { default as Step } from "../runtime/components/mdx/Step.astro";
12
+ export { default as Steps } from "../runtime/components/mdx/Steps.astro";
13
+ 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;
@@ -119,20 +119,25 @@
119
119
  }
120
120
  }
121
121
 
122
- .shiki {
123
- @apply bg-muted dark:bg-muted/50;
122
+ .shiki,
123
+ .astro-code {
124
+ @apply bg-muted/50;
125
+ background-color: color-mix(in oklab, var(--color-muted) 50%, transparent) !important;
124
126
  color: var(--shiki-light);
125
127
  }
126
128
 
127
- .shiki span {
129
+ .shiki span,
130
+ .astro-code span {
128
131
  color: var(--shiki-light);
129
132
  }
130
133
 
131
- html.dark .shiki {
134
+ html.dark .shiki,
135
+ html.dark .astro-code {
132
136
  color: var(--shiki-dark);
133
137
  }
134
138
 
135
- html.dark .shiki span {
139
+ html.dark .shiki span,
140
+ html.dark .astro-code span {
136
141
  color: var(--shiki-dark);
137
142
  }
138
143
 
@@ -140,15 +145,15 @@ html.dark .shiki span {
140
145
  @apply sm:max-w-2xl;
141
146
  }
142
147
 
143
- .command [data-rsd-search-result] {
148
+ [data-search] .command a[role='menuitem'] {
144
149
  @apply flex-col items-start gap-1;
145
150
  }
146
151
 
147
- .command [data-rsd-search-title] {
152
+ [data-search] .command a[role='menuitem'] > strong {
148
153
  @apply font-medium;
149
154
  }
150
155
 
151
- .command [data-rsd-search-snippet] {
156
+ [data-search] .command a[role='menuitem'] > span {
152
157
  @apply text-muted-foreground line-clamp-2 text-xs font-normal leading-snug;
153
158
  }
154
159
 
@@ -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("")}
@@ -199,7 +199,7 @@ const { indexUrl } = Astro.props;
199
199
 
200
200
  async function loadIndex() {
201
201
  if (state.index) return state;
202
- if (!indexUrl) throw new Error("Missing RSD search index URL.");
202
+ if (!indexUrl) throw new Error("Missing reallysimpledocs search index URL.");
203
203
  if (!state.loadPromise) {
204
204
  renderLoading();
205
205
  state.loadPromise = fetch(indexUrl)
@@ -0,0 +1,41 @@
1
+ ---
2
+ import * as lucideIcons from "lucide-static";
3
+ import { addSvgClass } from "../../lib/html.js";
4
+
5
+ const { type, title = "Note", icon: iconName, class: className = "" } = Astro.props;
6
+ const normalizedType = String(type || "").toLowerCase();
7
+ const calloutType = normalizedType === "warning" || normalizedType === "danger" ? normalizedType : "";
8
+ const danger = calloutType === "danger";
9
+
10
+ function resolveNamedIcon(name) {
11
+ if (!name || typeof name !== "string") return "";
12
+ const trimmed = name.trim();
13
+ if (!trimmed) return "";
14
+ if (trimmed.startsWith("<svg")) return trimmed;
15
+ const pascalName = trimmed.includes("-")
16
+ ? trimmed
17
+ .split("-")
18
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
19
+ .join("")
20
+ : trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
21
+ return lucideIcons[pascalName] || "";
22
+ }
23
+
24
+ const iconSource = typeof iconName === "string" ? resolveNamedIcon(iconName) : "";
25
+ const icon = addSvgClass(iconSource, "size-4");
26
+ const heading = title;
27
+ const variantClass =
28
+ calloutType === "warning"
29
+ ? "[&>svg]:text-amber-700 [&>h3]:text-amber-700 [&>section]:text-amber-700 dark:[&>svg]:text-amber-300 dark:[&>h3]:text-amber-300 dark:[&>section]:text-amber-300"
30
+ : danger
31
+ ? "[&>svg]:text-destructive [&>h3]:text-destructive [&>section]:text-destructive"
32
+ : "";
33
+ ---
34
+
35
+ <div class:list={[danger ? "alert-destructive" : "alert", "my-6", variantClass, className]}>
36
+ {icon && <Fragment set:html={icon} />}
37
+ <h3>{heading}</h3>
38
+ <section>
39
+ <slot />
40
+ </section>
41
+ </div>
@@ -0,0 +1,14 @@
1
+ ---
2
+ const { title, description, href, class: className = "" } = Astro.props;
3
+ const Tag = href ? "a" : "article";
4
+ const classes = [
5
+ "my-6 block w-full rounded-lg border bg-card p-3 text-card-foreground no-underline",
6
+ href && "transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
7
+ className,
8
+ ];
9
+ ---
10
+
11
+ <Tag href={href} class:list={classes}>
12
+ {title && <h3 class="font-medium">{title}</h3>}
13
+ {description && <p class="mt-1 text-sm text-muted-foreground">{description}</p>}
14
+ </Tag>
@@ -0,0 +1,7 @@
1
+ ---
2
+ const { class: className = "" } = Astro.props;
3
+ ---
4
+
5
+ <div class:list={["my-6 grid w-full gap-4 sm:grid-cols-2 [&>*]:my-0", className]}>
6
+ <slot />
7
+ </div>
@@ -0,0 +1,39 @@
1
+ ---
2
+ import { highlightCode } from "../../lib/highlight.js";
3
+
4
+ const { lang = "text", title, class: className = "" } = Astro.props;
5
+ const slotHtml = Astro.slots.has("default") ? await Astro.slots.render("default") : "";
6
+ const rawCode = decodeSlotText(slotHtml);
7
+ const html = await highlightCode(rawCode, lang);
8
+ const classes = [
9
+ "relative my-6 w-full max-w-full overflow-hidden rounded-lg",
10
+ className,
11
+ ];
12
+
13
+ function decodeSlotText(value) {
14
+ return String(value || "")
15
+ .replace(/^<p>/, "")
16
+ .replace(/<\/p>$/, "")
17
+ .replace(/&lt;/g, "<")
18
+ .replace(/&gt;/g, ">")
19
+ .replace(/&amp;/g, "&")
20
+ .replace(/&quot;/g, '"')
21
+ .replace(/&#39;/g, "'")
22
+ .trim();
23
+ }
24
+ ---
25
+
26
+ <div
27
+ data-code-block
28
+ data-code-title={title || undefined}
29
+ class:list={classes}
30
+ >
31
+ {
32
+ title && (
33
+ <header class="flex h-10 items-center gap-2 border-b border-border/50 bg-muted/50 px-3 text-sm font-medium">
34
+ <span class="mr-auto">{title}</span>
35
+ </header>
36
+ )
37
+ }
38
+ <Fragment set:html={html} />
39
+ </div>
@@ -0,0 +1,80 @@
1
+ ---
2
+ const { defaultTab, class: className = "" } = Astro.props;
3
+ const id = `code-group-${Math.random().toString(36).slice(2)}`;
4
+ const slotHtml = Astro.slots.has("default") ? await Astro.slots.render("default") : "";
5
+ const normalizedTabs = parseCodeTabs(slotHtml);
6
+ const defaultValue = defaultTab || normalizedTabs[0]?.value;
7
+ const classes = [
8
+ "relative my-6 w-full max-w-full overflow-hidden rounded-lg [&>[role=tabpanel]]:border-t-0 [&_[data-code-block]]:my-0 [&_[data-code-block]]:min-w-0 [&_[data-code-block]]:rounded-none [&_[data-code-block]>header]:hidden",
9
+ className,
10
+ ];
11
+
12
+ function parseCodeTabs(html) {
13
+ const blocks = [];
14
+ const pattern =
15
+ /<div\b(?=[^>]*\bdata-code-block\b)(?=[^>]*\bdata-code-title="([^"]*)")[^>]*>([\s\S]*?)<\/div>/g;
16
+ let match;
17
+ while ((match = pattern.exec(html))) {
18
+ const label = decodeHtml(match[1]).trim();
19
+ if (!label) continue;
20
+ blocks.push({
21
+ label,
22
+ value: label,
23
+ html: match[0],
24
+ });
25
+ }
26
+ return blocks;
27
+ }
28
+
29
+ function decodeHtml(value) {
30
+ return String(value || "")
31
+ .replace(/&lt;/g, "<")
32
+ .replace(/&gt;/g, ">")
33
+ .replace(/&amp;/g, "&")
34
+ .replace(/&quot;/g, '"')
35
+ .replace(/&#39;/g, "'");
36
+ }
37
+ ---
38
+
39
+ <div class:list={classes} data-code-group>
40
+ <header class="flex h-10 items-center gap-2 border-b border-border/50 bg-muted/50 px-3 text-sm font-medium">
41
+ <div class="tabs my-0 min-w-0 max-w-full gap-0">
42
+ <nav class="h-full justify-start rounded-none bg-transparent p-0" role="tablist" aria-orientation="horizontal" data-variant="line">
43
+ {
44
+ normalizedTabs.map((tab, index) => {
45
+ const selected = tab.value === defaultValue;
46
+ return (
47
+ <button
48
+ type="button"
49
+ role="tab"
50
+ id={`${id}-tab-${index + 1}`}
51
+ aria-controls={`${id}-panel-${index + 1}`}
52
+ aria-selected={selected ? "true" : "false"}
53
+ tabindex={selected ? "0" : "-1"}
54
+ >
55
+ {tab.label}
56
+ </button>
57
+ );
58
+ })
59
+ }
60
+ </nav>
61
+ </div>
62
+ </header>
63
+ {
64
+ normalizedTabs.map((tab, index) => {
65
+ const selected = tab.value === defaultValue;
66
+ return (
67
+ <div
68
+ role="tabpanel"
69
+ id={`${id}-panel-${index + 1}`}
70
+ aria-labelledby={`${id}-tab-${index + 1}`}
71
+ tabindex="-1"
72
+ aria-selected={selected ? "true" : "false"}
73
+ hidden={!selected}
74
+ >
75
+ <Fragment set:html={tab.html} />
76
+ </div>
77
+ );
78
+ })
79
+ }
80
+ </div>
@@ -0,0 +1,67 @@
1
+ ---
2
+ import { highlightCode } from "../../lib/highlight.js";
3
+
4
+ const { code = "", lang = "html", title = "Preview", class: className = "" } = Astro.props;
5
+ const id = `preview-${Math.random().toString(36).slice(2)}`;
6
+ const html = await highlightCode(code, lang);
7
+ ---
8
+
9
+ <section
10
+ class="relative my-6 max-w-full overflow-hidden rounded-lg border [&>[role=tabpanel]]:border-t-0 [&>[data-code-panel]_[data-code-block]]:my-0 [&>[data-code-panel]_[data-code-block]]:min-w-0 [&>[data-code-panel]_[data-code-block]]:rounded-none [&>[data-preview-panel]>div]:flex [&>[data-preview-panel]>div]:min-h-64 [&>[data-preview-panel]>div]:w-full [&>[data-preview-panel]>div]:items-center [&>[data-preview-panel]>div]:justify-center [&>[data-preview-panel]>div]:p-6"
11
+ data-preview
12
+ >
13
+ <header class="flex h-10 items-center gap-2 border-b border-border/50 bg-muted/50 px-3 text-sm font-medium">
14
+ <div class="tabs my-0 min-w-0 max-w-full gap-0">
15
+ <nav class="h-full justify-start rounded-none bg-transparent p-0" role="tablist" aria-orientation="horizontal" data-variant="line">
16
+ <button
17
+ type="button"
18
+ role="tab"
19
+ id={`${id}-preview-tab`}
20
+ aria-controls={`${id}-preview-panel`}
21
+ aria-selected="true"
22
+ tabindex="0"
23
+ >
24
+ {title}
25
+ </button>
26
+ <button
27
+ type="button"
28
+ role="tab"
29
+ id={`${id}-code-tab`}
30
+ aria-controls={`${id}-code-panel`}
31
+ aria-selected="false"
32
+ tabindex="-1"
33
+ >
34
+ Code
35
+ </button>
36
+ </nav>
37
+ </div>
38
+ </header>
39
+ <section
40
+ role="tabpanel"
41
+ data-preview-panel
42
+ id={`${id}-preview-panel`}
43
+ aria-labelledby={`${id}-preview-tab`}
44
+ tabindex="-1"
45
+ aria-selected="true"
46
+ >
47
+ <div class={className}>
48
+ <slot />
49
+ </div>
50
+ </section>
51
+ <section
52
+ role="tabpanel"
53
+ data-code-panel
54
+ id={`${id}-code-panel`}
55
+ aria-labelledby={`${id}-code-tab`}
56
+ tabindex="-1"
57
+ aria-selected="false"
58
+ hidden
59
+ >
60
+ <div
61
+ class="relative my-6 w-full max-w-full overflow-hidden rounded-lg"
62
+ data-code-block
63
+ >
64
+ <Fragment set:html={html} />
65
+ </div>
66
+ </section>
67
+ </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,11 @@
1
+ ---
2
+ const { class: className = "" } = Astro.props;
3
+ const classes = [
4
+ "mb-12 mt-6 [counter-reset:step] md:ml-4 md:border-l md:pl-8 [&>section]:mb-10 [&>section]:[counter-increment:step] [&>section:last-child]:mb-0 [&>section>*+*]:mt-6 [&>section>*]:leading-relaxed [&>section>*:first-child]:mt-0 [&>section>*:last-child]:mb-0 [&>section>h3+*]:mt-4 [&>section>h3]:relative [&>section>h3]:scroll-m-28 [&>section>h3]:text-lg [&>section>h3]:font-medium [&>section>h3]:tracking-tight [&>section>h3]:before:mr-4 [&>section>h3]:before:inline-flex [&>section>h3]:before:size-7 [&>section>h3]:before:items-center [&>section>h3]:before:justify-center [&>section>h3]:before:rounded-full [&>section>h3]:before:bg-muted [&>section>h3]:before:text-sm [&>section>h3]:before:font-medium [&>section>h3]:before:text-muted-foreground [&>section>h3]:before:content-[counter(step)] md:[&>section>h3]:before:absolute md:[&>section>h3]:before:left-0 md:[&>section>h3]:before:top-0 md:[&>section>h3]:before:mr-0 md:[&>section>h3]:before:-translate-x-[2.875rem]",
5
+ className,
6
+ ];
7
+ ---
8
+
9
+ <div role="list" class:list={classes}>
10
+ <slot />
11
+ </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 my-6", 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>
@@ -2,8 +2,8 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { marked } from "marked";
4
4
  import GithubSlugger from "github-slugger";
5
- import { codeToHtml } from "shiki";
6
5
  import * as lucideIcons from "lucide-static";
6
+ import { highlightCode as renderHighlightedCode } from "./highlight.js";
7
7
  import { markdownPath, routePath } from "./paths.js";
8
8
 
9
9
  export function getManifest(config) {
@@ -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,
@@ -108,11 +117,25 @@ export async function renderDoc(config, page) {
108
117
  };
109
118
 
110
119
  const tokens = marked.lexer(content);
111
- await highlightCode(tokens);
120
+ await highlightMarkdownCode(tokens);
112
121
  const html = marked.parser(tokens, { renderer });
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,
@@ -141,7 +164,9 @@ export function resolveIcon(icon) {
141
164
  .join("")
142
165
  : trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
143
166
  const iconClass = trimmed.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
144
- return lucideIcons[pascalName]?.replace('class="lucide', `class="lucide lucide-${iconClass}`) || "";
167
+ const svg = lucideIcons[pascalName] || "";
168
+ if (!svg || svg.includes(`lucide-${iconClass}`)) return svg;
169
+ return svg.replace('class="lucide', `class="lucide lucide-${iconClass}`);
145
170
  }
146
171
 
147
172
  export function buildMenus(config, currentSlug) {
@@ -156,7 +181,7 @@ export function buildMenus(config, currentSlug) {
156
181
  type: "item",
157
182
  icon,
158
183
  url: page.path,
159
- label: page.title,
184
+ label: item.label || page.title,
160
185
  current: page.slug === currentSlug,
161
186
  keywords: page.title,
162
187
  };
@@ -231,7 +256,7 @@ export function getPageItemSlug(item) {
231
256
  }
232
257
 
233
258
  function parseDocMarkdown(source, fallbackTitle) {
234
- const content = String(source || "").trimStart();
259
+ const content = normalizeDocSource(source);
235
260
  const tokens = marked.lexer(content);
236
261
  const first = tokens[0];
237
262
 
@@ -249,10 +274,21 @@ function parseDocMarkdown(source, fallbackTitle) {
249
274
  }
250
275
 
251
276
  function markdownPlainText(markdown) {
252
- return tokensText(marked.lexer(renderNunjucksCompat(markdown)));
277
+ return tokensText(marked.lexer(renderNunjucksCompat(normalizeDocSource(markdown))));
278
+ }
279
+
280
+ function normalizeDocSource(source) {
281
+ const withoutFrontmatter = String(source || "")
282
+ .trimStart()
283
+ .replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "");
284
+ const firstHeading = withoutFrontmatter.search(/^#\s+/m);
285
+ if (firstHeading > 0) return withoutFrontmatter.slice(firstHeading).trimStart();
286
+ return withoutFrontmatter
287
+ .replace(/^(?:[ \t]*(?:import|export)\s+[^\n]*\n)+/, "")
288
+ .trimStart();
253
289
  }
254
290
 
255
- async function highlightCode(tokens) {
291
+ async function highlightMarkdownCode(tokens) {
256
292
  for (const token of tokens) {
257
293
  if (token.type === "code") {
258
294
  const html = await highlightToken(token);
@@ -266,36 +302,28 @@ async function highlightCode(tokens) {
266
302
  }
267
303
 
268
304
  for (const childTokens of nestedTokenLists(token)) {
269
- await highlightCode(childTokens);
305
+ await highlightMarkdownCode(childTokens);
270
306
  }
271
307
  }
272
308
  }
273
309
 
274
310
  async function highlightToken(token) {
311
+ const lang = normalizeLanguage(token.lang);
275
312
  try {
276
- return await codeToHtml(token.text, {
277
- lang: normalizeLanguage(token.lang),
278
- themes: {
279
- light: "github-light",
280
- dark: "github-dark",
281
- },
282
- defaultColor: false,
283
- });
313
+ return wrapCodeBlock(await renderHighlightedCode(token.text, lang));
284
314
  } catch {
285
- return codeToHtml(token.text, {
286
- lang: "text",
287
- themes: {
288
- light: "github-light",
289
- dark: "github-dark",
290
- },
291
- defaultColor: false,
292
- });
315
+ return wrapCodeBlock(await renderHighlightedCode(token.text, "text"));
293
316
  }
294
317
  }
295
318
 
319
+ function wrapCodeBlock(html) {
320
+ return `<div class="relative my-6 w-full max-w-full overflow-hidden rounded-lg" data-code-block>${html}</div>`;
321
+ }
322
+
296
323
  function normalizeLanguage(lang) {
297
324
  const value = lang?.trim().split(/\s+/)[0].toLowerCase() || "text";
298
325
  if (value === "njk") return "html";
326
+ if (value === "mdx") return "tsx";
299
327
  if (value === "sh" || value === "shell") return "bash";
300
328
  return value;
301
329
  }
@@ -0,0 +1,27 @@
1
+ import { codeToHtml } from "shiki";
2
+
3
+ export async function highlightCode(code, lang = "text") {
4
+ const value = String(code || "");
5
+ const language = String(lang || "text").toLowerCase() === "mdx" ? "tsx" : String(lang || "text");
6
+ const html = await codeToHtml(value, {
7
+ lang: language,
8
+ themes: {
9
+ light: "github-light",
10
+ dark: "github-dark",
11
+ },
12
+ defaultColor: false,
13
+ });
14
+ return addCodeLayoutClasses(html);
15
+ }
16
+
17
+ function addCodeLayoutClasses(html) {
18
+ return String(html)
19
+ .replace(
20
+ /<pre class="/,
21
+ '<pre class="!my-0 max-w-full overflow-x-auto !rounded-none !border-0 ',
22
+ )
23
+ .replace(
24
+ /<code>/,
25
+ '<code class="block min-w-full w-max px-4 py-3 text-[13px]">',
26
+ );
27
+ }
@@ -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,9 @@
1
1
  ---
2
2
  import config from "virtual:reallysimpledocs/config";
3
+ import { getMdxPage } from "virtual:reallysimpledocs/mdx-pages";
4
+ import * as RsdComponents from "../../components/index.js";
3
5
  import DocsLayout from "../components/DocsLayout.astro";
4
- import { getPages, renderDoc } from "../lib/docs.js";
6
+ import { getDocHeadings, getPages, renderDoc } from "../lib/docs.js";
5
7
  import { routeSlug } from "../lib/paths.js";
6
8
 
7
9
  export function getStaticPaths() {
@@ -12,9 +14,12 @@ export function getStaticPaths() {
12
14
  }
13
15
 
14
16
  const { page } = Astro.props;
15
- const doc = await renderDoc(config, page);
17
+ const isMdx = page.extension === ".mdx";
18
+ const mdxModule = isMdx ? await getMdxPage(page.slug) : null;
19
+ const MdxContent = mdxModule?.Content || mdxModule?.default;
20
+ const doc = isMdx ? { page, headings: getDocHeadings(config, page), html: "" } : await renderDoc(config, page);
16
21
  ---
17
22
 
18
23
  <DocsLayout page={page} headings={doc.headings}>
19
- <Fragment set:html={doc.html} />
24
+ {MdxContent ? <MdxContent components={RsdComponents} /> : <Fragment set:html={doc.html} />}
20
25
  </DocsLayout>
@@ -19,18 +19,63 @@ 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
+ const codeFrameClass = "relative my-6 w-full max-w-full overflow-hidden rounded-lg";
24
+ const codePreClasses = ["!my-0", "max-w-full", "overflow-x-auto", "!rounded-none", "!border-0"];
25
+ const codeContentClasses = ["block", "min-w-full", "w-max", "px-4", "py-3", "text-[13px]"];
26
+ const copyButtonClass =
27
+ "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";
28
+
22
29
  const initCopyCode = (codeBlock) => {
23
30
  const codeEl = codeBlock.querySelector(":scope > code");
24
31
  if (!codeEl) return;
25
32
 
33
+ codeBlock.classList.add(...codePreClasses);
34
+ codeEl.classList.add(...codeContentClasses);
35
+
36
+ let target = null;
37
+ let frame = codeBlock.closest("[data-code-group]");
38
+ let getCode = frame ? () => activePanelCode(frame) || codeEl : () => codeEl;
39
+ if (frame) target = frame.querySelector(":scope > header");
40
+
41
+ const preview = codeBlock.closest("[data-preview]");
42
+ const previewCodePanel = preview?.querySelector(":scope > [data-code-panel]");
43
+ if (!frame && previewCodePanel?.contains(codeBlock)) {
44
+ frame = preview;
45
+ target = frame.querySelector(":scope > header");
46
+ getCode = () => codeEl;
47
+ }
48
+
49
+ if (!frame) {
50
+ frame = codeBlock.parentElement?.hasAttribute("data-code-block") ? codeBlock.parentElement : null;
51
+ if (!frame) {
52
+ frame = document.createElement("div");
53
+ frame.dataset.codeBlock = "";
54
+ frame.className = codeFrameClass;
55
+ codeBlock.parentNode.insertBefore(frame, codeBlock);
56
+ frame.appendChild(codeBlock);
57
+ }
58
+ }
59
+
60
+ target ||= frame.querySelector(":scope > header") || frame;
61
+
62
+ if (target.querySelector(":scope > button[aria-label='Copy code']")) {
63
+ codeBlock.dataset.copyCodeInitialized = true;
64
+ return;
65
+ }
66
+
26
67
  const button = document.createElement("button");
27
68
  button.type = "button";
28
- button.className = "code-block-copy";
29
69
  button.setAttribute("aria-label", "Copy code");
70
+ button.className = `${copyButtonClass} ${
71
+ target === frame
72
+ ? "absolute right-2.5 top-2.5 bg-muted/50 shadow-sm backdrop-blur-sm"
73
+ : "ml-auto"
74
+ }`;
30
75
  button.innerHTML = Copy;
31
76
 
32
77
  button.addEventListener("click", async () => {
33
- const text = (codeEl.textContent || "").replace(/\n$/, "");
78
+ const text = (getCode()?.textContent || "").replace(/\n$/, "");
34
79
  if (!text) return;
35
80
 
36
81
  button.disabled = true;
@@ -47,8 +92,18 @@ const initCopyCode = (codeBlock) => {
47
92
  }, 1200);
48
93
  });
49
94
 
50
- codeBlock.insertBefore(button, codeBlock.firstChild);
95
+ target.appendChild(button);
96
+ if (previewCodePanel?.contains(codeBlock)) {
97
+ const syncPreviewCopyButton = () => {
98
+ button.hidden = previewCodePanel.hidden;
99
+ };
100
+ syncPreviewCopyButton();
101
+ new MutationObserver(syncPreviewCopyButton).observe(previewCodePanel, {
102
+ attributes: true,
103
+ attributeFilter: ["hidden"],
104
+ });
105
+ }
51
106
  codeBlock.dataset.copyCodeInitialized = true;
52
107
  };
53
108
 
54
- window.basecoat?.register("copy-code", "pre:not([data-copy-code-initialized])", initCopyCode);
109
+ window.basecoat?.register("copy-code", "pre:has(> code):not([data-copy-code-initialized])", initCopyCode);