doccupine 0.0.114 → 0.0.116

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 CHANGED
@@ -9,7 +9,7 @@ import chalk from "chalk";
9
9
  import { appStructure, obsoleteFiles, startingDocsStructure, } from "./lib/structures.js";
10
10
  import { rootLayoutTemplate, siteLayoutTemplate } from "./lib/layout.js";
11
11
  import { ConfigManager } from "./lib/config-manager.js";
12
- import { findAvailablePort, generateSlug, getFullSlug, escapeTemplateContent, } from "./lib/utils.js";
12
+ import { findAvailablePort, generateSlug, getFullSlug, escapeTemplateContent, resolvePackageManager, } from "./lib/utils.js";
13
13
  import { generateMetadataBlock, generateRuntimeOnlyMetadataBlock, generateJsonLdScript, } from "./lib/metadata.js";
14
14
  import { nextConfigTemplate } from "./templates/next.config.js";
15
15
  import { pnpmWorkspaceTemplate } from "./templates/pnpmWorkspace.js";
@@ -60,9 +60,6 @@ class MDXToNextJSGenerator {
60
60
  await fs.ensureDir(this.outputDir);
61
61
  this.sectionsConfig = await this.resolveSections();
62
62
  this.analyticsConfig = await this.loadAnalyticsConfig();
63
- if (this.sectionsConfig) {
64
- console.log(chalk.blue(`šŸ“‘ Found ${this.sectionsConfig.length} section(s): ${this.sectionsConfig.map((s) => s.label).join(", ")}`));
65
- }
66
63
  if (this.analyticsConfig) {
67
64
  console.log(chalk.blue(`šŸ“Š Analytics enabled: ${this.analyticsConfig.provider}`));
68
65
  }
@@ -72,8 +69,16 @@ class MDXToNextJSGenerator {
72
69
  await this.copyFontConfig();
73
70
  await this.copyAnalyticsConfig();
74
71
  await this.copyPublicFiles();
72
+ // createStartingDocs() may have written the sample docs - which carry
73
+ // section frontmatter - after the initial resolveSections() ran against an
74
+ // empty watch dir. Re-resolve now that every MDX file is on disk so the
75
+ // build applies the correct sections in a single O(n) pass, instead of
76
+ // rediscovering them per file (the old O(n²) behavior).
77
+ this.sectionsConfig = await this.resolveSections();
78
+ if (this.sectionsConfig) {
79
+ console.log(chalk.blue(`šŸ“‘ Found ${this.sectionsConfig.length} section(s): ${this.sectionsConfig.map((s) => s.label).join(", ")}`));
80
+ }
75
81
  await this.processAllMDXFiles();
76
- await this.generateSectionIndexPages();
77
82
  console.log(chalk.green("āœ… Initial setup complete!"));
78
83
  console.log(chalk.cyan("šŸ’” To start the Next.js dev server:"));
79
84
  console.log(chalk.white(` cd ${path.relative(process.cwd(), this.outputDir)}`));
@@ -260,7 +265,6 @@ class MDXToNextJSGenerator {
260
265
  console.log(chalk.cyan("šŸ“‘ Sections configuration changed"));
261
266
  this.sectionsConfig = await this.resolveSections();
262
267
  await this.processAllMDXFiles();
263
- await this.generateSectionIndexPages();
264
268
  }
265
269
  async maybeUpdateSections() {
266
270
  if (this.isReprocessing)
@@ -278,8 +282,9 @@ class MDXToNextJSGenerator {
278
282
  this.sectionsConfig = newSections;
279
283
  this.isReprocessing = true;
280
284
  try {
285
+ // processAllMDXFiles() already refreshes section index pages via its
286
+ // aggregate pass, so no separate generateSectionIndexPages() here.
281
287
  await this.processAllMDXFiles();
282
- await this.generateSectionIndexPages();
283
288
  }
284
289
  finally {
285
290
  this.isReprocessing = false;
@@ -681,36 +686,58 @@ class MDXToNextJSGenerator {
681
686
  const files = await this.getAllMDXFiles();
682
687
  return Promise.all(files.map((file) => this.parseMDXFile(file)));
683
688
  }
689
+ /**
690
+ * Writes the generated page(s) for a single MDX file: the doc page and, for a
691
+ * section-index file, the section landing page. Deliberately does NOT run the
692
+ * site-wide aggregations (pages index, layout, sitemap, llms, section
693
+ * redirects) - the caller batches those so a bulk build runs them once at the
694
+ * end instead of once per file (which is what made large builds O(n²)).
695
+ */
696
+ async writePageForFile(filePath) {
697
+ const fullPath = path.join(this.watchDir, filePath);
698
+ const content = await fs.readFile(fullPath, "utf8");
699
+ const { data: frontmatter, content: mdxContent } = matter(content);
700
+ const { sectionSlug, pageSlug } = this.determineSectionForFile(filePath, frontmatter);
701
+ const fullSlug = getFullSlug(pageSlug, sectionSlug);
702
+ const isIndex = filePath === "index.mdx" || filePath === "./index.mdx";
703
+ const isSectionIndex = this.sectionsConfig && pageSlug === "" && sectionSlug !== "";
704
+ if (isIndex) {
705
+ // The homepage is emitted by updatePagesIndex() in the aggregate pass, so
706
+ // there is no per-file page to write here.
707
+ console.log(chalk.blue("šŸ  Updating homepage with index.mdx content"));
708
+ }
709
+ else {
710
+ const mdxFile = {
711
+ path: filePath,
712
+ content: mdxContent,
713
+ frontmatter,
714
+ slug: fullSlug,
715
+ };
716
+ await this.generatePageFromMDX(mdxFile);
717
+ }
718
+ if (isSectionIndex) {
719
+ await this.updateSectionIndex(sectionSlug, frontmatter, mdxContent);
720
+ }
721
+ }
722
+ /**
723
+ * Regenerates every file that depends on the full set of pages (pages index,
724
+ * root/site layout, sitemap, llms files, section redirects). Parses all MDX
725
+ * exactly once and threads the result through each generator, so one refresh
726
+ * is a single scan rather than one scan per generator.
727
+ */
728
+ async refreshSiteAggregates() {
729
+ const pages = await this.buildAllPagesMeta();
730
+ await this.updatePagesIndex();
731
+ await this.updateRootLayout(pages);
732
+ await this.updateSitemap(pages);
733
+ await this.updateLlmsFiles(pages);
734
+ await this.generateSectionIndexPages(pages);
735
+ }
684
736
  async handleFileChange(action, filePath) {
685
737
  console.log(chalk.cyan(`šŸ“ File ${action}: ${filePath}`));
686
- const fullPath = path.join(this.watchDir, filePath);
687
738
  try {
688
- const content = await fs.readFile(fullPath, "utf8");
689
- const { data: frontmatter, content: mdxContent } = matter(content);
690
- const { sectionSlug, pageSlug } = this.determineSectionForFile(filePath, frontmatter);
691
- const fullSlug = getFullSlug(pageSlug, sectionSlug);
692
- const isIndex = filePath === "index.mdx" || filePath === "./index.mdx";
693
- const isSectionIndex = this.sectionsConfig && pageSlug === "" && sectionSlug !== "";
694
- if (isIndex) {
695
- console.log(chalk.blue("šŸ  Updating homepage with index.mdx content"));
696
- }
697
- else {
698
- const mdxFile = {
699
- path: filePath,
700
- content: mdxContent,
701
- frontmatter,
702
- slug: fullSlug,
703
- };
704
- await this.generatePageFromMDX(mdxFile);
705
- }
706
- if (isSectionIndex) {
707
- await this.updateSectionIndex(sectionSlug, frontmatter, mdxContent);
708
- }
709
- await this.updatePagesIndex();
710
- await this.updateRootLayout();
711
- await this.updateSitemap();
712
- await this.updateLlmsFiles();
713
- await this.generateSectionIndexPages();
739
+ await this.writePageForFile(filePath);
740
+ await this.refreshSiteAggregates();
714
741
  console.log(chalk.green(`āœ… Generated page for: ${filePath}`));
715
742
  await this.maybeUpdateSections();
716
743
  }
@@ -744,9 +771,21 @@ class MDXToNextJSGenerator {
744
771
  }
745
772
  async processAllMDXFiles() {
746
773
  const files = await this.getAllMDXFiles();
774
+ // Write each page first (the only genuinely per-file work), then run the
775
+ // site-wide aggregations a single time. Doing the aggregations per file
776
+ // re-scanned and re-parsed every MDX file on each iteration, which made a
777
+ // full build O(n²); batching them makes it O(n). A single bad file is
778
+ // logged and skipped so it never aborts the whole build.
747
779
  for (const file of files) {
748
- await this.handleFileChange("processed", file);
780
+ console.log(chalk.cyan(`šŸ“ Processing: ${file}`));
781
+ try {
782
+ await this.writePageForFile(file);
783
+ }
784
+ catch (error) {
785
+ console.error(chalk.red(`āŒ Error processing ${file}:`), error);
786
+ }
749
787
  }
788
+ await this.refreshSiteAggregates();
750
789
  }
751
790
  async getAllMDXFiles() {
752
791
  const files = [];
@@ -771,23 +810,23 @@ class MDXToNextJSGenerator {
771
810
  const analyticsEnabled = this.analyticsConfig !== null;
772
811
  return rootLayoutTemplate(fontConfig, analyticsEnabled);
773
812
  }
774
- async generateSiteLayout() {
775
- const pages = await this.buildAllPagesMeta();
776
- return siteLayoutTemplate(pages, this.sectionsConfig);
813
+ async generateSiteLayout(pages) {
814
+ const resolvedPages = pages ?? (await this.buildAllPagesMeta());
815
+ return siteLayoutTemplate(resolvedPages, this.sectionsConfig);
777
816
  }
778
- async generateSectionIndexPages() {
817
+ async generateSectionIndexPages(pages) {
779
818
  if (!this.sectionsConfig || this.sectionsConfig.length === 0)
780
819
  return;
781
- const pages = await this.buildAllPagesMeta();
820
+ const resolvedPages = pages ?? (await this.buildAllPagesMeta());
782
821
  for (const section of this.sectionsConfig) {
783
822
  if (section.slug === "")
784
823
  continue;
785
824
  // Check if a page already exists at the section root
786
- const hasIndex = pages.some((p) => p.slug === section.slug);
825
+ const hasIndex = resolvedPages.some((p) => p.slug === section.slug);
787
826
  if (hasIndex)
788
827
  continue;
789
828
  // Find the first page in this section
790
- const sectionPages = pages
829
+ const sectionPages = resolvedPages
791
830
  .filter((p) => p.section === section.slug)
792
831
  .sort((a, b) => {
793
832
  if (a.categoryOrder !== b.categoryOrder)
@@ -983,11 +1022,11 @@ export default function Page() {
983
1022
  await fs.ensureDir(path.dirname(pagePath));
984
1023
  await fs.writeFile(pagePath, indexContent, "utf8");
985
1024
  }
986
- async updateRootLayout() {
1025
+ async updateRootLayout(pages) {
987
1026
  await fs.writeFile(path.join(this.outputDir, "app", "layout.tsx"), await this.generateRootLayout(), "utf8");
988
1027
  const siteLayoutPath = path.join(this.outputDir, "app", "(site)", "layout.tsx");
989
1028
  await fs.ensureDir(path.dirname(siteLayoutPath));
990
- await fs.writeFile(siteLayoutPath, await this.generateSiteLayout(), "utf8");
1029
+ await fs.writeFile(siteLayoutPath, await this.generateSiteLayout(pages), "utf8");
991
1030
  }
992
1031
  async loadSiteUrl() {
993
1032
  const configPath = path.join(this.rootDir, "config.json");
@@ -1033,7 +1072,7 @@ export default function Page() {
1033
1072
  }
1034
1073
  return entries;
1035
1074
  }
1036
- async updateSitemap() {
1075
+ async updateSitemap(pages) {
1037
1076
  const sitemapPath = path.join(this.outputDir, "app", "sitemap.ts");
1038
1077
  const siteUrl = await this.loadSiteUrl();
1039
1078
  if (!siteUrl) {
@@ -1043,8 +1082,8 @@ export default function Page() {
1043
1082
  }
1044
1083
  return;
1045
1084
  }
1046
- const pages = await this.buildAllPagesMeta();
1047
- const entries = this.buildSitemapEntries(pages);
1085
+ const resolvedPages = pages ?? (await this.buildAllPagesMeta());
1086
+ const entries = this.buildSitemapEntries(resolvedPages);
1048
1087
  await fs.writeFile(sitemapPath, sitemapTemplate(entries), "utf8");
1049
1088
  console.log(chalk.green(`šŸ—ŗļø Generated sitemap.ts with ${entries.length} page(s) using ${siteUrl}`));
1050
1089
  }
@@ -1116,17 +1155,17 @@ export default function Page() {
1116
1155
  const json = JSON.stringify(payload, null, 2) + "\n";
1117
1156
  await fs.writeFile(manifestPath, json, "utf8");
1118
1157
  }
1119
- async updateLlmsFiles() {
1158
+ async updateLlmsFiles(pages) {
1120
1159
  const publicDir = path.join(this.outputDir, "public");
1121
1160
  await fs.ensureDir(publicDir);
1122
1161
  const { url: baseUrl, name, description } = await this.loadSiteMetadata();
1123
- const pages = await this.buildAllPagesMeta();
1124
- const pagesWithBodies = await Promise.all(pages.map((page) => this.readPageWithBody(page)));
1162
+ const resolvedPages = pages ?? (await this.buildAllPagesMeta());
1163
+ const pagesWithBodies = await Promise.all(resolvedPages.map((page) => this.readPageWithBody(page)));
1125
1164
  const indexContent = llmsIndexTemplate({
1126
1165
  siteName: name,
1127
1166
  siteDescription: description,
1128
1167
  baseUrl,
1129
- pages,
1168
+ pages: resolvedPages,
1130
1169
  sectionsConfig: this.sectionsConfig,
1131
1170
  });
1132
1171
  const fullContent = llmsFullTemplate({
@@ -1167,7 +1206,7 @@ export default function Page() {
1167
1206
  }
1168
1207
  this.generatedLlmsPagePaths = nextRelativePaths;
1169
1208
  await this.writeLlmsManifest(nextRelativePaths);
1170
- console.log(chalk.green(`šŸ¤– Generated llms.txt and llms-full.txt with ${pages.length} page(s)${baseUrl ? ` using ${baseUrl}` : " (relative URLs)"}`));
1209
+ console.log(chalk.green(`šŸ¤– Generated llms.txt and llms-full.txt with ${resolvedPages.length} page(s)${baseUrl ? ` using ${baseUrl}` : " (relative URLs)"}`));
1171
1210
  }
1172
1211
  async stop() {
1173
1212
  if (this.watcher) {
@@ -1205,6 +1244,7 @@ program
1205
1244
  .option("--port <port>", "Port for Next.js dev server", "3000")
1206
1245
  .option("--verbose", "Show verbose output")
1207
1246
  .option("--reset", "Reset configuration and prompt for new directories")
1247
+ .option("--package-manager <name>", "Package manager for the generated app: pnpm or npm (default: auto-detect)")
1208
1248
  .action(async (options) => {
1209
1249
  const configManager = new ConfigManager();
1210
1250
  const config = await configManager.getConfig({
@@ -1215,18 +1255,13 @@ program
1215
1255
  await generator.init();
1216
1256
  let devServer = null;
1217
1257
  console.log(chalk.blue("šŸ“¦ Installing dependencies..."));
1218
- const { spawn, execSync } = await import("child_process");
1219
- // Check if pnpm is available, fallback to npm
1220
- let packageManager = "npm";
1221
- try {
1222
- execSync("pnpm --version", { stdio: "ignore" });
1223
- packageManager = "pnpm";
1224
- }
1225
- catch {
1226
- // pnpm not available, use npm
1227
- }
1228
- console.log(chalk.blue(`šŸ“¦ Using ${packageManager}...`));
1229
- const install = spawn(packageManager, ["install"], {
1258
+ const { spawn } = await import("child_process");
1259
+ // Prefer pnpm (the generated app ships a pnpm workspace) and fall back to
1260
+ // npm. A --package-manager flag or a "packageManager" field in
1261
+ // doccupine.json overrides detection; the flag wins.
1262
+ const packageManager = resolvePackageManager(options.packageManager ?? config.packageManager);
1263
+ console.log(chalk.blue(`šŸ“¦ Using ${packageManager.name}...`));
1264
+ const install = spawn(packageManager.bin, ["install"], {
1230
1265
  cwd: config.outputDir,
1231
1266
  stdio: "inherit",
1232
1267
  });
@@ -1237,7 +1272,7 @@ program
1237
1272
  resolve(void 0);
1238
1273
  }
1239
1274
  else {
1240
- reject(new Error(`${packageManager} install failed with code ${code}`));
1275
+ reject(new Error(`${packageManager.name} install failed with code ${code}`));
1241
1276
  }
1242
1277
  });
1243
1278
  install.on("error", reject);
@@ -1248,10 +1283,10 @@ program
1248
1283
  }
1249
1284
  console.log(chalk.blue(`šŸš€ Starting Next.js dev server on port ${port}...`));
1250
1285
  const portStr = String(port);
1251
- const devArgs = packageManager === "npm"
1286
+ const devArgs = packageManager.name === "npm"
1252
1287
  ? ["run", "dev", "--", "--port", portStr]
1253
1288
  : ["run", "dev", "--port", portStr];
1254
- devServer = spawn(packageManager, devArgs, {
1289
+ devServer = spawn(packageManager.bin, devArgs, {
1255
1290
  cwd: config.outputDir,
1256
1291
  stdio: ["ignore", "pipe", "pipe"],
1257
1292
  });
@@ -27,6 +27,10 @@ export interface DoccupineConfig {
27
27
  watchDir: string;
28
28
  outputDir: string;
29
29
  port: string;
30
+ /** Force the package manager used to install and run the generated app
31
+ * ("pnpm" or "npm"), overriding auto-detection. Also settable per run via
32
+ * the `--package-manager` flag, which takes precedence over this field. */
33
+ packageManager?: string;
30
34
  }
31
35
  export interface FontConfig {
32
36
  [key: string]: any;
@@ -1,3 +1,19 @@
1
+ export type PackageManagerName = "pnpm" | "npm";
2
+ export interface ResolvedPackageManager {
3
+ name: PackageManagerName;
4
+ /** Absolute path when we can resolve one, otherwise the bare command name.
5
+ * Spawning the resolved path (not the bare name) is what lets pnpm work
6
+ * even when its install dir is missing from the process PATH. */
7
+ bin: string;
8
+ }
9
+ /**
10
+ * Chooses the package manager for the generated app. The app ships a pnpm
11
+ * workspace, so pnpm is preferred whenever it can be found; npm is the fallback.
12
+ * An explicit `override` (from `--package-manager` or doccupine.json) always
13
+ * wins. Auto-detection also trusts `npm_config_user_agent` when pnpm launched
14
+ * the CLI.
15
+ */
16
+ export declare function resolvePackageManager(override?: string): ResolvedPackageManager;
1
17
  export declare function findAvailablePort(startPort: number): Promise<number>;
2
18
  export declare function generateSlug(filePath: string): string;
3
19
  export declare function getFullSlug(pageSlug: string, sectionSlug: string): string;
package/dist/lib/utils.js CHANGED
@@ -1,3 +1,59 @@
1
+ import fs from "fs-extra";
2
+ import path from "path";
3
+ import { execSync } from "child_process";
4
+ import chalk from "chalk";
5
+ /**
6
+ * Locates a usable pnpm binary without relying solely on the caller's PATH.
7
+ * Standalone pnpm installs (e.g. ~/Library/pnpm) expose PNPM_HOME but only add
8
+ * their bin dir to PATH via an interactive shell rc, so a CLI launched from an
9
+ * IDE terminal or GUI often can't see `pnpm` on PATH. Returns null if pnpm
10
+ * genuinely isn't available.
11
+ */
12
+ function findPnpmBin() {
13
+ const exe = process.platform === "win32" ? "pnpm.exe" : "pnpm";
14
+ const home = process.env.PNPM_HOME;
15
+ if (home) {
16
+ const candidate = path.join(home, exe);
17
+ if (fs.existsSync(candidate))
18
+ return candidate;
19
+ }
20
+ try {
21
+ execSync("pnpm --version", { stdio: "ignore" });
22
+ return "pnpm";
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ /**
29
+ * Chooses the package manager for the generated app. The app ships a pnpm
30
+ * workspace, so pnpm is preferred whenever it can be found; npm is the fallback.
31
+ * An explicit `override` (from `--package-manager` or doccupine.json) always
32
+ * wins. Auto-detection also trusts `npm_config_user_agent` when pnpm launched
33
+ * the CLI.
34
+ */
35
+ export function resolvePackageManager(override) {
36
+ const requested = override?.trim().toLowerCase();
37
+ if (requested === "npm")
38
+ return { name: "npm", bin: "npm" };
39
+ if (requested === "pnpm") {
40
+ const bin = findPnpmBin();
41
+ if (bin)
42
+ return { name: "pnpm", bin };
43
+ console.warn(chalk.yellow("āš ļø packageManager is set to pnpm but pnpm could not be found; using npm."));
44
+ return { name: "npm", bin: "npm" };
45
+ }
46
+ if (requested) {
47
+ console.warn(chalk.yellow(`āš ļø Unknown packageManager "${override}" (expected "pnpm" or "npm"); auto-detecting instead.`));
48
+ }
49
+ const launchedByPnpm = (process.env.npm_config_user_agent || "").startsWith("pnpm");
50
+ if (launchedByPnpm)
51
+ return { name: "pnpm", bin: "pnpm" };
52
+ const bin = findPnpmBin();
53
+ if (bin)
54
+ return { name: "pnpm", bin };
55
+ return { name: "npm", bin: "npm" };
56
+ }
1
57
  export async function findAvailablePort(startPort) {
2
58
  const net = await import("net");
3
59
  return new Promise((resolve) => {
@@ -1 +1 @@
1
- export declare const calloutTemplate = "\"use client\";\nimport { Theme } from \"@/app/theme\";\nimport { styledSmall } from \"cherry-styled-components\";\nimport styled, { css } from \"styled-components\";\nimport { Icon, IconProps } from \"@/components/layout/Icon\";\n\ntype CalloutType = \"note\" | \"info\" | \"warning\" | \"danger\" | \"success\";\n\nconst StyledCallout = styled.div<{ theme: Theme; $type?: CalloutType }>`\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n padding: 20px;\n margin: 0;\n ${({ theme }) => styledSmall(theme)}\n color: ${({ theme }) => theme.colors.grayDark};\n display: flex;\n\n & svg {\n vertical-align: middle;\n min-width: min-content;\n margin: 3px 10px 0 0;\n }\n\n /* Callout tints are alert-semantic (info-blue, warning-amber, danger-red,\n etc.) and intentionally independent of theme.json. Light-mode values are\n defined by default; dark-mode overrides live in :root.dark & blocks so\n the swap happens via the active <html> class with no re-render. */\n ${({ $type }) =>\n $type === \"note\" &&\n css`\n border-color: #0ea5e933;\n background: #f0f9ff80;\n\n & svg.lucide,\n & p {\n color: #0c4a6e;\n }\n\n :root.dark & {\n border-color: #0ea5e94d;\n background: #0ea5e91a;\n\n & svg.lucide,\n & p {\n color: #bae6fd;\n }\n }\n `}\n\n ${({ $type }) =>\n $type === \"info\" &&\n css`\n border-color: #71717a33;\n background: #fafafa80;\n\n & svg.lucide,\n & .lucide,\n & p {\n color: #18181b;\n }\n\n :root.dark & {\n border-color: #71717a4d;\n background: #71717a1a;\n\n & svg.lucide,\n & .lucide,\n & p {\n color: #e4e4e7;\n }\n }\n `}\n\n ${({ $type }) =>\n $type === \"warning\" &&\n css`\n border-color: #f59e0b33;\n background: #fffbeb80;\n\n & svg.lucide,\n & p {\n color: #78350f;\n }\n\n :root.dark & {\n border-color: #f59e0b4d;\n background: #f59e0b1a;\n\n & svg.lucide,\n & p {\n color: #fde68a;\n }\n }\n `}\n\n ${({ $type }) =>\n $type === \"danger\" &&\n css`\n border-color: #ef444433;\n background: #fef2f280;\n\n & svg.lucide,\n & p {\n color: #7f1d1d;\n }\n\n :root.dark & {\n border-color: #ef44444d;\n background: #ef44441a;\n\n & svg.lucide,\n & p {\n color: #fecaca;\n }\n }\n `}\n\n ${({ $type }) =>\n $type === \"success\" &&\n css`\n border-color: #10b98133;\n background: #ecfdf580;\n\n & svg.lucide,\n & p {\n color: #064e3b;\n }\n\n :root.dark & {\n border-color: #10b9814d;\n background: #10b9811a;\n\n & svg.lucide,\n & p {\n color: #a7f3d0;\n }\n }\n `}\n`;\n\nconst StyledChildren = styled.span`\n display: flex;\n flex-direction: column;\n gap: 10px;\n`;\n\ninterface CalloutProps extends React.HTMLAttributes<HTMLDivElement> {\n children: React.ReactNode;\n icon?: IconProps;\n type?: CalloutType;\n}\n\nfunction Callout({ children, type, icon }: CalloutProps) {\n const iconType =\n type === \"note\"\n ? \"CircleAlert\"\n : type === \"info\"\n ? \"Info\"\n : type === \"warning\"\n ? \"TriangleAlert\"\n : type === \"danger\"\n ? \"OctagonAlert\"\n : type === \"success\"\n ? \"Check\"\n : (icon as IconProps);\n return (\n <StyledCallout $type={type}>\n {iconType ? <Icon name={iconType} size={16} /> : null}\n <StyledChildren>{children}</StyledChildren>\n </StyledCallout>\n );\n}\n\nexport { Callout };\n";
1
+ export declare const calloutTemplate = "\"use client\";\nimport { Theme } from \"@/app/theme\";\nimport { styledSmall } from \"cherry-styled-components\";\nimport styled, { css } from \"styled-components\";\nimport { Icon, IconProps } from \"@/components/layout/Icon\";\n\ntype CalloutType = \"note\" | \"info\" | \"warning\" | \"danger\" | \"success\";\n\nconst StyledCallout = styled.div<{ theme: Theme; $type?: CalloutType }>`\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n padding: 20px;\n margin: 0;\n max-width: 100%;\n ${({ theme }) => styledSmall(theme)}\n color: ${({ theme }) => theme.colors.grayDark};\n display: flex;\n\n & svg {\n vertical-align: middle;\n min-width: min-content;\n margin: 3px 10px 0 0;\n }\n\n /* Callout tints are alert-semantic (info-blue, warning-amber, danger-red,\n etc.) and intentionally independent of theme.json. Light-mode values are\n defined by default; dark-mode overrides live in :root.dark & blocks so\n the swap happens via the active <html> class with no re-render. */\n ${({ $type }) =>\n $type === \"note\" &&\n css`\n border-color: #0ea5e933;\n background: #f0f9ff80;\n\n & svg.lucide,\n & p {\n color: #0c4a6e;\n }\n\n :root.dark & {\n border-color: #0ea5e94d;\n background: #0ea5e91a;\n\n & svg.lucide,\n & p {\n color: #bae6fd;\n }\n }\n `}\n\n ${({ $type }) =>\n $type === \"info\" &&\n css`\n border-color: #71717a33;\n background: #fafafa80;\n\n & svg.lucide,\n & .lucide,\n & p {\n color: #18181b;\n }\n\n :root.dark & {\n border-color: #71717a4d;\n background: #71717a1a;\n\n & svg.lucide,\n & .lucide,\n & p {\n color: #e4e4e7;\n }\n }\n `}\n\n ${({ $type }) =>\n $type === \"warning\" &&\n css`\n border-color: #f59e0b33;\n background: #fffbeb80;\n\n & svg.lucide,\n & p {\n color: #78350f;\n }\n\n :root.dark & {\n border-color: #f59e0b4d;\n background: #f59e0b1a;\n\n & svg.lucide,\n & p {\n color: #fde68a;\n }\n }\n `}\n\n ${({ $type }) =>\n $type === \"danger\" &&\n css`\n border-color: #ef444433;\n background: #fef2f280;\n\n & svg.lucide,\n & p {\n color: #7f1d1d;\n }\n\n :root.dark & {\n border-color: #ef44444d;\n background: #ef44441a;\n\n & svg.lucide,\n & p {\n color: #fecaca;\n }\n }\n `}\n\n ${({ $type }) =>\n $type === \"success\" &&\n css`\n border-color: #10b98133;\n background: #ecfdf580;\n\n & svg.lucide,\n & p {\n color: #064e3b;\n }\n\n :root.dark & {\n border-color: #10b9814d;\n background: #10b9811a;\n\n & svg.lucide,\n & p {\n color: #a7f3d0;\n }\n }\n `}\n`;\n\nconst StyledChildren = styled.span`\n display: flex;\n flex-direction: column;\n gap: 10px;\n`;\n\ninterface CalloutProps extends React.HTMLAttributes<HTMLDivElement> {\n children: React.ReactNode;\n icon?: IconProps;\n type?: CalloutType;\n}\n\nfunction Callout({ children, type, icon }: CalloutProps) {\n const iconType =\n type === \"note\"\n ? \"CircleAlert\"\n : type === \"info\"\n ? \"Info\"\n : type === \"warning\"\n ? \"TriangleAlert\"\n : type === \"danger\"\n ? \"OctagonAlert\"\n : type === \"success\"\n ? \"Check\"\n : (icon as IconProps);\n return (\n <StyledCallout $type={type}>\n {iconType ? <Icon name={iconType} size={16} /> : null}\n <StyledChildren>{children}</StyledChildren>\n </StyledCallout>\n );\n}\n\nexport { Callout };\n";
@@ -12,6 +12,7 @@ const StyledCallout = styled.div<{ theme: Theme; $type?: CalloutType }>\`
12
12
  border-radius: \${({ theme }) => theme.spacing.radius.lg};
13
13
  padding: 20px;
14
14
  margin: 0;
15
+ max-width: 100%;
15
16
  \${({ theme }) => styledSmall(theme)}
16
17
  color: \${({ theme }) => theme.colors.grayDark};
17
18
  display: flex;
@@ -1 +1 @@
1
- export declare const codeTemplate = "\"use client\";\nimport { useState, useCallback, useMemo, useId } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport {\n interactiveStyles,\n resetButton,\n styledCode,\n} from \"cherry-styled-components\";\nimport { Theme } from \"@/app/theme\";\nimport { unified } from \"unified\";\nimport rehypeParse from \"rehype-parse\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport rehypeStringify from \"rehype-stringify\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { thinScrollbar } from \"@/components/layout/SharedStyled\";\n\ninterface CodeProps extends Omit<\n React.HTMLAttributes<HTMLDivElement>,\n \"theme\"\n> {\n code: string;\n language?: string;\n title?: string;\n theme?: Theme;\n}\n\ninterface CodeTab {\n label: string;\n code: string;\n language?: string;\n}\n\ninterface CodeTabsProps extends Omit<\n React.HTMLAttributes<HTMLDivElement>,\n \"theme\"\n> {\n tabs: CodeTab[];\n theme?: Theme;\n}\n\nconst CodeWrapper = styled.span<{ theme: Theme }>`\n position: relative;\n z-index: 2;\n display: block;\n width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n border: solid 1px rgba(0, 0, 0, 0.1);\n\n :root.dark & {\n border-color: rgba(255, 255, 255, 0.2);\n }\n`;\n\n/* Code block uses a fixed GitHub-style palette in both modes. Independent of\n theme.json so syntax highlighting stays legible regardless of brand colors.\n Dark variants live in :root.dark & blocks so the swap happens via the\n active <html> class with no re-render. */\nconst TopBar = styled.div<{ theme: Theme }>`\n position: relative;\n background: #f6f8fa;\n border-top-left-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-top-right-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-bottom: solid 1px rgba(0, 0, 0, 0.1);\n height: 33px;\n width: 100%;\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 5px;\n padding: 0 10px;\n\n :root.dark & {\n background: #0d1117;\n border-bottom-color: rgba(255, 255, 255, 0.1);\n }\n`;\n\nconst DotsContainer = styled.div`\n display: flex;\n gap: 5px;\n`;\n\nconst Dot = styled.span<{ theme: Theme }>`\n width: 10px;\n height: 10px;\n border-radius: 50%;\n background: rgba(0, 0, 0, 0.1);\n\n :root.dark & {\n background: rgba(255, 255, 255, 0.1);\n }\n`;\n\n/* Icon-only copy button. interactiveStyles supplies the border highlight on\n hover plus the focus/active rings (no scale effect); the GitHub-style\n copied/base colors stay fixed like the rest of the code block. The dark\n block re-declares the hover border because its higher-specificity\n :root.dark & border-color would otherwise override the mixin's hover. */\nconst CopyButton = styled.button<{ theme: Theme; $copied: boolean }>`\n ${resetButton}\n ${interactiveStyles}\n background: ${({ $copied }) =>\n $copied ? \"rgba(45, 164, 78, 0.1)\" : \"transparent\"};\n border-color: ${({ $copied }) => ($copied ? \"#2da44e\" : \"rgba(0, 0, 0, 0.1)\")};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n margin-right: -6px;\n\n & svg.lucide {\n color: ${({ $copied }) => ($copied ? \"#2da44e\" : \"#57606a\")};\n }\n\n :root.dark & {\n background: ${({ $copied }) =>\n $copied ? \"rgba(126, 231, 135, 0.2)\" : \"transparent\"};\n border-color: ${({ $copied }) =>\n $copied ? \"#7ee787\" : \"rgba(255, 255, 255, 0.1)\"};\n\n & svg.lucide {\n color: ${({ $copied }) => ($copied ? \"#7ee787\" : \"#c9d1d9\")};\n }\n\n &:hover {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n }\n`;\n\n/* Centered file name in the TopBar. Sits absolutely over the flex row so the\n dots and copy button keep their edge alignment. Monospace + muted GitHub\n grays, swapped via :root.dark like the rest of the block. pointer-events off\n so clicks fall through to nothing and never steal focus from the buttons. */\nconst TopBarTitle = styled.span<{ theme: Theme }>`\n position: absolute;\n left: 50%;\n transform: translateX(-50%);\n max-width: 60%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n pointer-events: none;\n font-family: ${({ theme }) => theme.fonts.mono};\n font-size: 12px;\n line-height: 2;\n color: #57606a;\n\n :root.dark & {\n color: #8b949e;\n }\n`;\n\n/* Segmented control living in the TopBar for CodeTabs. Scrolls horizontally\n with the thin scrollbar when the labels overflow the 33px bar. */\nconst TabList = styled.div<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 2px;\n min-width: 0;\n overflow-x: auto;\n ${thinScrollbar};\n margin-left: -6px;\n`;\n\n/* Individual tab button. Active tab reads as part of the window: a light body\n fill (#ffffff) that echoes the code area, with a soft border. Inactive tabs\n are muted and transparent. resetButton strips native styling; focus-visible\n draws an inset brand primary ring on a pseudo-element so the scrolling\n TabList can't clip it. All colors stay fixed and swap purely via :root.dark,\n matching the no-re-render palette approach. */\nconst CodeTab = styled.button<{ theme: Theme; $active: boolean }>`\n ${resetButton}\n flex: 0 0 auto;\n position: relative;\n font-family: ${({ theme }) => theme.fonts.mono};\n font-size: 12px;\n line-height: 1;\n padding: 5px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n cursor: pointer;\n white-space: nowrap;\n transition:\n color 0.15s ease,\n background 0.15s ease,\n border-color 0.15s ease;\n border: solid 1px\n ${({ $active }) => ($active ? \"rgba(0, 0, 0, 0.1)\" : \"transparent\")};\n background: ${({ $active }) => ($active ? \"#ffffff\" : \"transparent\")};\n color: ${({ $active }) => ($active ? \"#24292f\" : \"#57606a\")};\n\n &:hover {\n color: #24292f;\n background: ${({ $active }) =>\n $active ? \"#ffffff\" : \"rgba(0, 0, 0, 0.04)\"};\n }\n\n &:focus-visible {\n outline: none;\n }\n\n &:focus-visible::after {\n content: \"\";\n position: absolute;\n inset: 2px;\n border: solid 2px ${({ theme }) => theme.colors.primary};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n pointer-events: none;\n }\n\n :root.dark & {\n border-color: ${({ $active }) =>\n $active ? \"rgba(255, 255, 255, 0.15)\" : \"transparent\"};\n background: ${({ $active }) => ($active ? \"#161b22\" : \"transparent\")};\n color: ${({ $active }) => ($active ? \"#e6edf3\" : \"#8b949e\")};\n\n &:hover {\n color: #e6edf3;\n background: ${({ $active }) =>\n $active ? \"#161b22\" : \"rgba(255, 255, 255, 0.06)\"};\n }\n }\n`;\n\n/* GitHub Light syntax highlighting by default; GitHub Dark in :root.dark.\n Browser resolves which rule wins based on the active <html> class with no\n JS or React re-render involved. */\nconst lightSyntaxHighlight = css`\n & .hljs {\n color: #24292f;\n background: #ffffff;\n min-width: min-content;\n width: 100%;\n display: block;\n padding-right: 20px;\n }\n & .hljs-doctag,\n & .hljs-keyword,\n & .hljs-meta .hljs-keyword,\n & .hljs-template-tag,\n & .hljs-template-variable,\n & .hljs-type,\n & .hljs-variable.language_ {\n color: #cf222e;\n }\n & .hljs-title,\n & .hljs-title.class_,\n & .hljs-title.class_.inherited__,\n & .hljs-title.function_ {\n color: #8250df;\n }\n & .hljs-attr,\n & .hljs-attribute,\n & .hljs-literal,\n & .hljs-meta,\n & .hljs-number,\n & .hljs-operator,\n & .hljs-selector-attr,\n & .hljs-selector-class,\n & .hljs-selector-id,\n & .hljs-variable {\n color: #0550ae;\n }\n & .hljs-meta .hljs-string,\n & .hljs-regexp,\n & .hljs-string {\n color: #0a3069;\n }\n & .hljs-built_in,\n & .hljs-symbol {\n color: #953800;\n }\n & .hljs-code,\n & .hljs-comment,\n & .hljs-formula {\n color: #6e7781;\n }\n & .hljs-name,\n & .hljs-quote,\n & .hljs-selector-pseudo,\n & .hljs-selector-tag {\n color: #116329;\n }\n & .hljs-subst {\n color: #24292f;\n }\n & .hljs-section {\n color: #0550ae;\n font-weight: 700;\n }\n & .hljs-bullet {\n color: #953800;\n }\n & .hljs-emphasis {\n color: #24292f;\n font-style: italic;\n }\n & .hljs-strong {\n color: #24292f;\n font-weight: 700;\n }\n & .hljs-addition {\n color: #116329;\n background-color: #dafbe1;\n }\n & .hljs-deletion {\n color: #82071e;\n background-color: #ffebe9;\n }\n`;\n\nconst darkSyntaxHighlight = css`\n & .hljs {\n color: #c9d1d9;\n background: #0d1117;\n }\n & .hljs-doctag,\n & .hljs-keyword,\n & .hljs-meta .hljs-keyword,\n & .hljs-template-tag,\n & .hljs-template-variable,\n & .hljs-type,\n & .hljs-variable.language_ {\n color: #ff7b72;\n }\n & .hljs-title,\n & .hljs-title.class_,\n & .hljs-title.class_.inherited__,\n & .hljs-title.function_ {\n color: #d2a8ff;\n }\n & .hljs-attr,\n & .hljs-attribute,\n & .hljs-literal,\n & .hljs-meta,\n & .hljs-number,\n & .hljs-operator,\n & .hljs-selector-attr,\n & .hljs-selector-class,\n & .hljs-selector-id,\n & .hljs-variable {\n color: #79c0ff;\n }\n & .hljs-meta .hljs-string,\n & .hljs-regexp,\n & .hljs-string {\n color: #a5d6ff;\n }\n & .hljs-built_in,\n & .hljs-symbol {\n color: #ffa657;\n }\n & .hljs-code,\n & .hljs-comment,\n & .hljs-formula {\n color: #8b949e;\n }\n & .hljs-name,\n & .hljs-quote,\n & .hljs-selector-pseudo,\n & .hljs-selector-tag {\n color: #7ee787;\n }\n & .hljs-subst {\n color: #c9d1d9;\n }\n & .hljs-section {\n color: #1f6feb;\n font-weight: 700;\n }\n & .hljs-bullet {\n color: #f2cc60;\n }\n & .hljs-emphasis {\n color: #c9d1d9;\n font-style: italic;\n }\n & .hljs-strong {\n color: #c9d1d9;\n font-weight: 700;\n }\n & .hljs-addition {\n color: #aff5b4;\n background-color: #033a16;\n }\n & .hljs-deletion {\n color: #ffdcd7;\n background-color: #67060c;\n }\n`;\n\nconst Body = styled.div<{ theme: Theme }>`\n background: #ffffff;\n border-bottom-left-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-bottom-right-radius: ${({ theme }) => theme.spacing.radius.lg};\n color: #24292f;\n padding: 20px;\n font-family: ${({ theme }) => theme.fonts.mono};\n text-align: left;\n overflow-x: auto;\n overflow-y: auto;\n max-height: calc(100dvh - 400px);\n ${thinScrollbar};\n ${({ theme }) => styledCode(theme)};\n ${lightSyntaxHighlight};\n\n /* Diff lines: highlight.js wraps each +/- line in a single span, so\n stretching it to the full row reads like a GitHub diff. inline-block keeps\n the trailing newline as the line break instead of adding an empty row. */\n & .hljs-addition,\n & .hljs-deletion {\n display: inline-table;\n width: 100%;\n }\n\n :root.dark & {\n background: #0d1117;\n color: #ffffff;\n ${darkSyntaxHighlight};\n }\n`;\n\nconst escapeHtml = (unsafe: string): string => {\n return unsafe\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#039;\");\n};\n\nconst sanitizeLanguage = (lang: string): string =>\n lang.replace(/[^a-zA-Z0-9_-]/g, \"\");\n\nconst highlightCode = (code: string, language: string): string => {\n const escapedCode = escapeHtml(code);\n const safeLang = sanitizeLanguage(language);\n const result = unified()\n .use(rehypeParse, { fragment: true })\n .use(rehypeHighlight, {\n detect: true,\n ignoreMissing: true,\n })\n .use(rehypeStringify)\n .processSync(\n `<pre><code class=\"language-${safeLang}\">${escapedCode}</code></pre>`,\n );\n\n return String(result);\n};\n\nfunction Code({\n code,\n language = \"javascript\",\n title,\n theme,\n className,\n}: CodeProps) {\n const [copied, setCopied] = useState(false);\n const highlightedCode = useMemo(\n () => highlightCode(code, language),\n [code, language],\n );\n\n const handleCopy = useCallback(async () => {\n try {\n await navigator.clipboard.writeText(code);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n } catch (err) {\n console.error(\"Failed to copy code:\", err);\n }\n }, [code]);\n\n return (\n <CodeWrapper\n className={`${className ?? \"\"} code-wrapper`.trim()}\n theme={theme}\n >\n <TopBar theme={theme}>\n <DotsContainer>\n <Dot theme={theme} />\n <Dot theme={theme} />\n <Dot theme={theme} />\n </DotsContainer>\n {title ? <TopBarTitle theme={theme}>{title}</TopBarTitle> : null}\n <CopyButton\n onClick={handleCopy}\n $copied={copied}\n theme={theme}\n aria-label={copied ? \"Copied\" : \"Copy code\"}\n >\n <Icon name={copied ? \"check\" : \"copy\"} size={12} />\n </CopyButton>\n </TopBar>\n <Body\n dangerouslySetInnerHTML={{ __html: highlightedCode }}\n theme={theme}\n className=\"code-wrapper-body\"\n />\n </CodeWrapper>\n );\n}\n\n/* Multi-variant code block (npm / pnpm / yarn, etc). Tabs replace the macOS\n dots in the TopBar as a keyboard-accessible tablist; the copy button copies\n whichever tab is active and resets its copied state when the tab changes. */\nfunction CodeTabs({ tabs, theme, className }: CodeTabsProps) {\n const baseId = useId();\n const [active, setActive] = useState(0);\n const [copied, setCopied] = useState(false);\n\n const safeActive = active < tabs.length ? active : 0;\n const activeTab = tabs[safeActive];\n\n const highlightedCode = useMemo(\n () =>\n activeTab\n ? highlightCode(activeTab.code, activeTab.language ?? \"bash\")\n : \"\",\n [activeTab],\n );\n\n const handleSelect = useCallback((index: number) => {\n setActive(index);\n setCopied(false);\n }, []);\n\n const handleCopy = useCallback(async () => {\n if (!activeTab) return;\n try {\n await navigator.clipboard.writeText(activeTab.code);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n } catch (err) {\n console.error(\"Failed to copy code:\", err);\n }\n }, [activeTab]);\n\n const handleKeyDown = useCallback(\n (event: React.KeyboardEvent<HTMLButtonElement>) => {\n let next = safeActive;\n if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") {\n next = (safeActive + 1) % tabs.length;\n } else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") {\n next = (safeActive - 1 + tabs.length) % tabs.length;\n } else if (event.key === \"Home\") {\n next = 0;\n } else if (event.key === \"End\") {\n next = tabs.length - 1;\n } else {\n return;\n }\n event.preventDefault();\n handleSelect(next);\n const target = document.getElementById(`${baseId}-tab-${next}`);\n if (target) target.focus();\n },\n [baseId, handleSelect, safeActive, tabs.length],\n );\n\n if (!tabs || tabs.length === 0) return null;\n\n return (\n <CodeWrapper\n className={`${className ?? \"\"} code-wrapper`.trim()}\n theme={theme}\n >\n <TopBar theme={theme}>\n <TabList role=\"tablist\" aria-label=\"Code variants\">\n {tabs.map((tab, index) => (\n <CodeTab\n key={`${tab.label}-${index}`}\n type=\"button\"\n role=\"tab\"\n id={`${baseId}-tab-${index}`}\n aria-selected={index === safeActive}\n aria-controls={`${baseId}-panel`}\n tabIndex={index === safeActive ? 0 : -1}\n onClick={() => handleSelect(index)}\n onKeyDown={handleKeyDown}\n $active={index === safeActive}\n theme={theme}\n >\n {tab.label}\n </CodeTab>\n ))}\n </TabList>\n <CopyButton\n onClick={handleCopy}\n $copied={copied}\n theme={theme}\n aria-label={copied ? \"Copied\" : \"Copy code\"}\n >\n <Icon name={copied ? \"check\" : \"copy\"} size={12} />\n </CopyButton>\n </TopBar>\n <Body\n id={`${baseId}-panel`}\n role=\"tabpanel\"\n aria-labelledby={`${baseId}-tab-${safeActive}`}\n dangerouslySetInnerHTML={{ __html: highlightedCode }}\n theme={theme}\n className=\"code-wrapper-body\"\n />\n </CodeWrapper>\n );\n}\n\nexport { Code, CodeTabs };\n";
1
+ export declare const codeTemplate = "\"use client\";\nimport { useState, useCallback, useMemo, useId } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport {\n interactiveStyles,\n resetButton,\n styledCode,\n} from \"cherry-styled-components\";\nimport { Theme } from \"@/app/theme\";\nimport { unified } from \"unified\";\nimport rehypeParse from \"rehype-parse\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport rehypeStringify from \"rehype-stringify\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { thinScrollbar } from \"@/components/layout/SharedStyled\";\n\ninterface CodeProps extends Omit<\n React.HTMLAttributes<HTMLDivElement>,\n \"theme\"\n> {\n code: string;\n language?: string;\n title?: string;\n theme?: Theme;\n}\n\ninterface CodeTab {\n label: string;\n code: string;\n language?: string;\n}\n\ninterface CodeTabsProps extends Omit<\n React.HTMLAttributes<HTMLDivElement>,\n \"theme\"\n> {\n tabs: CodeTab[];\n theme?: Theme;\n}\n\nconst CodeWrapper = styled.span<{ theme: Theme }>`\n position: relative;\n z-index: 2;\n display: block;\n width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n background: ${({ theme }) => theme.colors.light};\n`;\n\n/* Code block uses a fixed GitHub-style palette for the syntax tokens in both\n modes so highlighting stays legible regardless of brand colors. The dark-mode\n surface, however, matches the left sidebar's translucent brand tint\n (color-mix of theme.colors.primaryLight over the black page) instead of\n GitHub's #0d1117, so the window sits on the same background as the nav. Dark\n variants live in :root.dark & blocks so the swap happens via the active\n <html> class with no re-render. */\nconst TopBar = styled.div<{ theme: Theme }>`\n position: relative;\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 5%, transparent)`};\n border-top-left-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-top-right-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n height: 33px;\n width: 100%;\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 5px;\n padding: 0 10px;\n`;\n\nconst DotsContainer = styled.div`\n display: flex;\n gap: 5px;\n`;\n\nconst Dot = styled.span<{ theme: Theme }>`\n width: 10px;\n height: 10px;\n border-radius: 50%;\n background: rgba(0, 0, 0, 0.1);\n\n :root.dark & {\n background: rgba(255, 255, 255, 0.1);\n }\n`;\n\n/* Icon-only copy button. interactiveStyles supplies the border highlight on\n hover plus the focus/active rings (no scale effect). Colors come from theme\n tokens (grayLight border, success/grayDark icon) that swap for dark mode via\n the theme prop, so no :root.dark & override is needed and the copied state\n reads consistently in both modes. */\nconst CopyButton = styled.button<{ theme: Theme; $copied: boolean }>`\n ${resetButton}\n ${interactiveStyles}\n background: ${({ theme }) => theme.colors.light};\n border-color: ${({ theme }) => theme.colors.grayLight};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n margin-right: -6px;\n\n & svg.lucide {\n margin: 0;\n color: ${({ theme, $copied }) =>\n $copied ? theme.colors.success : theme.colors.grayDark};\n }\n`;\n\n/* Centered file name in the TopBar. Sits absolutely over the flex row so the\n dots and copy button keep their edge alignment. Monospace + a muted grayDark\n token that swaps for dark mode via the theme prop. pointer-events off so\n clicks fall through to nothing and never steal focus from the buttons. */\nconst TopBarTitle = styled.span<{ theme: Theme }>`\n position: absolute;\n left: 50%;\n transform: translateX(-50%);\n max-width: 60%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n pointer-events: none;\n font-family: ${({ theme }) => theme.fonts.mono};\n font-size: 12px;\n line-height: 2;\n color: ${({ theme }) => theme.colors.grayDark};\n`;\n\n/* Segmented control living in the TopBar for CodeTabs. Scrolls horizontally\n with the thin scrollbar when the labels overflow the 33px bar. */\nconst TabList = styled.div<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 2px;\n min-width: 0;\n overflow-x: auto;\n ${thinScrollbar};\n margin-left: -6px;\n`;\n\n/* Individual tab button. The active tab reads as part of the window: a\n theme.colors.light fill that echoes the code body, with a soft grayLight\n border. Inactive tabs are muted (grayDark) and transparent. resetButton\n strips native styling; focus-visible draws an inset brand primary ring on a\n pseudo-element so the scrolling TabList can't clip it. Colors come from theme\n tokens that swap for dark mode via the theme prop, so no :root.dark &\n override is needed. */\nconst CodeTab = styled.button<{ theme: Theme; $active: boolean }>`\n ${resetButton}\n flex: 0 0 auto;\n position: relative;\n font-family: ${({ theme }) => theme.fonts.mono};\n font-size: 12px;\n line-height: 1;\n padding: 5px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n cursor: pointer;\n white-space: nowrap;\n transition:\n color 0.15s ease,\n background 0.15s ease,\n border-color 0.15s ease;\n border: solid 1px\n ${({ theme, $active }) =>\n $active ? theme.colors.grayLight : \"transparent\"};\n background: ${({ theme, $active }) =>\n $active ? theme.colors.light : \"transparent\"};\n color: ${({ theme, $active }) =>\n $active ? theme.colors.dark : theme.colors.grayDark};\n\n &:hover {\n color: ${({ theme }) => theme.colors.dark};\n background: ${({ theme, $active }) =>\n $active\n ? theme.colors.light\n : `color-mix(in srgb, ${theme.colors.dark} 4%, transparent)`};\n }\n\n &:focus-visible {\n outline: none;\n }\n\n &:focus-visible::after {\n content: \"\";\n position: absolute;\n inset: 2px;\n border: solid 2px ${({ theme }) => theme.colors.primary};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n pointer-events: none;\n }\n`;\n\n/* GitHub Light syntax highlighting by default; GitHub Dark in :root.dark.\n Browser resolves which rule wins based on the active <html> class with no\n JS or React re-render involved. */\nconst lightSyntaxHighlight = css`\n & .hljs {\n color: #24292f;\n background: #ffffff;\n min-width: min-content;\n width: 100%;\n display: block;\n padding-right: 20px;\n }\n & .hljs-doctag,\n & .hljs-keyword,\n & .hljs-meta .hljs-keyword,\n & .hljs-template-tag,\n & .hljs-template-variable,\n & .hljs-type,\n & .hljs-variable.language_ {\n color: #cf222e;\n }\n & .hljs-title,\n & .hljs-title.class_,\n & .hljs-title.class_.inherited__,\n & .hljs-title.function_ {\n color: #8250df;\n }\n & .hljs-attr,\n & .hljs-attribute,\n & .hljs-literal,\n & .hljs-meta,\n & .hljs-number,\n & .hljs-operator,\n & .hljs-selector-attr,\n & .hljs-selector-class,\n & .hljs-selector-id,\n & .hljs-variable {\n color: #0550ae;\n }\n & .hljs-meta .hljs-string,\n & .hljs-regexp,\n & .hljs-string {\n color: #0a3069;\n }\n & .hljs-built_in,\n & .hljs-symbol {\n color: #953800;\n }\n & .hljs-code,\n & .hljs-comment,\n & .hljs-formula {\n color: #6e7781;\n }\n & .hljs-name,\n & .hljs-quote,\n & .hljs-selector-pseudo,\n & .hljs-selector-tag {\n color: #116329;\n }\n & .hljs-subst {\n color: #24292f;\n }\n & .hljs-section {\n color: #0550ae;\n font-weight: 700;\n }\n & .hljs-bullet {\n color: #953800;\n }\n & .hljs-emphasis {\n color: #24292f;\n font-style: italic;\n }\n & .hljs-strong {\n color: #24292f;\n font-weight: 700;\n }\n & .hljs-addition {\n color: #116329;\n background-color: #dafbe1;\n }\n & .hljs-deletion {\n color: #82071e;\n background-color: #ffebe9;\n }\n`;\n\nconst darkSyntaxHighlight = css`\n & .hljs {\n color: #c9d1d9;\n background: transparent;\n }\n & .hljs-doctag,\n & .hljs-keyword,\n & .hljs-meta .hljs-keyword,\n & .hljs-template-tag,\n & .hljs-template-variable,\n & .hljs-type,\n & .hljs-variable.language_ {\n color: #ff7b72;\n }\n & .hljs-title,\n & .hljs-title.class_,\n & .hljs-title.class_.inherited__,\n & .hljs-title.function_ {\n color: #d2a8ff;\n }\n & .hljs-attr,\n & .hljs-attribute,\n & .hljs-literal,\n & .hljs-meta,\n & .hljs-number,\n & .hljs-operator,\n & .hljs-selector-attr,\n & .hljs-selector-class,\n & .hljs-selector-id,\n & .hljs-variable {\n color: #79c0ff;\n }\n & .hljs-meta .hljs-string,\n & .hljs-regexp,\n & .hljs-string {\n color: #a5d6ff;\n }\n & .hljs-built_in,\n & .hljs-symbol {\n color: #ffa657;\n }\n & .hljs-code,\n & .hljs-comment,\n & .hljs-formula {\n color: #8b949e;\n }\n & .hljs-name,\n & .hljs-quote,\n & .hljs-selector-pseudo,\n & .hljs-selector-tag {\n color: #7ee787;\n }\n & .hljs-subst {\n color: #c9d1d9;\n }\n & .hljs-section {\n color: #1f6feb;\n font-weight: 700;\n }\n & .hljs-bullet {\n color: #f2cc60;\n }\n & .hljs-emphasis {\n color: #c9d1d9;\n font-style: italic;\n }\n & .hljs-strong {\n color: #c9d1d9;\n font-weight: 700;\n }\n & .hljs-addition {\n color: #aff5b4;\n background-color: #033a16;\n }\n & .hljs-deletion {\n color: #ffdcd7;\n background-color: #67060c;\n }\n`;\n\nconst Body = styled.div<{ theme: Theme }>`\n background: #ffffff;\n border-bottom-left-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-bottom-right-radius: ${({ theme }) => theme.spacing.radius.lg};\n color: #24292f;\n padding: 20px;\n font-family: ${({ theme }) => theme.fonts.mono};\n text-align: left;\n overflow-x: auto;\n overflow-y: auto;\n max-height: calc(100dvh - 400px);\n ${thinScrollbar};\n ${({ theme }) => styledCode(theme)};\n ${lightSyntaxHighlight};\n\n /* Diff lines: highlight.js wraps each +/- line in a single span, so\n stretching it to the full row reads like a GitHub diff. inline-block keeps\n the trailing newline as the line break instead of adding an empty row. */\n & .hljs-addition,\n & .hljs-deletion {\n display: inline-table;\n width: 100%;\n }\n\n :root.dark & {\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 5%, transparent)`};\n color: #ffffff;\n ${darkSyntaxHighlight};\n }\n`;\n\nconst escapeHtml = (unsafe: string): string => {\n return unsafe\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#039;\");\n};\n\nconst sanitizeLanguage = (lang: string): string =>\n lang.replace(/[^a-zA-Z0-9_-]/g, \"\");\n\nconst highlightCode = (code: string, language: string): string => {\n const escapedCode = escapeHtml(code);\n const safeLang = sanitizeLanguage(language);\n const result = unified()\n .use(rehypeParse, { fragment: true })\n .use(rehypeHighlight, {\n detect: true,\n ignoreMissing: true,\n })\n .use(rehypeStringify)\n .processSync(\n `<pre><code class=\"language-${safeLang}\">${escapedCode}</code></pre>`,\n );\n\n return String(result);\n};\n\nfunction Code({\n code,\n language = \"javascript\",\n title,\n theme,\n className,\n}: CodeProps) {\n const [copied, setCopied] = useState(false);\n const highlightedCode = useMemo(\n () => highlightCode(code, language),\n [code, language],\n );\n\n const handleCopy = useCallback(async () => {\n try {\n await navigator.clipboard.writeText(code);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n } catch (err) {\n console.error(\"Failed to copy code:\", err);\n }\n }, [code]);\n\n return (\n <CodeWrapper\n className={`${className ?? \"\"} code-wrapper`.trim()}\n theme={theme}\n >\n <TopBar theme={theme}>\n <DotsContainer>\n <Dot theme={theme} />\n <Dot theme={theme} />\n <Dot theme={theme} />\n </DotsContainer>\n {title ? <TopBarTitle theme={theme}>{title}</TopBarTitle> : null}\n <CopyButton\n onClick={handleCopy}\n $copied={copied}\n theme={theme}\n aria-label={copied ? \"Copied\" : \"Copy code\"}\n >\n <Icon name={copied ? \"check\" : \"copy\"} size={12} />\n </CopyButton>\n </TopBar>\n <Body\n dangerouslySetInnerHTML={{ __html: highlightedCode }}\n theme={theme}\n className=\"code-wrapper-body\"\n />\n </CodeWrapper>\n );\n}\n\n/* Multi-variant code block (npm / pnpm / yarn, etc). Tabs replace the macOS\n dots in the TopBar as a keyboard-accessible tablist; the copy button copies\n whichever tab is active and resets its copied state when the tab changes. */\nfunction CodeTabs({ tabs, theme, className }: CodeTabsProps) {\n const baseId = useId();\n const [active, setActive] = useState(0);\n const [copied, setCopied] = useState(false);\n\n const safeActive = active < tabs.length ? active : 0;\n const activeTab = tabs[safeActive];\n\n const highlightedCode = useMemo(\n () =>\n activeTab\n ? highlightCode(activeTab.code, activeTab.language ?? \"bash\")\n : \"\",\n [activeTab],\n );\n\n const handleSelect = useCallback((index: number) => {\n setActive(index);\n setCopied(false);\n }, []);\n\n const handleCopy = useCallback(async () => {\n if (!activeTab) return;\n try {\n await navigator.clipboard.writeText(activeTab.code);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n } catch (err) {\n console.error(\"Failed to copy code:\", err);\n }\n }, [activeTab]);\n\n const handleKeyDown = useCallback(\n (event: React.KeyboardEvent<HTMLButtonElement>) => {\n let next = safeActive;\n if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") {\n next = (safeActive + 1) % tabs.length;\n } else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") {\n next = (safeActive - 1 + tabs.length) % tabs.length;\n } else if (event.key === \"Home\") {\n next = 0;\n } else if (event.key === \"End\") {\n next = tabs.length - 1;\n } else {\n return;\n }\n event.preventDefault();\n handleSelect(next);\n const target = document.getElementById(`${baseId}-tab-${next}`);\n if (target) target.focus();\n },\n [baseId, handleSelect, safeActive, tabs.length],\n );\n\n if (!tabs || tabs.length === 0) return null;\n\n return (\n <CodeWrapper\n className={`${className ?? \"\"} code-wrapper`.trim()}\n theme={theme}\n >\n <TopBar theme={theme}>\n <TabList role=\"tablist\" aria-label=\"Code variants\">\n {tabs.map((tab, index) => (\n <CodeTab\n key={`${tab.label}-${index}`}\n type=\"button\"\n role=\"tab\"\n id={`${baseId}-tab-${index}`}\n aria-selected={index === safeActive}\n aria-controls={`${baseId}-panel`}\n tabIndex={index === safeActive ? 0 : -1}\n onClick={() => handleSelect(index)}\n onKeyDown={handleKeyDown}\n $active={index === safeActive}\n theme={theme}\n >\n {tab.label}\n </CodeTab>\n ))}\n </TabList>\n <CopyButton\n onClick={handleCopy}\n $copied={copied}\n theme={theme}\n aria-label={copied ? \"Copied\" : \"Copy code\"}\n >\n <Icon name={copied ? \"check\" : \"copy\"} size={12} />\n </CopyButton>\n </TopBar>\n <Body\n id={`${baseId}-panel`}\n role=\"tabpanel\"\n aria-labelledby={`${baseId}-tab-${safeActive}`}\n dangerouslySetInnerHTML={{ __html: highlightedCode }}\n theme={theme}\n className=\"code-wrapper-body\"\n />\n </CodeWrapper>\n );\n}\n\nexport { Code, CodeTabs };\n";
@@ -44,23 +44,24 @@ const CodeWrapper = styled.span<{ theme: Theme }>\`
44
44
  display: block;
45
45
  width: 100%;
46
46
  border-radius: \${({ theme }) => theme.spacing.radius.lg};
47
- border: solid 1px rgba(0, 0, 0, 0.1);
48
-
49
- :root.dark & {
50
- border-color: rgba(255, 255, 255, 0.2);
51
- }
47
+ border: solid 1px \${({ theme }) => theme.colors.grayLight};
48
+ background: \${({ theme }) => theme.colors.light};
52
49
  \`;
53
50
 
54
- /* Code block uses a fixed GitHub-style palette in both modes. Independent of
55
- theme.json so syntax highlighting stays legible regardless of brand colors.
56
- Dark variants live in :root.dark & blocks so the swap happens via the
57
- active <html> class with no re-render. */
51
+ /* Code block uses a fixed GitHub-style palette for the syntax tokens in both
52
+ modes so highlighting stays legible regardless of brand colors. The dark-mode
53
+ surface, however, matches the left sidebar's translucent brand tint
54
+ (color-mix of theme.colors.primaryLight over the black page) instead of
55
+ GitHub's #0d1117, so the window sits on the same background as the nav. Dark
56
+ variants live in :root.dark & blocks so the swap happens via the active
57
+ <html> class with no re-render. */
58
58
  const TopBar = styled.div<{ theme: Theme }>\`
59
59
  position: relative;
60
- background: #f6f8fa;
60
+ background: \${({ theme }) =>
61
+ \`color-mix(in srgb, \${theme.colors.primaryLight} 5%, transparent)\`};
61
62
  border-top-left-radius: \${({ theme }) => theme.spacing.radius.lg};
62
63
  border-top-right-radius: \${({ theme }) => theme.spacing.radius.lg};
63
- border-bottom: solid 1px rgba(0, 0, 0, 0.1);
64
+ border-bottom: solid 1px \${({ theme }) => theme.colors.grayLight};
64
65
  height: 33px;
65
66
  width: 100%;
66
67
  display: flex;
@@ -68,11 +69,6 @@ const TopBar = styled.div<{ theme: Theme }>\`
68
69
  align-items: center;
69
70
  gap: 5px;
70
71
  padding: 0 10px;
71
-
72
- :root.dark & {
73
- background: #0d1117;
74
- border-bottom-color: rgba(255, 255, 255, 0.1);
75
- }
76
72
  \`;
77
73
 
78
74
  const DotsContainer = styled.div\`
@@ -92,16 +88,15 @@ const Dot = styled.span<{ theme: Theme }>\`
92
88
  \`;
93
89
 
94
90
  /* Icon-only copy button. interactiveStyles supplies the border highlight on
95
- hover plus the focus/active rings (no scale effect); the GitHub-style
96
- copied/base colors stay fixed like the rest of the code block. The dark
97
- block re-declares the hover border because its higher-specificity
98
- :root.dark & border-color would otherwise override the mixin's hover. */
91
+ hover plus the focus/active rings (no scale effect). Colors come from theme
92
+ tokens (grayLight border, success/grayDark icon) that swap for dark mode via
93
+ the theme prop, so no :root.dark & override is needed and the copied state
94
+ reads consistently in both modes. */
99
95
  const CopyButton = styled.button<{ theme: Theme; $copied: boolean }>\`
100
96
  \${resetButton}
101
97
  \${interactiveStyles}
102
- background: \${({ $copied }) =>
103
- $copied ? "rgba(45, 164, 78, 0.1)" : "transparent"};
104
- border-color: \${({ $copied }) => ($copied ? "#2da44e" : "rgba(0, 0, 0, 0.1)")};
98
+ background: \${({ theme }) => theme.colors.light};
99
+ border-color: \${({ theme }) => theme.colors.grayLight};
105
100
  border-radius: \${({ theme }) => theme.spacing.radius.xs};
106
101
  padding: 4px;
107
102
  display: flex;
@@ -110,29 +105,16 @@ const CopyButton = styled.button<{ theme: Theme; $copied: boolean }>\`
110
105
  margin-right: -6px;
111
106
 
112
107
  & svg.lucide {
113
- color: \${({ $copied }) => ($copied ? "#2da44e" : "#57606a")};
114
- }
115
-
116
- :root.dark & {
117
- background: \${({ $copied }) =>
118
- $copied ? "rgba(126, 231, 135, 0.2)" : "transparent"};
119
- border-color: \${({ $copied }) =>
120
- $copied ? "#7ee787" : "rgba(255, 255, 255, 0.1)"};
121
-
122
- & svg.lucide {
123
- color: \${({ $copied }) => ($copied ? "#7ee787" : "#c9d1d9")};
124
- }
125
-
126
- &:hover {
127
- border-color: \${({ theme }) => theme.colors.primary};
128
- }
108
+ margin: 0;
109
+ color: \${({ theme, $copied }) =>
110
+ $copied ? theme.colors.success : theme.colors.grayDark};
129
111
  }
130
112
  \`;
131
113
 
132
114
  /* Centered file name in the TopBar. Sits absolutely over the flex row so the
133
- dots and copy button keep their edge alignment. Monospace + muted GitHub
134
- grays, swapped via :root.dark like the rest of the block. pointer-events off
135
- so clicks fall through to nothing and never steal focus from the buttons. */
115
+ dots and copy button keep their edge alignment. Monospace + a muted grayDark
116
+ token that swaps for dark mode via the theme prop. pointer-events off so
117
+ clicks fall through to nothing and never steal focus from the buttons. */
136
118
  const TopBarTitle = styled.span<{ theme: Theme }>\`
137
119
  position: absolute;
138
120
  left: 50%;
@@ -145,11 +127,7 @@ const TopBarTitle = styled.span<{ theme: Theme }>\`
145
127
  font-family: \${({ theme }) => theme.fonts.mono};
146
128
  font-size: 12px;
147
129
  line-height: 2;
148
- color: #57606a;
149
-
150
- :root.dark & {
151
- color: #8b949e;
152
- }
130
+ color: \${({ theme }) => theme.colors.grayDark};
153
131
  \`;
154
132
 
155
133
  /* Segmented control living in the TopBar for CodeTabs. Scrolls horizontally
@@ -164,12 +142,13 @@ const TabList = styled.div<{ theme: Theme }>\`
164
142
  margin-left: -6px;
165
143
  \`;
166
144
 
167
- /* Individual tab button. Active tab reads as part of the window: a light body
168
- fill (#ffffff) that echoes the code area, with a soft border. Inactive tabs
169
- are muted and transparent. resetButton strips native styling; focus-visible
170
- draws an inset brand primary ring on a pseudo-element so the scrolling
171
- TabList can't clip it. All colors stay fixed and swap purely via :root.dark,
172
- matching the no-re-render palette approach. */
145
+ /* Individual tab button. The active tab reads as part of the window: a
146
+ theme.colors.light fill that echoes the code body, with a soft grayLight
147
+ border. Inactive tabs are muted (grayDark) and transparent. resetButton
148
+ strips native styling; focus-visible draws an inset brand primary ring on a
149
+ pseudo-element so the scrolling TabList can't clip it. Colors come from theme
150
+ tokens that swap for dark mode via the theme prop, so no :root.dark &
151
+ override is needed. */
173
152
  const CodeTab = styled.button<{ theme: Theme; $active: boolean }>\`
174
153
  \${resetButton}
175
154
  flex: 0 0 auto;
@@ -186,14 +165,19 @@ const CodeTab = styled.button<{ theme: Theme; $active: boolean }>\`
186
165
  background 0.15s ease,
187
166
  border-color 0.15s ease;
188
167
  border: solid 1px
189
- \${({ $active }) => ($active ? "rgba(0, 0, 0, 0.1)" : "transparent")};
190
- background: \${({ $active }) => ($active ? "#ffffff" : "transparent")};
191
- color: \${({ $active }) => ($active ? "#24292f" : "#57606a")};
168
+ \${({ theme, $active }) =>
169
+ $active ? theme.colors.grayLight : "transparent"};
170
+ background: \${({ theme, $active }) =>
171
+ $active ? theme.colors.light : "transparent"};
172
+ color: \${({ theme, $active }) =>
173
+ $active ? theme.colors.dark : theme.colors.grayDark};
192
174
 
193
175
  &:hover {
194
- color: #24292f;
195
- background: \${({ $active }) =>
196
- $active ? "#ffffff" : "rgba(0, 0, 0, 0.04)"};
176
+ color: \${({ theme }) => theme.colors.dark};
177
+ background: \${({ theme, $active }) =>
178
+ $active
179
+ ? theme.colors.light
180
+ : \`color-mix(in srgb, \${theme.colors.dark} 4%, transparent)\`};
197
181
  }
198
182
 
199
183
  &:focus-visible {
@@ -208,19 +192,6 @@ const CodeTab = styled.button<{ theme: Theme; $active: boolean }>\`
208
192
  border-radius: \${({ theme }) => theme.spacing.radius.xs};
209
193
  pointer-events: none;
210
194
  }
211
-
212
- :root.dark & {
213
- border-color: \${({ $active }) =>
214
- $active ? "rgba(255, 255, 255, 0.15)" : "transparent"};
215
- background: \${({ $active }) => ($active ? "#161b22" : "transparent")};
216
- color: \${({ $active }) => ($active ? "#e6edf3" : "#8b949e")};
217
-
218
- &:hover {
219
- color: #e6edf3;
220
- background: \${({ $active }) =>
221
- $active ? "#161b22" : "rgba(255, 255, 255, 0.06)"};
222
- }
223
- }
224
195
  \`;
225
196
 
226
197
  /* GitHub Light syntax highlighting by default; GitHub Dark in :root.dark.
@@ -313,7 +284,7 @@ const lightSyntaxHighlight = css\`
313
284
  const darkSyntaxHighlight = css\`
314
285
  & .hljs {
315
286
  color: #c9d1d9;
316
- background: #0d1117;
287
+ background: transparent;
317
288
  }
318
289
  & .hljs-doctag,
319
290
  & .hljs-keyword,
@@ -415,7 +386,8 @@ const Body = styled.div<{ theme: Theme }>\`
415
386
  }
416
387
 
417
388
  :root.dark & {
418
- background: #0d1117;
389
+ background: \${({ theme }) =>
390
+ \`color-mix(in srgb, \${theme.colors.primaryLight} 5%, transparent)\`};
419
391
  color: #ffffff;
420
392
  \${darkSyntaxHighlight};
421
393
  }
@@ -1 +1 @@
1
- export declare const globalStylesTemplate = "\"use client\";\nimport { createGlobalStyle } from \"styled-components\";\nimport {\n colorsLight,\n colorsDark,\n shadowsLight,\n shadowsDark,\n} from \"@/app/theme\";\n\n// Build \"name: value;\" lines for every entry in a record. Used to emit\n// :root and :root.dark blocks from the resolved hex objects in theme.ts.\n// Custom theme.json overrides flow through automatically because they are\n// already merged into colorsLight / colorsDark.\nfunction toCssVars<T extends object>(prefix: string, record: T): string {\n return Object.entries(record)\n .map(([k, v]) => ` --${prefix}-${k}: ${v};`)\n .join(\"\\n\");\n}\n\nconst lightVars = [\n toCssVars(\"color\", colorsLight),\n toCssVars(\"shadow\", shadowsLight),\n // Semantic tokens \u2014 derived from the brand palette per mode. See the\n // SemanticColors interface in theme.ts for the intent of each.\n // These reference the palette via var() (not baked hex) so that any\n // runtime override of --color-primary* (e.g. the DemoTheme presets)\n // cascades into the semantic tokens automatically.\n ` --color-accent: var(--color-primaryDark);`,\n // accentStrong = accent shifted ~10% toward black (light mode) or white (dark\n // mode). color-mix in sRGB is not identical to polished's HSL darken/lighten\n // but the visual difference at 10% on a UI accent is imperceptible.\n ` --color-accentStrong: color-mix(in srgb, var(--color-primaryDark) 90%, black);`,\n ` --color-accentMuted: var(--color-primary);`,\n ` --color-surface: var(--color-light);`,\n].join(\"\\n\");\n\nconst darkVars = [\n toCssVars(\"color\", colorsDark),\n toCssVars(\"shadow\", shadowsDark),\n ` --color-accent: var(--color-primaryLight);`,\n ` --color-accentStrong: color-mix(in srgb, var(--color-primaryLight) 90%, white);`,\n ` --color-accentMuted: var(--color-grayDark);`,\n ` --color-surface: var(--color-dark);`,\n].join(\"\\n\");\n\nconst GlobalStyles = createGlobalStyle`\n:root {\n${lightVars}\n}\n\n:root.dark {\n${darkVars}\n}\n\nhtml,\nbody {\n margin: 0;\n padding: 0;\n min-height: 100%;\n background-color: var(--color-light);\n scroll-padding-top: 80px;\n}\n\nhtml:has(:target) {\n scroll-behavior: smooth;\n}\n\nbody {\n font-family: \"Inter\", sans-serif;\n -moz-osx-font-smoothing: grayscale;\n -webkit-text-size-adjust: 100%;\n -webkit-font-smoothing: antialiased;\n}\n\n:root {\n interpolate-size: allow-keywords;\n}\n\n* {\n box-sizing: border-box;\n min-width: 0;\n}\n\nhr {\n border: none;\n border-bottom: solid 1px var(--color-grayLight);\n margin: 10px 0;\n}\n\npre,\ncode,\nkbd,\nsamp {\n font-family: monospace, monospace;\n}\n\npre,\ncode,\nkbd,\nsamp,\nblockquote,\np,\na,\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\nul li,\nol li {\n margin: 0;\n padding: 0;\n color: var(--color-dark);\n}\n\na {\n color: var(--color-primary);\n}\n\n/* Keyboard-focus ring, defined once globally so links don't each re-declare it.\n :focus-visible keeps it keyboard-only (no ring on mouse click); box-shadow\n gives a soft primaryLight glow that follows the element's border-radius. The\n 6px radius mirrors theme.spacing.radius.xs so the ring rounds on links that\n have no radius of their own. Cherry link-buttons (.button-link) are excluded\n via :where() so the exclusion adds no specificity - this keeps the selector\n at (0,1,1) so bespoke per-component :focus-visible rules (inline content\n links, the section bar, the sidebar group rows) still override it. */\na:focus-visible:where(:not(.button-link)) {\n outline: none;\n border-radius: 6px;\n box-shadow: 0 0 0 2px var(--color-primaryLight);\n}\n\nol,\nul {\n list-style: none;\n margin: 0;\n padding: 0;\n}\n\nfigure {\n margin: 0;\n}\n\nfieldset {\n appearance: none;\n border: none;\n}\n\nbutton,\ninput,\na,\nimg,\nsvg,\nsvg * {\n transition: all 0.3s ease;\n}\n\nstrong,\nb {\n font-weight: 700;\n}\n\n.full-width {\n width: 100%;\n}\n\n/* Mode-conditional visibility helpers. ThemeToggle uses these to swap Sun\n and Moon icons; Header uses them to swap light/dark logos. Pure CSS so\n the swap happens via the active <html> class without JS or re-render. */\n:root.dark .light-only {\n display: none !important;\n}\n:root:not(.dark) .dark-only {\n display: none !important;\n}\n`;\n\nexport { GlobalStyles };\n";
1
+ export declare const globalStylesTemplate = "\"use client\";\nimport { createGlobalStyle } from \"styled-components\";\nimport {\n colorsLight,\n colorsDark,\n shadowsLight,\n shadowsDark,\n} from \"@/app/theme\";\n\n// Build \"name: value;\" lines for every entry in a record. Used to emit\n// :root and :root.dark blocks from the resolved hex objects in theme.ts.\n// Custom theme.json overrides flow through automatically because they are\n// already merged into colorsLight / colorsDark.\nfunction toCssVars<T extends object>(prefix: string, record: T): string {\n return Object.entries(record)\n .map(([k, v]) => ` --${prefix}-${k}: ${v};`)\n .join(\"\\n\");\n}\n\nconst lightVars = [\n toCssVars(\"color\", colorsLight),\n toCssVars(\"shadow\", shadowsLight),\n // Semantic tokens \u2014 derived from the brand palette per mode. See the\n // SemanticColors interface in theme.ts for the intent of each.\n // These reference the palette via var() (not baked hex) so that any\n // runtime override of --color-primary* (e.g. the DemoTheme presets)\n // cascades into the semantic tokens automatically.\n ` --color-accent: var(--color-primaryDark);`,\n // accentStrong = accent shifted ~10% toward black (light mode) or white (dark\n // mode). color-mix in sRGB is not identical to polished's HSL darken/lighten\n // but the visual difference at 10% on a UI accent is imperceptible.\n ` --color-accentStrong: color-mix(in srgb, var(--color-primaryDark) 90%, black);`,\n ` --color-accentMuted: var(--color-primary);`,\n ` --color-surface: var(--color-light);`,\n].join(\"\\n\");\n\nconst darkVars = [\n toCssVars(\"color\", colorsDark),\n toCssVars(\"shadow\", shadowsDark),\n ` --color-accent: var(--color-primaryLight);`,\n ` --color-accentStrong: color-mix(in srgb, var(--color-primaryLight) 90%, white);`,\n ` --color-accentMuted: var(--color-grayDark);`,\n ` --color-surface: var(--color-dark);`,\n].join(\"\\n\");\n\nconst GlobalStyles = createGlobalStyle`\n:root {\n${lightVars}\n}\n\n:root.dark {\n${darkVars}\n}\n\nhtml,\nbody {\n margin: 0;\n padding: 0;\n min-height: 100%;\n background-color: var(--color-light);\n scroll-padding-top: 80px;\n}\n\nhtml:has(:target) {\n scroll-behavior: smooth;\n}\n\nbody {\n font-family: \"Inter\", sans-serif;\n -moz-osx-font-smoothing: grayscale;\n -webkit-text-size-adjust: 100%;\n -webkit-font-smoothing: antialiased;\n}\n\n:root {\n interpolate-size: allow-keywords;\n}\n\n* {\n box-sizing: border-box;\n min-width: 0;\n}\n\nhr {\n border: none;\n border-bottom: solid 1px var(--color-grayLight);\n margin: 10px 0;\n}\n\npre,\ncode,\nkbd,\nsamp {\n font-family: monospace, monospace;\n}\n\npre,\ncode,\nkbd,\nsamp,\nblockquote,\np,\na,\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\nul li,\nol li {\n margin: 0;\n padding: 0;\n color: var(--color-dark);\n}\n\na {\n color: var(--color-primary);\n}\n\n/* Keyboard-focus ring, defined once globally so links don't each re-declare it.\n :focus-visible keeps it keyboard-only (no ring on mouse click); box-shadow\n gives a soft primaryLight glow that follows the element's border-radius. The\n 6px radius mirrors theme.spacing.radius.xs so the ring rounds on links that\n have no radius of their own. Cherry link-buttons (.button-link) are excluded\n via :where() so the exclusion adds no specificity - this keeps the selector\n at (0,1,1) so bespoke per-component :focus-visible rules (inline content\n links, the section bar, the sidebar group rows) still override it. */\na:focus-visible:where(:not(.button-link)) {\n outline: none;\n border-radius: 6px;\n box-shadow: 0 0 0 2px var(--color-primaryLight);\n}\n\nol,\nul {\n list-style: none;\n margin: 0;\n padding: 0;\n max-width: 100%;\n}\n\nfigure {\n margin: 0;\n}\n\nfieldset {\n appearance: none;\n border: none;\n}\n\nbutton,\ninput,\na,\nimg,\nsvg,\nsvg * {\n transition: all 0.3s ease;\n}\n\nstrong,\nb {\n font-weight: 700;\n}\n\n.full-width {\n width: 100%;\n}\n\n/* Mode-conditional visibility helpers. ThemeToggle uses these to swap Sun\n and Moon icons; Header uses them to swap light/dark logos. Pure CSS so\n the swap happens via the active <html> class without JS or re-render. */\n:root.dark .light-only {\n display: none !important;\n}\n:root:not(.dark) .dark-only {\n display: none !important;\n}\n`;\n\nexport { GlobalStyles };\n";
@@ -137,6 +137,7 @@ ul {
137
137
  list-style: none;
138
138
  margin: 0;
139
139
  padding: 0;
140
+ max-width: 100%;
140
141
  }
141
142
 
142
143
  figure {
@@ -1 +1 @@
1
- export declare const sharedStyledTemplate = "\"use client\";\nimport { styledSmall, styledText } from \"cherry-styled-components\";\nimport { mq, Theme } from \"@/app/theme\";\nimport styled, { css } from \"styled-components\";\n\n/** Slim, theme-aware scrollbar for internal scroll areas (code blocks,\n modals, tables) so the chunky native bar doesn't stand out, especially\n in dark mode. */\nexport const thinScrollbar = css<{ theme: Theme }>`\n scrollbar-width: thin;\n scrollbar-color: ${({ theme }) => theme.colors.grayLight} transparent;\n`;\n\nexport const interactiveStyles = css<{ theme: Theme }>`\n transition: all 0.3s ease;\n border: solid 1px transparent;\n box-shadow: 0 0 0 0px ${({ theme }) => theme.colors.primary};\n\n &:hover {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n\n &:focus {\n border-color: ${({ theme }) => theme.colors.primary};\n box-shadow: 0 0 0 4px ${({ theme }) => theme.colors.primaryLight};\n }\n\n &:active {\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n`;\n\nexport const styledAnchor = css<{ theme: Theme }>`\n & a:not([class]):not(:has(img)) {\n color: inherit;\n transition: all 0.3s ease;\n text-decoration: none;\n box-shadow: 0 2px 0 0 ${({ theme }) => theme.colors.primary};\n\n &:hover {\n color: ${({ theme }) => theme.colors.accent};\n box-shadow: 0 1px 0 0 ${({ theme }) => theme.colors.primary};\n }\n\n /* Same primaryLight focus glow as the global a:focus-visible ring, but\n layered over the link's own box-shadow underline (same property) so the\n underline is preserved. */\n &:focus-visible {\n outline: none;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n box-shadow:\n 0 2px 0 0 ${({ theme }) => theme.colors.primary},\n 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n }\n`;\n\nexport const stylesLists = css<{ theme: Theme }>`\n & ul,\n & ol {\n & li {\n & > .code-wrapper {\n margin: 10px 0;\n }\n }\n }\n\n & ul {\n list-style: none;\n padding: 0;\n margin: 0;\n\n & li {\n text-indent: 0;\n display: block;\n position: relative;\n padding: 0 0 0 15px;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n min-height: 23px;\n\n ${mq(\"lg\")} {\n min-height: 27px;\n }\n\n &::before {\n content: \"\";\n display: block;\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: ${({ theme }) => theme.colors.primary};\n position: absolute;\n top: 8px;\n left: 2px;\n\n ${mq(\"lg\")} {\n top: 10px;\n }\n }\n }\n }\n\n & ol {\n padding: 0;\n margin: 0;\n\n & ul {\n padding-left: 15px;\n }\n\n & > li {\n position: relative;\n padding: 0;\n counter-increment: item;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n\n &::before {\n content: counter(item) \".\";\n display: inline-block;\n margin: 0 4px 0 0;\n font-weight: 700;\n color: ${({ theme }) => theme.colors.primary};\n min-width: max-content;\n }\n }\n }\n`;\n\nexport const styledTable = css<{ theme: Theme }>`\n & .table-wrapper {\n overflow-x: auto;\n width: 100%;\n ${thinScrollbar};\n }\n\n & table {\n margin: 0;\n padding: 0;\n border-collapse: collapse;\n width: 100%;\n text-align: left;\n\n & tr {\n margin: 0;\n padding: 0;\n }\n\n & th {\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 10px 10px 10px 0;\n ${({ theme }) => styledSmall(theme)};\n font-weight: 600;\n color: ${({ theme }) => theme.colors.dark};\n }\n\n & td {\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 10px 10px 10px 0;\n color: ${({ theme }) => theme.colors.grayDark};\n ${({ theme }) => styledSmall(theme)};\n }\n }\n`;\n\nexport const StyledSmallButton = styled.button<{ theme: Theme }>`\n ${interactiveStyles};\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.accent};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 6px 8px;\n font-size: 12px;\n font-family: inherit;\n font-weight: 600;\n cursor: pointer;\n transition: all 0.2s ease;\n display: flex;\n align-items: center;\n gap: 6px;\n margin-right: -6px;\n\n & svg.lucide {\n color: inherit;\n }\n`;\n";
1
+ export declare const sharedStyledTemplate = "\"use client\";\nimport { styledSmall, styledText } from \"cherry-styled-components\";\nimport { mq, Theme } from \"@/app/theme\";\nimport styled, { css } from \"styled-components\";\n\n/** Slim, theme-aware scrollbar for internal scroll areas (code blocks,\n modals, tables) so the chunky native bar doesn't stand out, especially\n in dark mode. */\nexport const thinScrollbar = css<{ theme: Theme }>`\n scrollbar-width: thin;\n scrollbar-color: ${({ theme }) => theme.colors.grayLight} transparent;\n`;\n\nexport const interactiveStyles = css<{ theme: Theme }>`\n transition: all 0.3s ease;\n border: solid 1px transparent;\n box-shadow: 0 0 0 0px ${({ theme }) => theme.colors.primary};\n\n &:hover {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n\n &:focus {\n border-color: ${({ theme }) => theme.colors.primary};\n box-shadow: 0 0 0 4px ${({ theme }) => theme.colors.primaryLight};\n }\n\n &:active {\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n`;\n\nexport const styledAnchor = css<{ theme: Theme }>`\n & a:not([class]):not(:has(img)) {\n color: inherit;\n transition: all 0.3s ease;\n text-decoration: none;\n box-shadow: 0 2px 0 0 ${({ theme }) => theme.colors.primary};\n\n &:hover {\n color: ${({ theme }) => theme.colors.accent};\n box-shadow: 0 1px 0 0 ${({ theme }) => theme.colors.primary};\n }\n\n /* Same primaryLight focus glow as the global a:focus-visible ring, but\n layered over the link's own box-shadow underline (same property) so the\n underline is preserved. */\n &:focus-visible {\n outline: none;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n box-shadow:\n 0 2px 0 0 ${({ theme }) => theme.colors.primary},\n 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n }\n`;\n\nexport const stylesLists = css<{ theme: Theme }>`\n & ul,\n & ol {\n & li {\n & > .code-wrapper {\n margin: 10px 0;\n }\n\n & p {\n display: inline;\n }\n }\n }\n\n & ul {\n list-style: none;\n padding: 0;\n margin: 0;\n\n & li {\n text-indent: 0;\n display: block;\n position: relative;\n padding: 0 0 0 15px;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n min-height: 23px;\n\n ${mq(\"lg\")} {\n min-height: 27px;\n }\n\n &::before {\n content: \"\";\n display: block;\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: ${({ theme }) => theme.colors.primary};\n position: absolute;\n top: 8px;\n left: 2px;\n\n ${mq(\"lg\")} {\n top: 10px;\n }\n }\n }\n }\n\n & ol {\n padding: 0;\n margin: 0;\n\n & ul {\n padding-left: 15px;\n }\n\n & > li {\n position: relative;\n padding: 0 0 0 24px;\n counter-increment: item;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n\n &::before {\n content: counter(item) \".\";\n position: absolute;\n left: 2px;\n font-weight: 700;\n color: ${({ theme }) => theme.colors.primary};\n }\n }\n }\n`;\n\nexport const styledTable = css<{ theme: Theme }>`\n & .table-wrapper {\n overflow-x: auto;\n width: 100%;\n ${thinScrollbar};\n }\n\n & table {\n margin: 0;\n padding: 0;\n border-collapse: collapse;\n width: 100%;\n text-align: left;\n\n & tr {\n margin: 0;\n padding: 0;\n }\n\n & th {\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 10px 10px 10px 0;\n ${({ theme }) => styledSmall(theme)};\n font-weight: 600;\n color: ${({ theme }) => theme.colors.dark};\n }\n\n & td {\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 10px 10px 10px 0;\n color: ${({ theme }) => theme.colors.grayDark};\n ${({ theme }) => styledSmall(theme)};\n }\n }\n`;\n\nexport const StyledSmallButton = styled.button<{ theme: Theme }>`\n ${interactiveStyles};\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.accent};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 6px 8px;\n font-size: 12px;\n font-family: inherit;\n font-weight: 600;\n cursor: pointer;\n transition: all 0.2s ease;\n display: flex;\n align-items: center;\n gap: 6px;\n margin-right: -6px;\n\n & svg.lucide {\n color: inherit;\n }\n`;\n";
@@ -62,6 +62,10 @@ export const stylesLists = css<{ theme: Theme }>\`
62
62
  & > .code-wrapper {
63
63
  margin: 10px 0;
64
64
  }
65
+
66
+ & p {
67
+ display: inline;
68
+ }
65
69
  }
66
70
  }
67
71
 
@@ -111,18 +115,17 @@ export const stylesLists = css<{ theme: Theme }>\`
111
115
 
112
116
  & > li {
113
117
  position: relative;
114
- padding: 0;
118
+ padding: 0 0 0 24px;
115
119
  counter-increment: item;
116
120
  margin: 0;
117
121
  \${({ theme }) => styledText(theme)};
118
122
 
119
123
  &::before {
120
124
  content: counter(item) ".";
121
- display: inline-block;
122
- margin: 0 4px 0 0;
125
+ position: absolute;
126
+ left: 2px;
123
127
  font-weight: 700;
124
128
  color: \${({ theme }) => theme.colors.primary};
125
- min-width: max-content;
126
129
  }
127
130
  }
128
131
  }
@@ -12,19 +12,19 @@ export const packageJsonTemplate = JSON.stringify({
12
12
  },
13
13
  dependencies: {
14
14
  "@langchain/anthropic": "^1.5.1",
15
- "@langchain/core": "^1.2.1",
15
+ "@langchain/core": "^1.2.2",
16
16
  "@langchain/google-genai": "^2.2.0",
17
- "@langchain/openai": "^1.5.3",
17
+ "@langchain/openai": "^1.5.5",
18
18
  "@mdx-js/react": "^3.1.1",
19
19
  "@modelcontextprotocol/sdk": "^1.29.0",
20
20
  "@posthog/react": "^1.10.3",
21
21
  "cherry-styled-components": "^0.2.10",
22
22
  langchain: "^1.5.3",
23
- "lucide-react": "^1.23.0",
23
+ "lucide-react": "^1.24.0",
24
24
  minisearch: "^7.2.0",
25
25
  next: "16.2.10",
26
26
  "next-mdx-remote": "^6.0.0",
27
- "posthog-js": "^1.399.0",
27
+ "posthog-js": "^1.399.1",
28
28
  "posthog-node": "^5.40.0",
29
29
  react: "19.2.7",
30
30
  "react-dom": "19.2.7",
@@ -50,7 +50,7 @@ export const packageJsonTemplate = JSON.stringify({
50
50
  "eslint-plugin-react": "^7.37.5",
51
51
  "eslint-plugin-react-hooks": "^7.1.1",
52
52
  globals: "^17.7.0",
53
- prettier: "^3.9.4",
53
+ prettier: "^3.9.5",
54
54
  typescript: "npm:@typescript/typescript6@^6.0.2",
55
55
  },
56
56
  }, null, 2) + "\n";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doccupine",
3
- "version": "0.0.114",
3
+ "version": "0.0.116",
4
4
  "description": "Free and open-source documentation platform. Write MDX, get a production-ready site with AI chat, built-in components, and an MCP server - in one command.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -48,7 +48,7 @@
48
48
  "@types/fs-extra": "^11.0.4",
49
49
  "@types/node": "^26.1.1",
50
50
  "@types/prompts": "^2.4.9",
51
- "prettier": "^3.9.4",
51
+ "prettier": "^3.9.5",
52
52
  "typescript": "^7.0.2",
53
53
  "vitest": "^4.1.10"
54
54
  },