@aravindc26/velu 0.3.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aravindc26/velu",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "A modern documentation site generator powered by Markdown and JSON configuration",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -10,6 +10,69 @@
10
10
  "type": "string",
11
11
  "description": "Path or URL to the JSON schema for editor validation."
12
12
  },
13
+ "theme": {
14
+ "type": "string",
15
+ "description": "Named theme preset that controls the overall look and feel.",
16
+ "enum": ["violet", "maple", "palm", "willow", "linden", "almond", "aspen"],
17
+ "default": "violet"
18
+ },
19
+ "colors": {
20
+ "type": "object",
21
+ "description": "Override the theme's accent colors.",
22
+ "properties": {
23
+ "primary": {
24
+ "type": "string",
25
+ "description": "Primary brand color (hex). Used as accent in both light and dark modes unless overridden.",
26
+ "pattern": "^#[0-9a-fA-F]{6}$"
27
+ },
28
+ "light": {
29
+ "type": "string",
30
+ "description": "Accent color for light mode (hex). Overrides primary in light mode.",
31
+ "pattern": "^#[0-9a-fA-F]{6}$"
32
+ },
33
+ "dark": {
34
+ "type": "string",
35
+ "description": "Accent color for dark mode (hex). Overrides primary in dark mode.",
36
+ "pattern": "^#[0-9a-fA-F]{6}$"
37
+ }
38
+ },
39
+ "additionalProperties": false
40
+ },
41
+ "appearance": {
42
+ "type": "string",
43
+ "description": "Controls light/dark mode behavior.",
44
+ "enum": ["system", "light", "dark"],
45
+ "default": "system"
46
+ },
47
+ "styling": {
48
+ "type": "object",
49
+ "description": "Fine-grained styling options.",
50
+ "properties": {
51
+ "codeblocks": {
52
+ "type": "object",
53
+ "description": "Code block styling options.",
54
+ "properties": {
55
+ "theme": {
56
+ "description": "Shiki theme for syntax highlighting. Use a string for a single theme, or an object with light/dark keys.",
57
+ "oneOf": [
58
+ { "type": "string" },
59
+ {
60
+ "type": "object",
61
+ "properties": {
62
+ "light": { "type": "string" },
63
+ "dark": { "type": "string" }
64
+ },
65
+ "required": ["light", "dark"],
66
+ "additionalProperties": false
67
+ }
68
+ ]
69
+ }
70
+ },
71
+ "additionalProperties": false
72
+ }
73
+ },
74
+ "additionalProperties": false
75
+ },
13
76
  "navigation": {
14
77
  "type": "object",
15
78
  "description": "Defines the site navigation hierarchy: tabs → groups → pages.",
package/src/build.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, rmSync } from "node:fs";
2
2
  import { resolve, join, dirname } from "node:path";
3
+ import { generateThemeCss, type ThemeConfig, type VeluColors, type VeluStyling } from "./themes.js";
3
4
 
4
5
  // ── Types (used only by build.ts for page copying) ─────────────────────────────
5
6
 
@@ -17,6 +18,10 @@ interface VeluTab {
17
18
 
18
19
  interface VeluConfig {
19
20
  $schema?: string;
21
+ theme?: string;
22
+ colors?: VeluColors;
23
+ appearance?: "system" | "light" | "dark";
24
+ styling?: VeluStyling;
20
25
  navigation: {
21
26
  tabs?: VeluTab[];
22
27
  groups?: VeluGroup[];
@@ -132,6 +137,10 @@ export interface VeluTab {
132
137
 
133
138
  export interface VeluConfig {
134
139
  $schema?: string;
140
+ theme?: string;
141
+ colors?: { primary?: string; light?: string; dark?: string };
142
+ appearance?: 'system' | 'light' | 'dark';
143
+ styling?: { codeblocks?: { theme?: string | { light: string; dark: string } } };
135
144
  navigation: {
136
145
  tabs?: VeluTab[];
137
146
  groups?: VeluGroup[];
@@ -296,9 +305,31 @@ export function getTabSidebarMap(): Record<string, string[]> {
296
305
  writeFileSync(join(outDir, "src", "lib", "velu.ts"), veluLib, "utf-8");
297
306
  console.log("📚 Generated config module");
298
307
 
299
- // ── 4. Generate site config ────────────────────────────────────────────────
308
+ // ── 4. Generate theme CSS ─────────────────────────────────────────────────
309
+ const themeCss = generateThemeCss({
310
+ theme: config.theme,
311
+ colors: config.colors,
312
+ appearance: config.appearance,
313
+ styling: config.styling,
314
+ });
315
+ writeFileSync(join(outDir, "src", "styles", "velu-theme.css"), themeCss, "utf-8");
316
+ console.log(`🎨 Generated theme: ${config.theme || "mint"}`);
317
+
318
+ // ── 5. Generate site config ────────────────────────────────────────────────
319
+ // Build expressiveCode config for code block themes
320
+ let expressiveCodeConfig = "";
321
+ if (config.styling?.codeblocks?.theme) {
322
+ const cbt = config.styling.codeblocks.theme;
323
+ if (typeof cbt === "string") {
324
+ expressiveCodeConfig = `\n expressiveCode: { themes: ['${cbt}'] },`;
325
+ } else {
326
+ expressiveCodeConfig = `\n expressiveCode: { themes: ['${cbt.dark}', '${cbt.light}'] },`;
327
+ }
328
+ }
329
+
300
330
  const astroConfig = `import { defineConfig } from 'astro/config';
301
331
  import starlight from '@astrojs/starlight';
332
+ import { ion } from 'starlight-ion-theme';
302
333
  import { getSidebar } from './src/lib/velu.ts';
303
334
 
304
335
  export default defineConfig({
@@ -306,11 +337,12 @@ export default defineConfig({
306
337
  integrations: [
307
338
  starlight({
308
339
  title: 'Velu Docs',
340
+ plugins: [ion()],
309
341
  components: {
310
- Header: './src/components/Header.astro',
311
342
  Sidebar: './src/components/Sidebar.astro',
343
+ PageTitle: './src/components/PageTitle.astro',
312
344
  },
313
- customCss: ['./src/styles/tabs.css'],
345
+ customCss: ['./src/styles/velu-theme.css', './src/styles/tabs.css'],${expressiveCodeConfig}
314
346
  sidebar: getSidebar(),
315
347
  }),
316
348
  ],
@@ -319,64 +351,26 @@ export default defineConfig({
319
351
  writeFileSync(join(outDir, "_config.mjs"), astroConfig, "utf-8");
320
352
  console.log("⚙️ Generated site config");
321
353
 
322
- // ── 5. Generate Header.astro — reads tabs from velu.ts ────────────────────
323
- const headerComponent = `---
324
- import Default from '@astrojs/starlight/components/Header.astro';
325
- import { getTabs } from '../lib/velu.ts';
354
+ // ── 6. Generate Sidebar.astro — tabs at top + filtered content ─────────────
355
+ const sidebarComponent = `---
356
+ import MobileMenuFooter from 'virtual:starlight/components/MobileMenuFooter';
357
+ import SidebarSublist from '@astrojs/starlight/components/SidebarSublist.astro';
358
+ import { getTabs, getTabSidebarMap } from '../lib/velu.ts';
326
359
 
327
360
  const tabs = getTabs();
361
+ const tabSidebarMap = getTabSidebarMap();
328
362
  const currentPath = Astro.url.pathname;
329
363
 
330
364
  function isTabActive(tab: any, path: string): boolean {
331
365
  if (tab.href) return false;
332
366
  if (tab.pathPrefix === '__default__') {
333
367
  const otherPrefixes = tabs
334
- .filter((t) => t.pathPrefix && t.pathPrefix !== '__default__' && !t.href)
335
- .map((t) => t.pathPrefix);
336
- return !otherPrefixes.some((p) => path.startsWith('/' + p + '/'));
368
+ .filter((t: any) => t.pathPrefix && t.pathPrefix !== '__default__' && !t.href)
369
+ .map((t: any) => t.pathPrefix);
370
+ return !otherPrefixes.some((p: string) => path.startsWith('/' + p + '/'));
337
371
  }
338
372
  return path.startsWith('/' + tab.pathPrefix + '/');
339
373
  }
340
- ---
341
-
342
- <Default {...Astro.props}>
343
- <slot />
344
- </Default>
345
-
346
- <nav class="velu-tabs">
347
- <div class="velu-tabs-inner">
348
- {tabs.map((tab) => {
349
- if (tab.href) {
350
- return (
351
- <a href={tab.href} class="velu-tab" target="_blank" rel="noopener noreferrer">
352
- {tab.label}
353
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 17L17 7M17 7H7M17 7V17"/></svg>
354
- </a>
355
- );
356
- }
357
- const active = isTabActive(tab, currentPath);
358
- const href = tab.firstPage ? '/' + tab.firstPage + '/' : '/';
359
- return (
360
- <a href={href} class:list={['velu-tab', { active }]}>
361
- {tab.label}
362
- </a>
363
- );
364
- })}
365
- </div>
366
- </nav>
367
- `;
368
- writeFileSync(join(outDir, "src", "components", "Header.astro"), headerComponent, "utf-8");
369
- console.log("🧩 Generated header component");
370
-
371
- // ── 6. Generate Sidebar.astro — reads filter map from velu.ts ─────────────
372
- const sidebarComponent = `---
373
- import MobileMenuFooter from 'virtual:starlight/components/MobileMenuFooter';
374
- import SidebarPersister from '@astrojs/starlight/components/SidebarPersister.astro';
375
- import SidebarSublist from '@astrojs/starlight/components/SidebarSublist.astro';
376
- import { getTabSidebarMap } from '../lib/velu.ts';
377
-
378
- const tabSidebarMap = getTabSidebarMap();
379
- const currentPath = Astro.url.pathname;
380
374
 
381
375
  function getActivePrefix(path: string): string {
382
376
  const prefixes = Object.keys(tabSidebarMap).filter(p => p !== '__default__');
@@ -396,9 +390,29 @@ const filteredSidebar = sidebar.filter((entry: any) => {
396
390
  });
397
391
  ---
398
392
 
399
- <SidebarPersister>
400
- <SidebarSublist sublist={filteredSidebar} />
401
- </SidebarPersister>
393
+ <div class="velu-sidebar-tabs">
394
+ {tabs.map((tab) => {
395
+ if (tab.href) {
396
+ return (
397
+ <a href={tab.href} class="velu-sidebar-tab" target="_blank" rel="noopener noreferrer">
398
+ {tab.icon && <span class="velu-sidebar-tab-icon" data-icon={tab.icon} />}
399
+ <span>{tab.label}</span>
400
+ <svg class="velu-external-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 17L17 7M17 7H7M17 7V17"/></svg>
401
+ </a>
402
+ );
403
+ }
404
+ const active = isTabActive(tab, currentPath);
405
+ const href = tab.firstPage ? '/' + tab.firstPage + '/' : '/';
406
+ return (
407
+ <a href={href} class:list={['velu-sidebar-tab', { active }]}>
408
+ {tab.icon && <span class="velu-sidebar-tab-icon" data-icon={tab.icon} />}
409
+ <span>{tab.label}</span>
410
+ </a>
411
+ );
412
+ })}
413
+ </div>
414
+
415
+ <SidebarSublist sublist={filteredSidebar} />
402
416
 
403
417
  <div class="md:sl-hidden">
404
418
  <MobileMenuFooter />
@@ -407,75 +421,291 @@ const filteredSidebar = sidebar.filter((entry: any) => {
407
421
  writeFileSync(join(outDir, "src", "components", "Sidebar.astro"), sidebarComponent, "utf-8");
408
422
  console.log("📋 Generated sidebar component");
409
423
 
410
- // ── 7. Generate tabs.css ──────────────────────────────────────────────────
411
- const tabsCss = `/* ── Velu layout overrides ──────────────────────────────────────────────── */
424
+ // ── 7. Generate PageTitle.astro — title row with copy page button ────────
425
+ const pageTitleComponent = `---
426
+ const currentUrl = Astro.url.href;
427
+ const title = Astro.locals.starlightRoute.entry.data.title;
428
+ ---
429
+
430
+ <div class="velu-title-row">
431
+ <h1 id="_top">{title}</h1>
432
+ <div class="velu-copy-page-container">
433
+ <div class="velu-copy-split-btn">
434
+ <button class="velu-copy-main-btn" data-action="direct-copy">
435
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
436
+ <span class="velu-copy-label">Copy page</span>
437
+ </button>
438
+ <span class="velu-copy-sep"></span>
439
+ <button class="velu-copy-caret-btn" aria-expanded="false" aria-haspopup="true">
440
+ <svg class="velu-copy-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
441
+ </button>
442
+ </div>
443
+ <div class="velu-copy-dropdown" hidden>
444
+ <button class="velu-copy-option" data-action="copy">
445
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
446
+ <div>
447
+ <div class="velu-copy-option-title">Copy page</div>
448
+ <div class="velu-copy-option-desc">Copy page as Markdown for LLMs</div>
449
+ </div>
450
+ </button>
451
+ <a class="velu-copy-option" href={\`https://chatgpt.com/?prompt=Read+from+\${encodeURIComponent(currentUrl)}+so+I+can+ask+questions+about+it.\`} target="_blank" rel="noopener noreferrer">
452
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/></svg>
453
+ <div>
454
+ <div class="velu-copy-option-title">Open in ChatGPT <span class="velu-external-arrow">&nearr;</span></div>
455
+ <div class="velu-copy-option-desc">Ask questions about this page</div>
456
+ </div>
457
+ </a>
458
+ <a class="velu-copy-option" href={\`https://claude.ai/new?q=Read+from+\${encodeURIComponent(currentUrl)}+so+I+can+ask+questions+about+it.\`} target="_blank" rel="noopener noreferrer">
459
+ <svg width="18" height="18" viewBox="0 0 200 200" style="overflow:visible" fill="currentColor"><path d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"/></svg>
460
+ <div>
461
+ <div class="velu-copy-option-title">Open in Claude <span class="velu-external-arrow">&nearr;</span></div>
462
+ <div class="velu-copy-option-desc">Ask questions about this page</div>
463
+ </div>
464
+ </a>
465
+ </div>
466
+ </div>
467
+ </div>
468
+
469
+ <script is:inline>
470
+ (function init() {
471
+ var caretBtn = document.querySelector('.velu-copy-caret-btn');
472
+ var mainBtn = document.querySelector('.velu-copy-main-btn');
473
+ var dropdown = document.querySelector('.velu-copy-dropdown');
474
+ var label = document.querySelector('.velu-copy-label');
475
+ if (!caretBtn || !mainBtn || !dropdown) return;
476
+
477
+ function doCopy() {
478
+ if (label) label.textContent = 'Copying...';
479
+ var titleEl = document.querySelector('#_top');
480
+ var article = document.querySelector('.sl-markdown-content') || document.querySelector('.content-panel') || document.querySelector('main');
481
+ var text = '';
482
+ if (titleEl) text = '# ' + titleEl.textContent + '\\n\\n';
483
+ if (article) text += article.innerText;
484
+ if (text) {
485
+ navigator.clipboard.writeText(text).then(function() {
486
+ if (label) label.textContent = 'Copied!';
487
+ setTimeout(function() { if (label) label.textContent = 'Copy page'; }, 1500);
488
+ });
489
+ }
490
+ dropdown.hidden = true;
491
+ caretBtn.setAttribute('aria-expanded', 'false');
492
+ }
493
+
494
+ mainBtn.onclick = function(e) { e.stopPropagation(); doCopy(); };
495
+
496
+ caretBtn.onclick = function(e) {
497
+ e.stopPropagation();
498
+ var open = dropdown.hidden;
499
+ dropdown.hidden = !open;
500
+ caretBtn.setAttribute('aria-expanded', String(open));
501
+ };
502
+
503
+ document.addEventListener('click', function() {
504
+ dropdown.hidden = true;
505
+ caretBtn.setAttribute('aria-expanded', 'false');
506
+ });
507
+
508
+ dropdown.onclick = function(e) { e.stopPropagation(); };
509
+
510
+ var copyOpt = dropdown.querySelector('[data-action="copy"]');
511
+ if (copyOpt) {
512
+ copyOpt.onclick = function() { doCopy(); };
513
+ }
514
+ })();
515
+ </script>
516
+ `;
517
+ writeFileSync(join(outDir, "src", "components", "PageTitle.astro"), pageTitleComponent, "utf-8");
518
+ console.log("📋 Generated page title component");
519
+
520
+ // ── 8. Generate tabs.css ──────────────────────────────────────────────────
521
+ const tabsCss = `/* ── Velu sidebar tabs ─────────────────────────────────────────────────── */
412
522
 
413
523
  :root {
414
- --sl-nav-height: 6rem;
524
+ --sl-sidebar-width: 16rem;
415
525
  }
416
526
 
417
- /* Fixed header: flex column, no bottom padding — tab bar sits at the bottom */
418
- .page > header.header {
527
+ .velu-sidebar-tabs {
419
528
  display: flex;
420
529
  flex-direction: column;
421
- padding-bottom: 0;
530
+ gap: 0.15rem;
531
+ padding: 0.35rem;
532
+ margin-bottom: 1rem;
533
+ background-color: var(--sl-color-bg);
534
+ border: 1px solid var(--sl-color-gray-5);
535
+ border-radius: 0.5rem;
536
+ }
537
+
538
+ .velu-sidebar-tab {
539
+ display: flex;
540
+ align-items: center;
541
+ gap: 0.5rem;
542
+ padding: 0.45rem 0.65rem;
543
+ font-size: var(--sl-text-sm);
544
+ font-weight: 600;
545
+ color: var(--sl-color-gray-3);
546
+ text-decoration: none;
547
+ border-radius: 0.375rem;
548
+ transition: color 0.15s, background-color 0.15s;
549
+ }
550
+
551
+ .velu-sidebar-tab:hover {
552
+ color: var(--sl-color-white);
553
+ background-color: var(--sl-color-gray-6);
422
554
  }
423
555
 
424
- /* Standard nav content fills the top */
425
- .page > header.header > .header.sl-flex {
426
- height: auto;
427
- flex: 1;
556
+ .velu-sidebar-tab.active {
557
+ color: var(--sl-color-white);
558
+ background-color: var(--sl-color-gray-6);
428
559
  }
429
560
 
430
- /* ── Tab bar ───────────────────────────────────────────────────────────── */
561
+ :root[data-theme='light'] .velu-sidebar-tab.active {
562
+ color: var(--sl-color-white);
563
+ background-color: var(--sl-color-gray-7);
564
+ }
431
565
 
432
- .velu-tabs {
566
+ .velu-external-icon {
567
+ opacity: 0.4;
433
568
  flex-shrink: 0;
434
- /* Stretch to full header width past its padding */
435
- margin-inline: calc(-1 * var(--sl-nav-pad-x));
436
- padding-inline: var(--sl-nav-pad-x);
437
- background: var(--sl-color-bg-nav);
569
+ margin-inline-start: auto;
438
570
  }
439
571
 
440
- .velu-tabs-inner {
572
+ /* ── Copy page button ─────────────────────────────────────────────────── */
573
+
574
+ .velu-title-row {
441
575
  display: flex;
442
- gap: 0.25rem;
443
- overflow-x: auto;
576
+ align-items: flex-start;
577
+ justify-content: space-between;
578
+ gap: 1rem;
579
+ }
580
+
581
+ .velu-title-row h1 {
582
+ margin: 0;
444
583
  }
445
584
 
446
- .velu-tab {
585
+ .velu-copy-page-container {
586
+ position: relative;
587
+ flex-shrink: 0;
588
+ margin-top: 0.35rem;
589
+ }
590
+
591
+ .velu-copy-split-btn {
447
592
  display: inline-flex;
448
593
  align-items: center;
449
- gap: 0.35rem;
450
- padding: 0.55rem 0.85rem;
451
- font-size: var(--sl-text-sm);
594
+ border: 1px solid var(--sl-color-gray-5);
595
+ border-radius: 999px;
596
+ background: var(--sl-color-bg-nav);
597
+ }
598
+
599
+ .velu-copy-main-btn {
600
+ display: inline-flex;
601
+ align-items: center;
602
+ gap: 0.4rem;
603
+ padding: 0.35rem 0.5rem 0.35rem 0.75rem;
604
+ font-size: var(--sl-text-xs);
452
605
  font-weight: 500;
453
606
  color: var(--sl-color-gray-3);
607
+ background: none;
608
+ border: none;
609
+ cursor: pointer;
610
+ transition: color 0.15s;
611
+ }
612
+
613
+ .velu-copy-main-btn:hover {
614
+ color: var(--sl-color-white);
615
+ }
616
+
617
+ .velu-copy-sep {
618
+ width: 1px;
619
+ height: 14px;
620
+ background-color: var(--sl-color-gray-5);
621
+ flex-shrink: 0;
622
+ }
623
+
624
+ .velu-copy-caret-btn {
625
+ display: inline-flex;
626
+ align-items: center;
627
+ padding: 0.35rem 0.5rem;
628
+ background: none;
629
+ border: none;
630
+ color: var(--sl-color-gray-3);
631
+ cursor: pointer;
632
+ transition: color 0.15s;
633
+ }
634
+
635
+ .velu-copy-caret-btn:hover {
636
+ color: var(--sl-color-white);
637
+ }
638
+
639
+ .velu-copy-chevron {
640
+ transition: transform 0.15s;
641
+ }
642
+
643
+ .velu-copy-caret-btn[aria-expanded='true'] .velu-copy-chevron {
644
+ transform: rotate(180deg);
645
+ }
646
+
647
+ .velu-copy-dropdown {
648
+ position: absolute;
649
+ right: 0;
650
+ top: calc(100% + 0.35rem);
651
+ z-index: 100;
652
+ min-width: 16rem;
653
+ padding: 0.35rem;
654
+ background: var(--sl-color-bg-nav);
655
+ border: 1px solid var(--sl-color-gray-5);
656
+ border-radius: 0.5rem;
657
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
658
+ }
659
+
660
+ .velu-copy-option {
661
+ display: flex;
662
+ align-items: flex-start;
663
+ gap: 0.5rem;
664
+ width: 100%;
665
+ padding: 0.5rem 0.6rem;
666
+ font: inherit;
667
+ font-size: var(--sl-text-sm);
668
+ color: var(--sl-color-gray-2);
669
+ text-align: left;
454
670
  text-decoration: none;
455
- border-radius: 0.375rem;
456
- transition: color 0.15s, background-color 0.15s;
457
- white-space: nowrap;
671
+ background: none;
672
+ border: none;
673
+ border-radius: 0.35rem;
674
+ cursor: pointer;
675
+ transition: background-color 0.15s;
458
676
  }
459
677
 
460
- .velu-tab:hover {
461
- color: var(--sl-color-gray-1);
678
+ .velu-copy-option:hover {
462
679
  background-color: var(--sl-color-gray-6);
463
680
  }
464
681
 
465
- .velu-tab.active {
466
- color: var(--sl-color-white);
467
- background-color: var(--sl-color-gray-5);
682
+ .velu-copy-option svg {
683
+ flex-shrink: 0;
684
+ opacity: 0.7;
685
+ margin-top: 0.15rem;
686
+ overflow: visible;
468
687
  }
469
688
 
470
- .velu-tab svg {
689
+ .velu-copy-option-title {
690
+ font-weight: 500;
691
+ line-height: 1.3;
692
+ }
693
+
694
+ .velu-copy-option-desc {
695
+ font-size: var(--sl-text-xs);
696
+ color: var(--sl-color-gray-3);
697
+ line-height: 1.3;
698
+ }
699
+
700
+ .velu-external-arrow {
701
+ font-size: 0.75em;
471
702
  opacity: 0.5;
472
- flex-shrink: 0;
473
703
  }
474
704
  `;
475
705
  writeFileSync(join(outDir, "src", "styles", "tabs.css"), tabsCss, "utf-8");
476
706
  console.log("🎨 Generated tabs.css");
477
707
 
478
- // ── 8. Static boilerplate ─────────────────────────────────────────────────
708
+ // ── 9. Static boilerplate ─────────────────────────────────────────────────
479
709
  const astroPkg = {
480
710
  name: "velu-docs-site",
481
711
  version: "0.0.1",
@@ -487,9 +717,10 @@ const filteredSidebar = sidebar.filter((entry: any) => {
487
717
  preview: "astro preview",
488
718
  },
489
719
  dependencies: {
490
- astro: "^5.1.0",
491
- "@astrojs/starlight": "^0.32.0",
720
+ astro: "^5.12.0",
721
+ "@astrojs/starlight": "^0.35.0",
492
722
  sharp: "^0.33.0",
723
+ "starlight-ion-theme": "^2.3.0",
493
724
  },
494
725
  };
495
726
  writeFileSync(join(outDir, "package.json"), JSON.stringify(astroPkg, null, 2) + "\n", "utf-8");
@@ -513,7 +744,7 @@ const filteredSidebar = sidebar.filter((entry: any) => {
513
744
  "utf-8"
514
745
  );
515
746
 
516
- // ── 9. Generate _server.mjs — programmatic dev/build/preview ────────────────
747
+ // ── 10. Generate _server.mjs — programmatic dev/build/preview ───────────────
517
748
  const serverScript = `import { dev, build, preview } from 'astro';
518
749
  import { watch } from 'node:fs';
519
750
  import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from 'node:fs';
@@ -623,7 +854,7 @@ if (command === 'dev') {
623
854
  `;
624
855
  writeFileSync(join(outDir, "_server.mjs"), serverScript, "utf-8");
625
856
 
626
- // ── 10. Generate .gitignore ──────────────────────────────────────────────
857
+ // ── 11. Generate .gitignore ──────────────────────────────────────────────
627
858
  writeFileSync(
628
859
  join(outDir, ".gitignore"),
629
860
  `.astro/\nnode_modules/\ndist/\n`,
package/src/cli.ts CHANGED
@@ -38,6 +38,7 @@ function init(targetDir: string) {
38
38
  // velu.json
39
39
  const config = {
40
40
  $schema: "https://raw.githubusercontent.com/aravindc26/velu/main/schema/velu.schema.json",
41
+ theme: "violet" as const,
41
42
  navigation: {
42
43
  tabs: [
43
44
  {
package/src/themes.ts ADDED
@@ -0,0 +1,358 @@
1
+ // ── Types ────────────────────────────────────────────────────────────────────
2
+
3
+ interface ColorSet {
4
+ accentLow: string;
5
+ accent: string;
6
+ accentHigh: string;
7
+ white: string;
8
+ gray1: string;
9
+ gray2: string;
10
+ gray3: string;
11
+ gray4: string;
12
+ gray5: string;
13
+ gray6: string;
14
+ gray7: string;
15
+ black: string;
16
+ }
17
+
18
+ interface ThemePreset {
19
+ dark: ColorSet;
20
+ light: ColorSet;
21
+ font?: string;
22
+ fontMono?: string;
23
+ }
24
+
25
+ interface VeluColors {
26
+ primary?: string;
27
+ light?: string;
28
+ dark?: string;
29
+ }
30
+
31
+ interface VeluStyling {
32
+ codeblocks?: {
33
+ theme?: string | { light: string; dark: string };
34
+ };
35
+ }
36
+
37
+ interface ThemeConfig {
38
+ theme?: string;
39
+ colors?: VeluColors;
40
+ appearance?: "system" | "light" | "dark";
41
+ styling?: VeluStyling;
42
+ }
43
+
44
+ // ── Color utilities ──────────────────────────────────────────────────────────
45
+
46
+ function hexToRgb(hex: string): [number, number, number] {
47
+ const h = hex.replace("#", "");
48
+ return [
49
+ parseInt(h.substring(0, 2), 16),
50
+ parseInt(h.substring(2, 4), 16),
51
+ parseInt(h.substring(4, 6), 16),
52
+ ];
53
+ }
54
+
55
+ function rgbToHex(r: number, g: number, b: number): string {
56
+ const clamp = (v: number) => Math.round(Math.max(0, Math.min(255, v)));
57
+ return (
58
+ "#" +
59
+ [clamp(r), clamp(g), clamp(b)]
60
+ .map((c) => c.toString(16).padStart(2, "0"))
61
+ .join("")
62
+ );
63
+ }
64
+
65
+ function mixColors(hex1: string, hex2: string, weight: number): string {
66
+ const [r1, g1, b1] = hexToRgb(hex1);
67
+ const [r2, g2, b2] = hexToRgb(hex2);
68
+ return rgbToHex(
69
+ r1 * weight + r2 * (1 - weight),
70
+ g1 * weight + g2 * (1 - weight),
71
+ b1 * weight + b2 * (1 - weight)
72
+ );
73
+ }
74
+
75
+ function deriveAccentPalette(primary: string): { dark: Pick<ColorSet, "accentLow" | "accent" | "accentHigh">; light: Pick<ColorSet, "accentLow" | "accent" | "accentHigh"> } {
76
+ return {
77
+ dark: {
78
+ accentLow: mixColors(primary, "#000000", 0.3),
79
+ accent: mixColors(primary, "#ffffff", 0.2),
80
+ accentHigh: mixColors(primary, "#ffffff", 0.8),
81
+ },
82
+ light: {
83
+ accentLow: mixColors(primary, "#ffffff", 0.15),
84
+ accent: primary,
85
+ accentHigh: mixColors(primary, "#000000", 0.55),
86
+ },
87
+ };
88
+ }
89
+
90
+ // ── Gray palettes ────────────────────────────────────────────────────────────
91
+ // Starlight convention: gray-1 = strongest foreground, gray-7 = subtlest
92
+ // In dark mode: gray-1 is light, gray-7 is dark
93
+ // In light mode: gray-1 is dark, gray-7 is light
94
+ // --sl-color-white = foreground extreme, --sl-color-black = background extreme
95
+
96
+ const GRAY_SLATE = {
97
+ dark: {
98
+ white: "#ffffff",
99
+ gray1: "#eceef2",
100
+ gray2: "#c0c2c7",
101
+ gray3: "#888b96",
102
+ gray4: "#545861",
103
+ gray5: "#353841",
104
+ gray6: "#24272f",
105
+ gray7: "#17181c",
106
+ black: "#13141a",
107
+ },
108
+ light: {
109
+ white: "#13141a",
110
+ gray1: "#17181c",
111
+ gray2: "#24272f",
112
+ gray3: "#545861",
113
+ gray4: "#888b96",
114
+ gray5: "#c0c2c7",
115
+ gray6: "#eceef2",
116
+ gray7: "#f5f6f8",
117
+ black: "#ffffff",
118
+ },
119
+ };
120
+
121
+ const GRAY_ZINC = {
122
+ dark: {
123
+ white: "#ffffff",
124
+ gray1: "#ececef",
125
+ gray2: "#bfc0c4",
126
+ gray3: "#878890",
127
+ gray4: "#53545c",
128
+ gray5: "#34353b",
129
+ gray6: "#23242a",
130
+ gray7: "#17171a",
131
+ black: "#121214",
132
+ },
133
+ light: {
134
+ white: "#121214",
135
+ gray1: "#17171a",
136
+ gray2: "#23242a",
137
+ gray3: "#53545c",
138
+ gray4: "#878890",
139
+ gray5: "#bfc0c4",
140
+ gray6: "#ececef",
141
+ gray7: "#f5f5f7",
142
+ black: "#ffffff",
143
+ },
144
+ };
145
+
146
+ const GRAY_STONE = {
147
+ dark: {
148
+ white: "#ffffff",
149
+ gray1: "#eeeceb",
150
+ gray2: "#c3bfbb",
151
+ gray3: "#8c8680",
152
+ gray4: "#585550",
153
+ gray5: "#383532",
154
+ gray6: "#272421",
155
+ gray7: "#1a1816",
156
+ black: "#141210",
157
+ },
158
+ light: {
159
+ white: "#141210",
160
+ gray1: "#1a1816",
161
+ gray2: "#272421",
162
+ gray3: "#585550",
163
+ gray4: "#8c8680",
164
+ gray5: "#c3bfbb",
165
+ gray6: "#eeeceb",
166
+ gray7: "#f7f6f5",
167
+ black: "#ffffff",
168
+ },
169
+ };
170
+
171
+ // ── Theme presets ────────────────────────────────────────────────────────────
172
+
173
+ const THEMES: Record<string, ThemePreset> = {
174
+ violet: {
175
+ dark: {
176
+ accentLow: "#1e1b4b",
177
+ accent: "#818cf8",
178
+ accentHigh: "#e0e7ff",
179
+ ...GRAY_SLATE.dark,
180
+ },
181
+ light: {
182
+ accentLow: "#e0e7ff",
183
+ accent: "#4f46e5",
184
+ accentHigh: "#1e1b4b",
185
+ ...GRAY_SLATE.light,
186
+ },
187
+ },
188
+
189
+ maple: {
190
+ dark: {
191
+ accentLow: "#2e1065",
192
+ accent: "#a78bfa",
193
+ accentHigh: "#ede9fe",
194
+ ...GRAY_ZINC.dark,
195
+ },
196
+ light: {
197
+ accentLow: "#ede9fe",
198
+ accent: "#7c3aed",
199
+ accentHigh: "#2e1065",
200
+ ...GRAY_ZINC.light,
201
+ },
202
+ },
203
+
204
+ palm: {
205
+ dark: {
206
+ accentLow: "#0c2d44",
207
+ accent: "#38bdf8",
208
+ accentHigh: "#e0f2fe",
209
+ ...GRAY_SLATE.dark,
210
+ },
211
+ light: {
212
+ accentLow: "#e0f2fe",
213
+ accent: "#0369a1",
214
+ accentHigh: "#0c2d44",
215
+ ...GRAY_SLATE.light,
216
+ },
217
+ },
218
+
219
+ willow: {
220
+ dark: {
221
+ accentLow: "#292524",
222
+ accent: "#a8a29e",
223
+ accentHigh: "#fafaf9",
224
+ ...GRAY_STONE.dark,
225
+ },
226
+ light: {
227
+ accentLow: "#f5f5f4",
228
+ accent: "#57534e",
229
+ accentHigh: "#1c1917",
230
+ ...GRAY_STONE.light,
231
+ },
232
+ },
233
+
234
+ linden: {
235
+ dark: {
236
+ accentLow: "#052e16",
237
+ accent: "#4ade80",
238
+ accentHigh: "#dcfce7",
239
+ ...GRAY_ZINC.dark,
240
+ },
241
+ light: {
242
+ accentLow: "#dcfce7",
243
+ accent: "#16a34a",
244
+ accentHigh: "#052e16",
245
+ ...GRAY_ZINC.light,
246
+ },
247
+ font: "'JetBrains Mono', 'Fira Code', 'Courier New', monospace",
248
+ },
249
+
250
+ almond: {
251
+ dark: {
252
+ accentLow: "#451a03",
253
+ accent: "#fbbf24",
254
+ accentHigh: "#fef3c7",
255
+ ...GRAY_STONE.dark,
256
+ },
257
+ light: {
258
+ accentLow: "#fef3c7",
259
+ accent: "#b45309",
260
+ accentHigh: "#451a03",
261
+ ...GRAY_STONE.light,
262
+ },
263
+ },
264
+
265
+ aspen: {
266
+ dark: {
267
+ accentLow: "#1e1b4b",
268
+ accent: "#818cf8",
269
+ accentHigh: "#e0e7ff",
270
+ ...GRAY_SLATE.dark,
271
+ },
272
+ light: {
273
+ accentLow: "#e0e7ff",
274
+ accent: "#4f46e5",
275
+ accentHigh: "#1e1b4b",
276
+ ...GRAY_SLATE.light,
277
+ },
278
+ },
279
+ };
280
+
281
+ // ── CSS generator ────────────────────────────────────────────────────────────
282
+
283
+ function colorSetToCss(colors: ColorSet): string {
284
+ return [
285
+ ` --sl-color-accent-low: ${colors.accentLow};`,
286
+ ` --sl-color-accent: ${colors.accent};`,
287
+ ` --sl-color-accent-high: ${colors.accentHigh};`,
288
+ ` --sl-color-white: ${colors.white};`,
289
+ ` --sl-color-gray-1: ${colors.gray1};`,
290
+ ` --sl-color-gray-2: ${colors.gray2};`,
291
+ ` --sl-color-gray-3: ${colors.gray3};`,
292
+ ` --sl-color-gray-4: ${colors.gray4};`,
293
+ ` --sl-color-gray-5: ${colors.gray5};`,
294
+ ` --sl-color-gray-6: ${colors.gray6};`,
295
+ ` --sl-color-gray-7: ${colors.gray7};`,
296
+ ` --sl-color-black: ${colors.black};`,
297
+ ].join("\n");
298
+ }
299
+
300
+ function generateThemeCss(config: ThemeConfig): string {
301
+ const themeName = config.theme || "violet";
302
+ const preset = THEMES[themeName] || THEMES["violet"];
303
+
304
+ const darkColors: ColorSet = { ...preset.dark };
305
+ const lightColors: ColorSet = { ...preset.light };
306
+
307
+ // Apply color overrides
308
+ if (config.colors) {
309
+ const { primary, light, dark } = config.colors;
310
+
311
+ const lightAccent = light || primary;
312
+ const darkAccent = dark || primary;
313
+
314
+ if (lightAccent) {
315
+ const palette = deriveAccentPalette(lightAccent);
316
+ lightColors.accentLow = palette.light.accentLow;
317
+ lightColors.accent = palette.light.accent;
318
+ lightColors.accentHigh = palette.light.accentHigh;
319
+ }
320
+
321
+ if (darkAccent) {
322
+ const palette = deriveAccentPalette(darkAccent);
323
+ darkColors.accentLow = palette.dark.accentLow;
324
+ darkColors.accent = palette.dark.accent;
325
+ darkColors.accentHigh = palette.dark.accentHigh;
326
+ }
327
+ }
328
+
329
+ const lines: string[] = [];
330
+ lines.push(`/* Velu Theme: ${themeName} */`);
331
+ lines.push("");
332
+
333
+ // Dark mode (Starlight default)
334
+ lines.push(":root {");
335
+ lines.push(colorSetToCss(darkColors));
336
+ if (preset.font) {
337
+ lines.push(` --sl-font: ${preset.font};`);
338
+ }
339
+ if (preset.fontMono) {
340
+ lines.push(` --sl-font-mono: ${preset.fontMono};`);
341
+ }
342
+ lines.push("}");
343
+ lines.push("");
344
+
345
+ // Light mode
346
+ lines.push(":root[data-theme='light'] {");
347
+ lines.push(colorSetToCss(lightColors));
348
+ lines.push("}");
349
+ lines.push("");
350
+
351
+ return lines.join("\n");
352
+ }
353
+
354
+ function getThemeNames(): string[] {
355
+ return Object.keys(THEMES);
356
+ }
357
+
358
+ export { generateThemeCss, getThemeNames, THEMES, ThemeConfig, VeluColors, VeluStyling };
package/src/validate.ts CHANGED
@@ -21,6 +21,10 @@ interface VeluTab {
21
21
 
22
22
  interface VeluConfig {
23
23
  $schema?: string;
24
+ theme?: string;
25
+ colors?: { primary?: string; light?: string; dark?: string };
26
+ appearance?: "system" | "light" | "dark";
27
+ styling?: { codeblocks?: { theme?: string | { light: string; dark: string } } };
24
28
  navigation: {
25
29
  tabs?: VeluTab[];
26
30
  groups?: VeluGroup[];