create-unmint 1.1.1 → 1.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.
- package/dist/index.js +664 -6
- package/package.json +1 -1
- package/template/app/components/docs/docs-header.tsx +2 -2
- package/template/app/components/docs/docs-sidebar.tsx +3 -3
- package/template/app/components/docs/mdx/accordion.tsx +3 -1
- package/template/app/components/docs/mdx/tabs.tsx +85 -29
- package/template/app/components/docs/mobile-sidebar.tsx +7 -5
- package/template/app/components/docs/search-dialog.tsx +7 -2
- package/template/app/components/docs/theme-toggle.tsx +4 -4
- package/template/lib/theme-config.ts +1 -1
package/dist/index.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import
|
|
5
|
+
import chalk5 from "chalk";
|
|
6
6
|
import figlet from "figlet";
|
|
7
7
|
import gradient from "gradient-string";
|
|
8
|
+
import fs7 from "fs-extra";
|
|
9
|
+
import path7 from "path";
|
|
8
10
|
|
|
9
11
|
// src/commands/init.ts
|
|
10
12
|
import fs2 from "fs-extra";
|
|
@@ -109,6 +111,66 @@ function getDefaultConfig(projectName) {
|
|
|
109
111
|
installDeps: true
|
|
110
112
|
};
|
|
111
113
|
}
|
|
114
|
+
async function promptAddConfig(hasExistingDocs, customPath) {
|
|
115
|
+
const answers = await inquirer.prompt([
|
|
116
|
+
{
|
|
117
|
+
type: "input",
|
|
118
|
+
name: "docsRoute",
|
|
119
|
+
message: "Add Unmint docs at which route?",
|
|
120
|
+
default: customPath || (hasExistingDocs ? "/documentation" : "/docs"),
|
|
121
|
+
validate: (input) => {
|
|
122
|
+
if (!input.startsWith("/")) return "Route must start with /";
|
|
123
|
+
if (!/^\/[a-z0-9-/]*$/i.test(input)) {
|
|
124
|
+
return "Route can only contain letters, numbers, hyphens, and slashes";
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
type: "input",
|
|
131
|
+
name: "title",
|
|
132
|
+
message: "Docs title:",
|
|
133
|
+
default: "Documentation"
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
type: "input",
|
|
137
|
+
name: "description",
|
|
138
|
+
message: "Docs description:",
|
|
139
|
+
default: "Documentation for your project"
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
type: "list",
|
|
143
|
+
name: "accentColor",
|
|
144
|
+
message: "Accent color:",
|
|
145
|
+
choices: accentColors.map((c) => ({
|
|
146
|
+
name: c.name === "Cyan" ? `${c.name} ${chalk.dim("(default)")}` : c.name,
|
|
147
|
+
value: c.value
|
|
148
|
+
})),
|
|
149
|
+
default: "#0891b2"
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
type: "input",
|
|
153
|
+
name: "customAccent",
|
|
154
|
+
message: "Custom accent color (hex):",
|
|
155
|
+
when: (answers2) => answers2.accentColor === "custom",
|
|
156
|
+
validate: (input) => {
|
|
157
|
+
if (!/^#[0-9a-f]{6}$/i.test(input)) {
|
|
158
|
+
return "Please enter a valid hex color (e.g., #ff5733)";
|
|
159
|
+
}
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
]);
|
|
164
|
+
if (answers.accentColor === "custom" && answers.customAccent) {
|
|
165
|
+
answers.accentColor = answers.customAccent;
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
docsRoute: answers.docsRoute,
|
|
169
|
+
title: answers.title,
|
|
170
|
+
description: answers.description,
|
|
171
|
+
accentColor: answers.accentColor
|
|
172
|
+
};
|
|
173
|
+
}
|
|
112
174
|
|
|
113
175
|
// src/scaffold.ts
|
|
114
176
|
import fs from "fs-extra";
|
|
@@ -488,6 +550,588 @@ async function applyUpdates(projectDir, changes) {
|
|
|
488
550
|
return results;
|
|
489
551
|
}
|
|
490
552
|
|
|
553
|
+
// src/commands/add.ts
|
|
554
|
+
import chalk4 from "chalk";
|
|
555
|
+
import ora3 from "ora";
|
|
556
|
+
import path6 from "path";
|
|
557
|
+
import fs6 from "fs-extra";
|
|
558
|
+
import { execa as execa2 } from "execa";
|
|
559
|
+
|
|
560
|
+
// src/utils/detect-project.ts
|
|
561
|
+
import fs4 from "fs-extra";
|
|
562
|
+
import path4 from "path";
|
|
563
|
+
async function detectProject(cwd) {
|
|
564
|
+
const result = {
|
|
565
|
+
isExistingProject: false,
|
|
566
|
+
framework: "unknown",
|
|
567
|
+
useSrcDir: false,
|
|
568
|
+
appDir: "app",
|
|
569
|
+
hasExistingDocs: false,
|
|
570
|
+
hasFumadocs: false,
|
|
571
|
+
packageManager: "npm",
|
|
572
|
+
nextConfigPath: null,
|
|
573
|
+
globalsCssPath: null,
|
|
574
|
+
tailwindConfigPath: null
|
|
575
|
+
};
|
|
576
|
+
const packageJsonPath = path4.join(cwd, "package.json");
|
|
577
|
+
if (!await fs4.pathExists(packageJsonPath)) {
|
|
578
|
+
return result;
|
|
579
|
+
}
|
|
580
|
+
result.isExistingProject = true;
|
|
581
|
+
const nextConfigExtensions = ["js", "ts", "mjs"];
|
|
582
|
+
for (const ext of nextConfigExtensions) {
|
|
583
|
+
const configPath = path4.join(cwd, `next.config.${ext}`);
|
|
584
|
+
if (await fs4.pathExists(configPath)) {
|
|
585
|
+
result.nextConfigPath = configPath;
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (!result.nextConfigPath) {
|
|
590
|
+
return result;
|
|
591
|
+
}
|
|
592
|
+
const srcAppPath = path4.join(cwd, "src/app");
|
|
593
|
+
const appPath = path4.join(cwd, "app");
|
|
594
|
+
if (await fs4.pathExists(srcAppPath)) {
|
|
595
|
+
result.useSrcDir = true;
|
|
596
|
+
result.appDir = "src/app";
|
|
597
|
+
result.framework = "next-app";
|
|
598
|
+
} else if (await fs4.pathExists(appPath)) {
|
|
599
|
+
result.useSrcDir = false;
|
|
600
|
+
result.appDir = "app";
|
|
601
|
+
result.framework = "next-app";
|
|
602
|
+
} else {
|
|
603
|
+
const pagesPath = path4.join(cwd, "pages");
|
|
604
|
+
const srcPagesPath = path4.join(cwd, "src/pages");
|
|
605
|
+
if (await fs4.pathExists(pagesPath) || await fs4.pathExists(srcPagesPath)) {
|
|
606
|
+
result.framework = "next-pages";
|
|
607
|
+
}
|
|
608
|
+
return result;
|
|
609
|
+
}
|
|
610
|
+
const docsPath = path4.join(cwd, result.appDir, "docs");
|
|
611
|
+
result.hasExistingDocs = await fs4.pathExists(docsPath);
|
|
612
|
+
try {
|
|
613
|
+
const pkg = await fs4.readJson(packageJsonPath);
|
|
614
|
+
const allDeps = {
|
|
615
|
+
...pkg.dependencies,
|
|
616
|
+
...pkg.devDependencies
|
|
617
|
+
};
|
|
618
|
+
result.hasFumadocs = "fumadocs-core" in allDeps || "fumadocs-mdx" in allDeps;
|
|
619
|
+
} catch {
|
|
620
|
+
}
|
|
621
|
+
result.packageManager = await detectPackageManager2(cwd);
|
|
622
|
+
const globalsCssPaths = [
|
|
623
|
+
path4.join(cwd, result.appDir, "globals.css"),
|
|
624
|
+
path4.join(cwd, "src/styles/globals.css"),
|
|
625
|
+
path4.join(cwd, "styles/globals.css")
|
|
626
|
+
];
|
|
627
|
+
for (const cssPath of globalsCssPaths) {
|
|
628
|
+
if (await fs4.pathExists(cssPath)) {
|
|
629
|
+
result.globalsCssPath = cssPath;
|
|
630
|
+
break;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const tailwindConfigExtensions = ["ts", "js", "mjs"];
|
|
634
|
+
for (const ext of tailwindConfigExtensions) {
|
|
635
|
+
const configPath = path4.join(cwd, `tailwind.config.${ext}`);
|
|
636
|
+
if (await fs4.pathExists(configPath)) {
|
|
637
|
+
result.tailwindConfigPath = configPath;
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return result;
|
|
642
|
+
}
|
|
643
|
+
async function detectPackageManager2(cwd) {
|
|
644
|
+
if (await fs4.pathExists(path4.join(cwd, "pnpm-lock.yaml"))) {
|
|
645
|
+
return "pnpm";
|
|
646
|
+
}
|
|
647
|
+
if (await fs4.pathExists(path4.join(cwd, "yarn.lock"))) {
|
|
648
|
+
return "yarn";
|
|
649
|
+
}
|
|
650
|
+
if (await fs4.pathExists(path4.join(cwd, "bun.lockb"))) {
|
|
651
|
+
return "bun";
|
|
652
|
+
}
|
|
653
|
+
const userAgent = process.env.npm_config_user_agent || "";
|
|
654
|
+
if (userAgent.includes("pnpm")) return "pnpm";
|
|
655
|
+
if (userAgent.includes("yarn")) return "yarn";
|
|
656
|
+
if (userAgent.includes("bun")) return "bun";
|
|
657
|
+
return "npm";
|
|
658
|
+
}
|
|
659
|
+
function validateProjectForAdd(info) {
|
|
660
|
+
if (!info.isExistingProject) {
|
|
661
|
+
return {
|
|
662
|
+
valid: false,
|
|
663
|
+
error: 'No package.json found. Use "npx create-unmint my-docs" to create a new project.'
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
if (!info.nextConfigPath) {
|
|
667
|
+
return {
|
|
668
|
+
valid: false,
|
|
669
|
+
error: "No Next.js config found. This directory does not appear to be a Next.js project."
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
if (info.framework === "next-pages") {
|
|
673
|
+
return {
|
|
674
|
+
valid: false,
|
|
675
|
+
error: "Unmint requires App Router. Your project appears to use Pages Router."
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
if (info.framework === "unknown") {
|
|
679
|
+
return {
|
|
680
|
+
valid: false,
|
|
681
|
+
error: "Could not detect app directory. Make sure your project uses the Next.js App Router."
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
return { valid: true };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// src/utils/merge.ts
|
|
688
|
+
import fs5 from "fs-extra";
|
|
689
|
+
import path5 from "path";
|
|
690
|
+
var UNMINT_DEPENDENCIES = {
|
|
691
|
+
"fumadocs-core": "^16.4.7",
|
|
692
|
+
"fumadocs-mdx": "^14.2.5"
|
|
693
|
+
};
|
|
694
|
+
async function mergeDependencies(packageJsonPath, depsToAdd = UNMINT_DEPENDENCIES) {
|
|
695
|
+
const pkg = await fs5.readJson(packageJsonPath);
|
|
696
|
+
const added = [];
|
|
697
|
+
const skipped = [];
|
|
698
|
+
pkg.dependencies = pkg.dependencies || {};
|
|
699
|
+
for (const [name, version] of Object.entries(depsToAdd)) {
|
|
700
|
+
if (pkg.dependencies[name] || pkg.devDependencies?.[name]) {
|
|
701
|
+
skipped.push(name);
|
|
702
|
+
} else {
|
|
703
|
+
pkg.dependencies[name] = version;
|
|
704
|
+
added.push(name);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
pkg.dependencies = Object.fromEntries(
|
|
708
|
+
Object.entries(pkg.dependencies).sort(([a], [b]) => a.localeCompare(b))
|
|
709
|
+
);
|
|
710
|
+
await fs5.writeJson(packageJsonPath, pkg, { spaces: 2 });
|
|
711
|
+
return { added, skipped };
|
|
712
|
+
}
|
|
713
|
+
function getUnmintCssVariables(accentColor, darkAccentColor) {
|
|
714
|
+
return `
|
|
715
|
+
/* Unmint Docs - Scoped accent colors */
|
|
716
|
+
.unmint-docs {
|
|
717
|
+
--accent: ${accentColor};
|
|
718
|
+
--accent-foreground: #ffffff;
|
|
719
|
+
--accent-muted: ${hexToRgba(accentColor, 0.1)};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
.dark .unmint-docs {
|
|
723
|
+
--accent: ${darkAccentColor};
|
|
724
|
+
--accent-foreground: #0f172a;
|
|
725
|
+
--accent-muted: ${hexToRgba(darkAccentColor, 0.1)};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/* Syntax highlighting - Shiki integration for docs */
|
|
729
|
+
.unmint-docs pre code span {
|
|
730
|
+
color: var(--shiki-light);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
.dark .unmint-docs pre code span {
|
|
734
|
+
color: var(--shiki-dark);
|
|
735
|
+
}
|
|
736
|
+
`;
|
|
737
|
+
}
|
|
738
|
+
async function mergeGlobalsCss(globalsCssPath, accentColor, darkAccentColor) {
|
|
739
|
+
const existing = await fs5.readFile(globalsCssPath, "utf-8");
|
|
740
|
+
if (existing.includes(".unmint-docs")) {
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
const cssToAdd = getUnmintCssVariables(accentColor, darkAccentColor);
|
|
744
|
+
await fs5.appendFile(globalsCssPath, cssToAdd);
|
|
745
|
+
return true;
|
|
746
|
+
}
|
|
747
|
+
async function wrapNextConfig(nextConfigPath) {
|
|
748
|
+
const existing = await fs5.readFile(nextConfigPath, "utf-8");
|
|
749
|
+
if (existing.includes("fumadocs-mdx") || existing.includes("createMDX")) {
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
752
|
+
const ext = path5.extname(nextConfigPath);
|
|
753
|
+
let modified;
|
|
754
|
+
if (ext === ".ts") {
|
|
755
|
+
modified = wrapTypescriptConfig(existing);
|
|
756
|
+
} else {
|
|
757
|
+
modified = wrapJavascriptConfig(existing);
|
|
758
|
+
}
|
|
759
|
+
await fs5.writeFile(nextConfigPath, modified);
|
|
760
|
+
return true;
|
|
761
|
+
}
|
|
762
|
+
function wrapTypescriptConfig(existing) {
|
|
763
|
+
const importLine = "import { createMDX } from 'fumadocs-mdx/next'\n";
|
|
764
|
+
const exportMatch = existing.match(/export\s+default\s+(\w+)/);
|
|
765
|
+
if (exportMatch) {
|
|
766
|
+
const configName = exportMatch[1];
|
|
767
|
+
let modified = importLine + existing;
|
|
768
|
+
modified = modified.replace(
|
|
769
|
+
/export\s+default\s+\w+/,
|
|
770
|
+
`const withMDX = createMDX()
|
|
771
|
+
|
|
772
|
+
export default withMDX(${configName})`
|
|
773
|
+
);
|
|
774
|
+
return modified;
|
|
775
|
+
}
|
|
776
|
+
if (existing.includes("export default {")) {
|
|
777
|
+
let modified = importLine + existing.replace(
|
|
778
|
+
"export default {",
|
|
779
|
+
"const nextConfig = {"
|
|
780
|
+
);
|
|
781
|
+
modified = modified.trimEnd() + "\n\nconst withMDX = createMDX()\n\nexport default withMDX(nextConfig)\n";
|
|
782
|
+
return modified;
|
|
783
|
+
}
|
|
784
|
+
return importLine + existing;
|
|
785
|
+
}
|
|
786
|
+
function wrapJavascriptConfig(existing) {
|
|
787
|
+
const isESM = existing.includes("export default") || existing.includes("import ");
|
|
788
|
+
if (isESM) {
|
|
789
|
+
const importLine = "import { createMDX } from 'fumadocs-mdx/next'\n";
|
|
790
|
+
const exportMatch = existing.match(/export\s+default\s+(\w+)/);
|
|
791
|
+
if (exportMatch) {
|
|
792
|
+
const configName = exportMatch[1];
|
|
793
|
+
let modified = importLine + existing;
|
|
794
|
+
modified = modified.replace(
|
|
795
|
+
/export\s+default\s+\w+/,
|
|
796
|
+
`const withMDX = createMDX()
|
|
797
|
+
|
|
798
|
+
export default withMDX(${configName})`
|
|
799
|
+
);
|
|
800
|
+
return modified;
|
|
801
|
+
}
|
|
802
|
+
if (existing.includes("export default {")) {
|
|
803
|
+
let modified = importLine + existing.replace(
|
|
804
|
+
"export default {",
|
|
805
|
+
"const nextConfig = {"
|
|
806
|
+
);
|
|
807
|
+
modified = modified.trimEnd() + "\n\nconst withMDX = createMDX()\n\nexport default withMDX(nextConfig)\n";
|
|
808
|
+
return modified;
|
|
809
|
+
}
|
|
810
|
+
return importLine + existing;
|
|
811
|
+
}
|
|
812
|
+
const requireLine = "const { createMDX } = require('fumadocs-mdx/next')\n";
|
|
813
|
+
if (existing.includes("module.exports")) {
|
|
814
|
+
let modified = requireLine + existing.replace(
|
|
815
|
+
/module\.exports\s*=\s*(\w+)/,
|
|
816
|
+
(_, configName) => `const withMDX = createMDX()
|
|
817
|
+
|
|
818
|
+
module.exports = withMDX(${configName})`
|
|
819
|
+
);
|
|
820
|
+
return modified;
|
|
821
|
+
}
|
|
822
|
+
return requireLine + existing;
|
|
823
|
+
}
|
|
824
|
+
function hexToRgba(hex, alpha) {
|
|
825
|
+
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
826
|
+
if (!result) return `rgba(0, 0, 0, ${alpha})`;
|
|
827
|
+
const r = parseInt(result[1], 16);
|
|
828
|
+
const g = parseInt(result[2], 16);
|
|
829
|
+
const b = parseInt(result[3], 16);
|
|
830
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
831
|
+
}
|
|
832
|
+
function lightenColor2(hex, percent = 30) {
|
|
833
|
+
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
834
|
+
if (!result) return hex;
|
|
835
|
+
let r = parseInt(result[1], 16);
|
|
836
|
+
let g = parseInt(result[2], 16);
|
|
837
|
+
let b = parseInt(result[3], 16);
|
|
838
|
+
r = Math.min(255, Math.floor(r + (255 - r) * (percent / 100)));
|
|
839
|
+
g = Math.min(255, Math.floor(g + (255 - g) * (percent / 100)));
|
|
840
|
+
b = Math.min(255, Math.floor(b + (255 - b) * (percent / 100)));
|
|
841
|
+
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
|
842
|
+
}
|
|
843
|
+
async function createSourceConfig(targetDir) {
|
|
844
|
+
const sourceConfigPath = path5.join(targetDir, "source.config.ts");
|
|
845
|
+
if (await fs5.pathExists(sourceConfigPath)) {
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
const content = `import { defineConfig, defineDocs } from 'fumadocs-mdx/config'
|
|
849
|
+
import { rehypeCode } from 'fumadocs-core/mdx-plugins'
|
|
850
|
+
|
|
851
|
+
export const docs = defineDocs({
|
|
852
|
+
dir: 'content/docs',
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
export default defineConfig({
|
|
856
|
+
mdxOptions: {
|
|
857
|
+
rehypePlugins: [
|
|
858
|
+
[
|
|
859
|
+
rehypeCode,
|
|
860
|
+
{
|
|
861
|
+
themes: {
|
|
862
|
+
light: 'github-light',
|
|
863
|
+
dark: 'github-dark',
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
],
|
|
867
|
+
],
|
|
868
|
+
},
|
|
869
|
+
})
|
|
870
|
+
`;
|
|
871
|
+
await fs5.writeFile(sourceConfigPath, content);
|
|
872
|
+
}
|
|
873
|
+
async function createMdxComponents(targetDir, appDir) {
|
|
874
|
+
const mdxComponentsPath = path5.join(targetDir, "mdx-components.tsx");
|
|
875
|
+
if (await fs5.pathExists(mdxComponentsPath)) {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
const componentsPath = appDir.includes("src") ? "@/app/components/docs/mdx" : "@/app/components/docs/mdx";
|
|
879
|
+
const content = `import type { MDXComponents } from 'mdx/types'
|
|
880
|
+
import defaultComponents from 'fumadocs-ui/mdx'
|
|
881
|
+
import { Accordion } from '${componentsPath}/accordion'
|
|
882
|
+
import { Callout, Note, Tip, Warning, Info } from '${componentsPath}/callout'
|
|
883
|
+
import { Card, CardGroup } from '${componentsPath}/card'
|
|
884
|
+
import { CodeBlock } from '${componentsPath}/code-block'
|
|
885
|
+
import { Frame } from '${componentsPath}/frame'
|
|
886
|
+
import { Steps, Step } from '${componentsPath}/steps'
|
|
887
|
+
import { Tab, Tabs } from '${componentsPath}/tabs'
|
|
888
|
+
|
|
889
|
+
export function useMDXComponents(components: MDXComponents): MDXComponents {
|
|
890
|
+
return {
|
|
891
|
+
...defaultComponents,
|
|
892
|
+
...components,
|
|
893
|
+
Accordion,
|
|
894
|
+
Callout,
|
|
895
|
+
Note,
|
|
896
|
+
Tip,
|
|
897
|
+
Warning,
|
|
898
|
+
Info,
|
|
899
|
+
Card,
|
|
900
|
+
CardGroup,
|
|
901
|
+
CodeBlock,
|
|
902
|
+
Frame,
|
|
903
|
+
Steps,
|
|
904
|
+
Step,
|
|
905
|
+
Tab,
|
|
906
|
+
Tabs,
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
`;
|
|
910
|
+
await fs5.writeFile(mdxComponentsPath, content);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// src/commands/add.ts
|
|
914
|
+
async function add(options = {}) {
|
|
915
|
+
const cwd = process.cwd();
|
|
916
|
+
const spinner = ora3("Detecting project...").start();
|
|
917
|
+
const projectInfo = await detectProject(cwd);
|
|
918
|
+
const validation = validateProjectForAdd(projectInfo);
|
|
919
|
+
if (!validation.valid) {
|
|
920
|
+
spinner.fail(validation.error);
|
|
921
|
+
process.exit(1);
|
|
922
|
+
}
|
|
923
|
+
spinner.succeed(`Detected Next.js project (App Router)`);
|
|
924
|
+
console.log(chalk4.dim(` Using ${projectInfo.useSrcDir ? "src/app" : "app"} directory structure`));
|
|
925
|
+
console.log();
|
|
926
|
+
if (projectInfo.hasExistingDocs && !options.path) {
|
|
927
|
+
console.log(chalk4.yellow("\u26A0 A /docs route already exists in this project."));
|
|
928
|
+
console.log(chalk4.dim(" Use --path to specify a different route (e.g., --path /documentation)"));
|
|
929
|
+
console.log();
|
|
930
|
+
}
|
|
931
|
+
const config = options.yes ? getDefaultAddConfig(options.path) : await promptAddConfig(projectInfo.hasExistingDocs, options.path);
|
|
932
|
+
const docsRoutePath = path6.join(cwd, projectInfo.appDir, config.docsRoute.replace(/^\//, ""));
|
|
933
|
+
if (await fs6.pathExists(docsRoutePath)) {
|
|
934
|
+
console.log(chalk4.red(`
|
|
935
|
+
\u2716 The route ${config.docsRoute} already exists at ${docsRoutePath}`));
|
|
936
|
+
console.log(chalk4.dim(" Choose a different route or manually merge the directories."));
|
|
937
|
+
process.exit(1);
|
|
938
|
+
}
|
|
939
|
+
console.log();
|
|
940
|
+
console.log(chalk4.cyan(" Adding Unmint to your project..."));
|
|
941
|
+
console.log();
|
|
942
|
+
await copyDocsFiles(cwd, projectInfo, config);
|
|
943
|
+
await mergeConfigurations(cwd, projectInfo, config);
|
|
944
|
+
await installDependencies(cwd, projectInfo.packageManager);
|
|
945
|
+
printSuccessMessage(config, projectInfo.packageManager);
|
|
946
|
+
}
|
|
947
|
+
function getDefaultAddConfig(customPath) {
|
|
948
|
+
return {
|
|
949
|
+
docsRoute: customPath || "/docs",
|
|
950
|
+
title: "Documentation",
|
|
951
|
+
description: "Documentation for your project",
|
|
952
|
+
accentColor: "#0891b2"
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
async function copyDocsFiles(cwd, projectInfo, config) {
|
|
956
|
+
const spinner = ora3("Copying docs files...").start();
|
|
957
|
+
const templateDir = await resolveTemplateDir();
|
|
958
|
+
const appDir = projectInfo.appDir;
|
|
959
|
+
const routeName = config.docsRoute.replace(/^\//, "");
|
|
960
|
+
const libDir = projectInfo.useSrcDir ? "src/lib" : "lib";
|
|
961
|
+
const copyOperations = [
|
|
962
|
+
// Docs route
|
|
963
|
+
{
|
|
964
|
+
from: path6.join(templateDir, "app/docs"),
|
|
965
|
+
to: path6.join(cwd, appDir, routeName)
|
|
966
|
+
},
|
|
967
|
+
// Docs components
|
|
968
|
+
{
|
|
969
|
+
from: path6.join(templateDir, "app/components/docs"),
|
|
970
|
+
to: path6.join(cwd, appDir, "components/docs")
|
|
971
|
+
},
|
|
972
|
+
// Providers (theme provider)
|
|
973
|
+
{
|
|
974
|
+
from: path6.join(templateDir, "app/providers"),
|
|
975
|
+
to: path6.join(cwd, appDir, "providers")
|
|
976
|
+
},
|
|
977
|
+
// API routes
|
|
978
|
+
{
|
|
979
|
+
from: path6.join(templateDir, "app/api/search"),
|
|
980
|
+
to: path6.join(cwd, appDir, "api/search")
|
|
981
|
+
},
|
|
982
|
+
{
|
|
983
|
+
from: path6.join(templateDir, "app/api/og"),
|
|
984
|
+
to: path6.join(cwd, appDir, "api/og")
|
|
985
|
+
},
|
|
986
|
+
// Content (always at root)
|
|
987
|
+
{
|
|
988
|
+
from: path6.join(templateDir, "content/docs"),
|
|
989
|
+
to: path6.join(cwd, "content/docs")
|
|
990
|
+
},
|
|
991
|
+
// Lib files (in src/lib if using src directory)
|
|
992
|
+
{
|
|
993
|
+
from: path6.join(templateDir, "lib/docs-source.ts"),
|
|
994
|
+
to: path6.join(cwd, libDir, "docs-source.ts")
|
|
995
|
+
},
|
|
996
|
+
{
|
|
997
|
+
from: path6.join(templateDir, "lib/theme-config.ts"),
|
|
998
|
+
to: path6.join(cwd, libDir, "unmint-config.ts")
|
|
999
|
+
},
|
|
1000
|
+
// Logo files (copy to public directory if not present)
|
|
1001
|
+
{
|
|
1002
|
+
from: path6.join(templateDir, "public/logo.svg"),
|
|
1003
|
+
to: path6.join(cwd, "public/logo.svg")
|
|
1004
|
+
},
|
|
1005
|
+
{
|
|
1006
|
+
from: path6.join(templateDir, "public/logo.png"),
|
|
1007
|
+
to: path6.join(cwd, "public/logo.png")
|
|
1008
|
+
}
|
|
1009
|
+
];
|
|
1010
|
+
await fs6.ensureDir(path6.join(cwd, "public"));
|
|
1011
|
+
await fs6.ensureDir(path6.join(cwd, libDir));
|
|
1012
|
+
for (const op of copyOperations) {
|
|
1013
|
+
if (await fs6.pathExists(op.from)) {
|
|
1014
|
+
await fs6.copy(op.from, op.to, { overwrite: false });
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
const utilsPath = path6.join(cwd, libDir, "utils.ts");
|
|
1018
|
+
if (!await fs6.pathExists(utilsPath)) {
|
|
1019
|
+
await fs6.copy(
|
|
1020
|
+
path6.join(templateDir, "lib/utils.ts"),
|
|
1021
|
+
utilsPath
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
if (projectInfo.useSrcDir) {
|
|
1025
|
+
const docsSourcePath = path6.join(cwd, libDir, "docs-source.ts");
|
|
1026
|
+
if (await fs6.pathExists(docsSourcePath)) {
|
|
1027
|
+
let content = await fs6.readFile(docsSourcePath, "utf-8");
|
|
1028
|
+
content = content.replace(
|
|
1029
|
+
/from ['"]\.\.\/\.source\/server['"]/,
|
|
1030
|
+
"from '../../.source/server'"
|
|
1031
|
+
);
|
|
1032
|
+
await fs6.writeFile(docsSourcePath, content);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
await updateDocsImports(cwd, appDir, routeName);
|
|
1036
|
+
await updateThemeConfig(cwd, config);
|
|
1037
|
+
spinner.succeed(`Added ${appDir}/${routeName}/ route with layout`);
|
|
1038
|
+
ora3().succeed(`Added docs components to ${appDir}/components/docs/`);
|
|
1039
|
+
ora3().succeed("Added content/docs/ directory with sample content");
|
|
1040
|
+
}
|
|
1041
|
+
async function resolveTemplateDir() {
|
|
1042
|
+
const bundledPath = path6.join(import.meta.dirname, "../../template");
|
|
1043
|
+
if (await fs6.pathExists(bundledPath)) {
|
|
1044
|
+
return bundledPath;
|
|
1045
|
+
}
|
|
1046
|
+
const devPath = path6.join(import.meta.dirname, "../../../template");
|
|
1047
|
+
if (await fs6.pathExists(devPath)) {
|
|
1048
|
+
return devPath;
|
|
1049
|
+
}
|
|
1050
|
+
throw new Error("Could not find template directory");
|
|
1051
|
+
}
|
|
1052
|
+
async function updateDocsImports(cwd, appDir, routeName) {
|
|
1053
|
+
const filesToUpdate = [
|
|
1054
|
+
path6.join(cwd, appDir, routeName, "layout.tsx"),
|
|
1055
|
+
path6.join(cwd, appDir, routeName, "[[...slug]]", "page.tsx"),
|
|
1056
|
+
path6.join(cwd, appDir, "components/docs/docs-sidebar.tsx"),
|
|
1057
|
+
path6.join(cwd, appDir, "components/docs/docs-header.tsx"),
|
|
1058
|
+
path6.join(cwd, appDir, "components/docs/mobile-sidebar.tsx"),
|
|
1059
|
+
path6.join(cwd, appDir, "components/docs/search-dialog.tsx"),
|
|
1060
|
+
path6.join(cwd, appDir, "api/og/route.tsx")
|
|
1061
|
+
];
|
|
1062
|
+
for (const filePath of filesToUpdate) {
|
|
1063
|
+
if (await fs6.pathExists(filePath)) {
|
|
1064
|
+
let content = await fs6.readFile(filePath, "utf-8");
|
|
1065
|
+
if (content.includes("@/lib/theme-config")) {
|
|
1066
|
+
content = content.replace(
|
|
1067
|
+
/@\/lib\/theme-config/g,
|
|
1068
|
+
"@/lib/unmint-config"
|
|
1069
|
+
);
|
|
1070
|
+
await fs6.writeFile(filePath, content);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
async function updateThemeConfig(cwd, config) {
|
|
1076
|
+
const configPath = path6.join(cwd, "lib/unmint-config.ts");
|
|
1077
|
+
if (!await fs6.pathExists(configPath)) return;
|
|
1078
|
+
let content = await fs6.readFile(configPath, "utf-8");
|
|
1079
|
+
content = content.replace(
|
|
1080
|
+
/name:\s*['"][^'"]*['"]/,
|
|
1081
|
+
`name: '${config.title}'`
|
|
1082
|
+
);
|
|
1083
|
+
content = content.replace(
|
|
1084
|
+
/description:\s*['"][^'"]*['"]/,
|
|
1085
|
+
`description: '${config.description}'`
|
|
1086
|
+
);
|
|
1087
|
+
await fs6.writeFile(configPath, content);
|
|
1088
|
+
}
|
|
1089
|
+
async function mergeConfigurations(cwd, projectInfo, config) {
|
|
1090
|
+
const packageJsonPath = path6.join(cwd, "package.json");
|
|
1091
|
+
const { added } = await mergeDependencies(packageJsonPath);
|
|
1092
|
+
if (added.length > 0) {
|
|
1093
|
+
ora3().succeed(`Merged ${added.length} dependencies into package.json`);
|
|
1094
|
+
}
|
|
1095
|
+
if (projectInfo.globalsCssPath) {
|
|
1096
|
+
const darkAccent = lightenColor2(config.accentColor, 30);
|
|
1097
|
+
const merged = await mergeGlobalsCss(projectInfo.globalsCssPath, config.accentColor, darkAccent);
|
|
1098
|
+
if (merged) {
|
|
1099
|
+
ora3().succeed("Added CSS variables to globals.css");
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (projectInfo.nextConfigPath) {
|
|
1103
|
+
const wrapped = await wrapNextConfig(projectInfo.nextConfigPath);
|
|
1104
|
+
if (wrapped) {
|
|
1105
|
+
ora3().succeed("Updated next.config for MDX support");
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
await createSourceConfig(cwd);
|
|
1109
|
+
ora3().succeed("Created source.config.ts");
|
|
1110
|
+
await createMdxComponents(cwd, projectInfo.appDir);
|
|
1111
|
+
ora3().succeed("Created mdx-components.tsx");
|
|
1112
|
+
}
|
|
1113
|
+
async function installDependencies(cwd, packageManager) {
|
|
1114
|
+
const spinner = ora3("Installing dependencies...").start();
|
|
1115
|
+
try {
|
|
1116
|
+
const installCmd = packageManager === "npm" ? "install" : "install";
|
|
1117
|
+
await execa2(packageManager, [installCmd], { cwd, stdio: "pipe" });
|
|
1118
|
+
spinner.succeed(`Installed ${Object.keys(UNMINT_DEPENDENCIES).join(", ")}`);
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
spinner.warn("Could not install dependencies automatically");
|
|
1121
|
+
console.log(chalk4.dim(` Run "${packageManager} install" manually`));
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
function printSuccessMessage(config, packageManager) {
|
|
1125
|
+
console.log();
|
|
1126
|
+
console.log(chalk4.green(" Success! Unmint docs added to your project."));
|
|
1127
|
+
console.log();
|
|
1128
|
+
console.log(" Next steps:");
|
|
1129
|
+
console.log(chalk4.cyan(` ${packageManager} run dev`));
|
|
1130
|
+
console.log();
|
|
1131
|
+
console.log(` Your docs will be at ${chalk4.cyan(`http://localhost:3000${config.docsRoute}`)}`);
|
|
1132
|
+
console.log();
|
|
1133
|
+
}
|
|
1134
|
+
|
|
491
1135
|
// src/index.ts
|
|
492
1136
|
var cyanGradient = gradient([
|
|
493
1137
|
"#065f5f",
|
|
@@ -508,17 +1152,31 @@ function printBanner() {
|
|
|
508
1152
|
});
|
|
509
1153
|
console.log();
|
|
510
1154
|
console.log(cyanGradient.multiline(banner));
|
|
511
|
-
console.log(
|
|
1155
|
+
console.log(chalk5.dim(" Beautiful documentation, open source"));
|
|
512
1156
|
console.log();
|
|
513
1157
|
}
|
|
514
1158
|
var program = new Command();
|
|
515
|
-
program.name("create-unmint").description("Create and manage Unmint documentation projects").version("1.
|
|
516
|
-
program.argument("[project-name]",
|
|
1159
|
+
program.name("create-unmint").description("Create and manage Unmint documentation projects").version("1.2.0");
|
|
1160
|
+
program.argument("[project-name]", 'Name of the project to create (use "." for current directory)').option("-y, --yes", "Skip prompts and use defaults").option("--add", "Add Unmint docs to an existing Next.js project").option("--path <route>", "Custom route path for docs (e.g., /documentation)").option("--update", "Update an existing Unmint project").option("--dry-run", "Show what would be updated without making changes").action(async (projectName, options) => {
|
|
517
1161
|
printBanner();
|
|
518
1162
|
if (options.update) {
|
|
519
1163
|
await update(options);
|
|
520
|
-
|
|
521
|
-
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
if (options.add) {
|
|
1167
|
+
await add({ yes: options.yes, path: options.path });
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
if (projectName === ".") {
|
|
1171
|
+
const cwd = process.cwd();
|
|
1172
|
+
const hasNextConfig = await fs7.pathExists(path7.join(cwd, "next.config.ts")) || await fs7.pathExists(path7.join(cwd, "next.config.js")) || await fs7.pathExists(path7.join(cwd, "next.config.mjs"));
|
|
1173
|
+
if (hasNextConfig) {
|
|
1174
|
+
console.log(chalk5.dim(" Detected existing Next.js project, using add mode..."));
|
|
1175
|
+
console.log();
|
|
1176
|
+
await add({ yes: options.yes, path: options.path });
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
522
1179
|
}
|
|
1180
|
+
await init(projectName, options);
|
|
523
1181
|
});
|
|
524
1182
|
program.parse();
|
package/package.json
CHANGED
|
@@ -33,7 +33,7 @@ export function DocsHeader({ tree }: DocsHeaderProps) {
|
|
|
33
33
|
aria-label="Open menu"
|
|
34
34
|
aria-expanded={isMobileMenuOpen}
|
|
35
35
|
>
|
|
36
|
-
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
36
|
+
<svg aria-hidden="true" className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
37
37
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
38
38
|
</svg>
|
|
39
39
|
</button>
|
|
@@ -68,7 +68,7 @@ export function DocsHeader({ tree }: DocsHeaderProps) {
|
|
|
68
68
|
className="p-2 text-muted-foreground hover:text-foreground transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
|
|
69
69
|
aria-label="GitHub"
|
|
70
70
|
>
|
|
71
|
-
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
71
|
+
<svg aria-hidden="true" className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
72
72
|
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
|
|
73
73
|
</svg>
|
|
74
74
|
</a>
|
|
@@ -25,7 +25,7 @@ export function DocsSidebar({ tree }: DocsSidebarProps) {
|
|
|
25
25
|
className="flex items-center gap-3 py-1 text-sm text-[var(--accent)] font-medium hover:opacity-80 transition-opacity"
|
|
26
26
|
>
|
|
27
27
|
<span className="flex items-center justify-center w-7 h-7 rounded-md bg-[var(--accent-muted)]">
|
|
28
|
-
<svg className="w-4 h-4 text-[var(--accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
28
|
+
<svg aria-hidden="true" className="w-4 h-4 text-[var(--accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
29
29
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
|
30
30
|
</svg>
|
|
31
31
|
</span>
|
|
@@ -41,7 +41,7 @@ export function DocsSidebar({ tree }: DocsSidebarProps) {
|
|
|
41
41
|
className="flex items-center gap-3 py-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
42
42
|
>
|
|
43
43
|
<span className="flex items-center justify-center w-7 h-7 rounded-md bg-gray-100 dark:bg-gray-800">
|
|
44
|
-
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
44
|
+
<svg aria-hidden="true" className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
45
45
|
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
|
|
46
46
|
</svg>
|
|
47
47
|
</span>
|
|
@@ -56,7 +56,7 @@ export function DocsSidebar({ tree }: DocsSidebarProps) {
|
|
|
56
56
|
className="flex items-center gap-3 py-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
57
57
|
>
|
|
58
58
|
<span className="flex items-center justify-center w-7 h-7 rounded-md bg-gray-100 dark:bg-gray-800">
|
|
59
|
-
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
59
|
+
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
60
60
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
61
61
|
</svg>
|
|
62
62
|
</span>
|
|
@@ -28,10 +28,12 @@ export function Accordion({ title, children, defaultOpen = false }: AccordionPro
|
|
|
28
28
|
<div>
|
|
29
29
|
<button
|
|
30
30
|
onClick={() => setIsOpen(!isOpen)}
|
|
31
|
-
|
|
31
|
+
aria-expanded={isOpen}
|
|
32
|
+
className="flex w-full items-center justify-between px-4 py-4 text-left font-medium text-foreground hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-inset"
|
|
32
33
|
>
|
|
33
34
|
<span>{title}</span>
|
|
34
35
|
<svg
|
|
36
|
+
aria-hidden="true"
|
|
35
37
|
className={cn(
|
|
36
38
|
'w-5 h-5 text-muted-foreground transition-transform duration-200',
|
|
37
39
|
isOpen && 'rotate-180'
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useState, createContext, useContext } from 'react'
|
|
3
|
+
import { useState, createContext, useContext, Children, isValidElement, useId } from 'react'
|
|
4
4
|
import { cn } from '@/lib/utils'
|
|
5
5
|
|
|
6
6
|
interface TabsContextValue {
|
|
7
7
|
activeTab: string
|
|
8
8
|
setActiveTab: (tab: string) => void
|
|
9
|
+
tabsId: string
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
const TabsContext = createContext<TabsContextValue | null>(null)
|
|
@@ -17,10 +18,87 @@ interface TabsProps {
|
|
|
17
18
|
|
|
18
19
|
export function Tabs({ children, defaultValue }: TabsProps) {
|
|
19
20
|
const [activeTab, setActiveTab] = useState(defaultValue || '')
|
|
21
|
+
const tabsId = useId()
|
|
22
|
+
|
|
23
|
+
// Extract tab titles and content from children
|
|
24
|
+
const tabs: { title: string; content: React.ReactNode }[] = []
|
|
25
|
+
Children.forEach(children, (child) => {
|
|
26
|
+
if (isValidElement<TabProps>(child) && child.props.title) {
|
|
27
|
+
tabs.push({
|
|
28
|
+
title: child.props.title,
|
|
29
|
+
content: child.props.children,
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Set default active tab if not set
|
|
35
|
+
const currentActiveTab = activeTab || (tabs[0]?.title ?? '')
|
|
20
36
|
|
|
21
37
|
return (
|
|
22
|
-
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
|
|
23
|
-
<div className="my-6">
|
|
38
|
+
<TabsContext.Provider value={{ activeTab: currentActiveTab, setActiveTab, tabsId }}>
|
|
39
|
+
<div className="my-6">
|
|
40
|
+
{/* Tab list */}
|
|
41
|
+
<div role="tablist" aria-label="Tabs" className="flex border-b border-border">
|
|
42
|
+
{tabs.map((tab, index) => {
|
|
43
|
+
const isActive = currentActiveTab === tab.title
|
|
44
|
+
const tabId = `${tabsId}-tab-${index}`
|
|
45
|
+
const panelId = `${tabsId}-panel-${index}`
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<button
|
|
49
|
+
key={tab.title}
|
|
50
|
+
role="tab"
|
|
51
|
+
id={tabId}
|
|
52
|
+
aria-selected={isActive}
|
|
53
|
+
aria-controls={panelId}
|
|
54
|
+
tabIndex={isActive ? 0 : -1}
|
|
55
|
+
onClick={() => setActiveTab(tab.title)}
|
|
56
|
+
onKeyDown={(e) => {
|
|
57
|
+
if (e.key === 'ArrowRight') {
|
|
58
|
+
e.preventDefault()
|
|
59
|
+
const nextIndex = (index + 1) % tabs.length
|
|
60
|
+
setActiveTab(tabs[nextIndex].title)
|
|
61
|
+
} else if (e.key === 'ArrowLeft') {
|
|
62
|
+
e.preventDefault()
|
|
63
|
+
const prevIndex = (index - 1 + tabs.length) % tabs.length
|
|
64
|
+
setActiveTab(tabs[prevIndex].title)
|
|
65
|
+
}
|
|
66
|
+
}}
|
|
67
|
+
className={cn(
|
|
68
|
+
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
|
69
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2',
|
|
70
|
+
isActive
|
|
71
|
+
? 'border-[var(--accent)] text-[var(--accent)]'
|
|
72
|
+
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
73
|
+
)}
|
|
74
|
+
>
|
|
75
|
+
{tab.title}
|
|
76
|
+
</button>
|
|
77
|
+
)
|
|
78
|
+
})}
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{/* Tab panels */}
|
|
82
|
+
{tabs.map((tab, index) => {
|
|
83
|
+
const isActive = currentActiveTab === tab.title
|
|
84
|
+
const tabId = `${tabsId}-tab-${index}`
|
|
85
|
+
const panelId = `${tabsId}-panel-${index}`
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
key={tab.title}
|
|
90
|
+
role="tabpanel"
|
|
91
|
+
id={panelId}
|
|
92
|
+
aria-labelledby={tabId}
|
|
93
|
+
hidden={!isActive}
|
|
94
|
+
tabIndex={0}
|
|
95
|
+
className={cn('pt-4 [&>pre]:mt-0', !isActive && 'hidden')}
|
|
96
|
+
>
|
|
97
|
+
{tab.content}
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
})}
|
|
101
|
+
</div>
|
|
24
102
|
</TabsContext.Provider>
|
|
25
103
|
)
|
|
26
104
|
}
|
|
@@ -30,31 +108,9 @@ interface TabProps {
|
|
|
30
108
|
children: React.ReactNode
|
|
31
109
|
}
|
|
32
110
|
|
|
111
|
+
// Tab is now just a data container, rendering is handled by Tabs
|
|
33
112
|
export function Tab({ title, children }: TabProps) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const { activeTab, setActiveTab } = context
|
|
38
|
-
const isActive = activeTab === title || (!activeTab && title)
|
|
39
|
-
|
|
40
|
-
return (
|
|
41
|
-
<>
|
|
42
|
-
<button
|
|
43
|
-
onClick={() => setActiveTab(title)}
|
|
44
|
-
className={cn(
|
|
45
|
-
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
|
46
|
-
isActive
|
|
47
|
-
? 'border-[var(--accent)] text-[var(--accent)]'
|
|
48
|
-
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
49
|
-
)}
|
|
50
|
-
>
|
|
51
|
-
{title}
|
|
52
|
-
</button>
|
|
53
|
-
{isActive && (
|
|
54
|
-
<div className="pt-4 [&>pre]:mt-0">
|
|
55
|
-
{children}
|
|
56
|
-
</div>
|
|
57
|
-
)}
|
|
58
|
-
</>
|
|
59
|
-
)
|
|
113
|
+
// This component is used for data extraction only
|
|
114
|
+
// Actual rendering happens in Tabs component
|
|
115
|
+
return null
|
|
60
116
|
}
|
|
@@ -135,7 +135,7 @@ export function MobileSidebar({ tree, isOpen, onClose }: MobileSidebarProps) {
|
|
|
135
135
|
className="p-2 -mr-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
|
|
136
136
|
aria-label="Close menu"
|
|
137
137
|
>
|
|
138
|
-
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
138
|
+
<svg aria-hidden="true" className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
139
139
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
140
140
|
</svg>
|
|
141
141
|
</button>
|
|
@@ -153,7 +153,7 @@ export function MobileSidebar({ tree, isOpen, onClose }: MobileSidebarProps) {
|
|
|
153
153
|
className="flex items-center gap-3 py-2 px-2 text-sm text-[var(--accent)] font-medium hover:bg-[var(--accent-muted)] rounded-md transition-colors min-h-[44px]"
|
|
154
154
|
>
|
|
155
155
|
<span className="flex items-center justify-center w-7 h-7 rounded-md bg-[var(--accent-muted)]">
|
|
156
|
-
<svg className="w-4 h-4 text-[var(--accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
156
|
+
<svg aria-hidden="true" className="w-4 h-4 text-[var(--accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
157
157
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
|
158
158
|
</svg>
|
|
159
159
|
</span>
|
|
@@ -169,7 +169,7 @@ export function MobileSidebar({ tree, isOpen, onClose }: MobileSidebarProps) {
|
|
|
169
169
|
className="flex items-center gap-3 py-2 px-2 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors min-h-[44px]"
|
|
170
170
|
>
|
|
171
171
|
<span className="flex items-center justify-center w-7 h-7 rounded-md bg-muted">
|
|
172
|
-
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
172
|
+
<svg aria-hidden="true" className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
173
173
|
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
|
|
174
174
|
</svg>
|
|
175
175
|
</span>
|
|
@@ -184,7 +184,7 @@ export function MobileSidebar({ tree, isOpen, onClose }: MobileSidebarProps) {
|
|
|
184
184
|
className="flex items-center gap-3 py-2 px-2 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors min-h-[44px]"
|
|
185
185
|
>
|
|
186
186
|
<span className="flex items-center justify-center w-7 h-7 rounded-md bg-muted">
|
|
187
|
-
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
187
|
+
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
188
188
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
189
189
|
</svg>
|
|
190
190
|
</span>
|
|
@@ -243,10 +243,12 @@ function MobileSidebarNode({ node, pathname, onNavigate }: MobileSidebarNodeProp
|
|
|
243
243
|
<div>
|
|
244
244
|
<button
|
|
245
245
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
246
|
-
|
|
246
|
+
aria-expanded={isExpanded}
|
|
247
|
+
className="flex items-center justify-between w-full py-2 px-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors min-h-[44px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-inset"
|
|
247
248
|
>
|
|
248
249
|
<span>{node.name}</span>
|
|
249
250
|
<svg
|
|
251
|
+
aria-hidden="true"
|
|
250
252
|
className={cn('w-4 h-4 transition-transform', isExpanded && 'rotate-90')}
|
|
251
253
|
fill="none"
|
|
252
254
|
stroke="currentColor"
|
|
@@ -31,6 +31,7 @@ export function SearchTrigger() {
|
|
|
31
31
|
<button
|
|
32
32
|
type="button"
|
|
33
33
|
onClick={() => setOpen(true)}
|
|
34
|
+
aria-haspopup="dialog"
|
|
34
35
|
className={cn(
|
|
35
36
|
'flex items-center gap-3 px-4 py-2.5 rounded-lg w-full max-w-md',
|
|
36
37
|
'bg-muted/50 border border-border/50',
|
|
@@ -198,7 +199,7 @@ function SearchDialog({ onClose }: SearchDialogProps) {
|
|
|
198
199
|
<p>Searching...</p>
|
|
199
200
|
</div>
|
|
200
201
|
) : results.length > 0 ? (
|
|
201
|
-
<ul ref={resultsRef} className="py-2">
|
|
202
|
+
<ul ref={resultsRef} role="listbox" aria-label="Search results" className="py-2">
|
|
202
203
|
{results.map((result, index) => {
|
|
203
204
|
// Build breadcrumb path
|
|
204
205
|
const breadcrumbPath = result.breadcrumbs && result.breadcrumbs.length > 0
|
|
@@ -211,8 +212,11 @@ function SearchDialog({ onClose }: SearchDialogProps) {
|
|
|
211
212
|
type="button"
|
|
212
213
|
onClick={() => handleSelect(result.url)}
|
|
213
214
|
onMouseEnter={() => setSelectedIndex(index)}
|
|
215
|
+
role="option"
|
|
216
|
+
aria-selected={selectedIndex === index}
|
|
214
217
|
className={cn(
|
|
215
|
-
'w-full px-4 py-3 text-left transition-colors
|
|
218
|
+
'w-full px-4 py-3 text-left transition-colors',
|
|
219
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--accent)]',
|
|
216
220
|
selectedIndex === index
|
|
217
221
|
? 'bg-gray-100 dark:bg-gray-800'
|
|
218
222
|
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50'
|
|
@@ -265,6 +269,7 @@ function SearchDialog({ onClose }: SearchDialogProps) {
|
|
|
265
269
|
function SearchIcon({ className }: { className?: string }) {
|
|
266
270
|
return (
|
|
267
271
|
<svg
|
|
272
|
+
aria-hidden="true"
|
|
268
273
|
className={className}
|
|
269
274
|
fill="none"
|
|
270
275
|
viewBox="0 0 24 24"
|
|
@@ -12,21 +12,21 @@ export function ThemeToggle() {
|
|
|
12
12
|
}, [])
|
|
13
13
|
|
|
14
14
|
if (!mounted) {
|
|
15
|
-
return <div className="w-
|
|
15
|
+
return <div className="w-11 h-11" />
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
return (
|
|
19
19
|
<button
|
|
20
20
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
|
21
|
-
className="flex items-center justify-center w-
|
|
21
|
+
className="flex items-center justify-center w-11 h-11 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
|
22
22
|
aria-label="Toggle theme"
|
|
23
23
|
>
|
|
24
24
|
{theme === 'dark' ? (
|
|
25
|
-
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
25
|
+
<svg aria-hidden="true" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
26
26
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
27
27
|
</svg>
|
|
28
28
|
) : (
|
|
29
|
-
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
29
|
+
<svg aria-hidden="true" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
30
30
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
31
31
|
</svg>
|
|
32
32
|
)}
|