@specglass/theme-default 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/dist/__tests__/code-tabs.test.d.ts +2 -0
  2. package/dist/__tests__/code-tabs.test.d.ts.map +1 -0
  3. package/dist/__tests__/code-tabs.test.js +219 -0
  4. package/dist/__tests__/code-tabs.test.js.map +1 -0
  5. package/dist/__tests__/copy-button.test.d.ts +2 -0
  6. package/dist/__tests__/copy-button.test.d.ts.map +1 -0
  7. package/dist/__tests__/copy-button.test.js +116 -0
  8. package/dist/__tests__/copy-button.test.js.map +1 -0
  9. package/dist/__tests__/search-palette.test.d.ts +2 -0
  10. package/dist/__tests__/search-palette.test.d.ts.map +1 -0
  11. package/dist/__tests__/search-palette.test.js +71 -0
  12. package/dist/__tests__/search-palette.test.js.map +1 -0
  13. package/dist/__tests__/shiki.test.d.ts +2 -0
  14. package/dist/__tests__/shiki.test.d.ts.map +1 -0
  15. package/dist/__tests__/shiki.test.js +37 -0
  16. package/dist/__tests__/shiki.test.js.map +1 -0
  17. package/dist/__tests__/theme-css.test.d.ts +2 -0
  18. package/dist/__tests__/theme-css.test.d.ts.map +1 -0
  19. package/dist/__tests__/theme-css.test.js +124 -0
  20. package/dist/__tests__/theme-css.test.js.map +1 -0
  21. package/dist/__tests__/theme-helpers.test.d.ts +2 -0
  22. package/dist/__tests__/theme-helpers.test.d.ts.map +1 -0
  23. package/dist/__tests__/theme-helpers.test.js +81 -0
  24. package/dist/__tests__/theme-helpers.test.js.map +1 -0
  25. package/dist/index.d.ts +63 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +13 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/islands/CodeTabs.d.ts +21 -0
  30. package/dist/islands/CodeTabs.d.ts.map +1 -0
  31. package/dist/islands/CodeTabs.js +125 -0
  32. package/dist/islands/CodeTabs.js.map +1 -0
  33. package/dist/islands/CopyButton.d.ts +16 -0
  34. package/dist/islands/CopyButton.d.ts.map +1 -0
  35. package/dist/islands/CopyButton.js +54 -0
  36. package/dist/islands/CopyButton.js.map +1 -0
  37. package/dist/islands/SearchPalette.d.ts +2 -0
  38. package/dist/islands/SearchPalette.d.ts.map +1 -0
  39. package/dist/islands/SearchPalette.js +109 -0
  40. package/dist/islands/SearchPalette.js.map +1 -0
  41. package/dist/islands/SearchResults.d.ts +2 -0
  42. package/dist/islands/SearchResults.d.ts.map +1 -0
  43. package/dist/islands/SearchResults.js +130 -0
  44. package/dist/islands/SearchResults.js.map +1 -0
  45. package/dist/islands/ThemeToggle.d.ts +12 -0
  46. package/dist/islands/ThemeToggle.d.ts.map +1 -0
  47. package/dist/islands/ThemeToggle.js +43 -0
  48. package/dist/islands/ThemeToggle.js.map +1 -0
  49. package/dist/layouts/DocPage.test.d.ts +2 -0
  50. package/dist/layouts/DocPage.test.d.ts.map +1 -0
  51. package/dist/layouts/DocPage.test.js +165 -0
  52. package/dist/layouts/DocPage.test.js.map +1 -0
  53. package/dist/lib/utils.d.ts +10 -0
  54. package/dist/lib/utils.d.ts.map +1 -0
  55. package/dist/lib/utils.js +13 -0
  56. package/dist/lib/utils.js.map +1 -0
  57. package/dist/scripts/code-block-enhancer.d.ts +16 -0
  58. package/dist/scripts/code-block-enhancer.d.ts.map +1 -0
  59. package/dist/scripts/code-block-enhancer.js +55 -0
  60. package/dist/scripts/code-block-enhancer.js.map +1 -0
  61. package/dist/ui/command.d.ts +87 -0
  62. package/dist/ui/command.d.ts.map +1 -0
  63. package/dist/ui/command.js +28 -0
  64. package/dist/ui/command.js.map +1 -0
  65. package/dist/ui/dialog.d.ts +20 -0
  66. package/dist/ui/dialog.d.ts.map +1 -0
  67. package/dist/ui/dialog.js +22 -0
  68. package/dist/ui/dialog.js.map +1 -0
  69. package/dist/utils/parse-highlight-range.d.ts +12 -0
  70. package/dist/utils/parse-highlight-range.d.ts.map +1 -0
  71. package/dist/utils/parse-highlight-range.js +40 -0
  72. package/dist/utils/parse-highlight-range.js.map +1 -0
  73. package/dist/utils/parse-highlight-range.test.d.ts +2 -0
  74. package/dist/utils/parse-highlight-range.test.d.ts.map +1 -0
  75. package/dist/utils/parse-highlight-range.test.js +32 -0
  76. package/dist/utils/parse-highlight-range.test.js.map +1 -0
  77. package/dist/utils/schema-renderer.d.ts +38 -0
  78. package/dist/utils/schema-renderer.d.ts.map +1 -0
  79. package/dist/utils/schema-renderer.js +115 -0
  80. package/dist/utils/schema-renderer.js.map +1 -0
  81. package/dist/utils/schema-renderer.test.d.ts +2 -0
  82. package/dist/utils/schema-renderer.test.d.ts.map +1 -0
  83. package/dist/utils/schema-renderer.test.js +219 -0
  84. package/dist/utils/schema-renderer.test.js.map +1 -0
  85. package/dist/utils/shiki.d.ts +20 -0
  86. package/dist/utils/shiki.d.ts.map +1 -0
  87. package/dist/utils/shiki.js +84 -0
  88. package/dist/utils/shiki.js.map +1 -0
  89. package/dist/utils/sidebar-helpers.d.ts +10 -0
  90. package/dist/utils/sidebar-helpers.d.ts.map +1 -0
  91. package/dist/utils/sidebar-helpers.js +14 -0
  92. package/dist/utils/sidebar-helpers.js.map +1 -0
  93. package/dist/utils/theme-css.d.ts +21 -0
  94. package/dist/utils/theme-css.d.ts.map +1 -0
  95. package/dist/utils/theme-css.js +77 -0
  96. package/dist/utils/theme-css.js.map +1 -0
  97. package/dist/utils/theme-helpers.d.ts +28 -0
  98. package/dist/utils/theme-helpers.d.ts.map +1 -0
  99. package/dist/utils/theme-helpers.js +55 -0
  100. package/dist/utils/theme-helpers.js.map +1 -0
  101. package/dist/utils/toc-helpers.d.ts +12 -0
  102. package/dist/utils/toc-helpers.d.ts.map +1 -0
  103. package/dist/utils/toc-helpers.js +9 -0
  104. package/dist/utils/toc-helpers.js.map +1 -0
  105. package/package.json +68 -0
  106. package/src/components/ApiAuth.astro +116 -0
  107. package/src/components/ApiEndpoint.astro +75 -0
  108. package/src/components/ApiNavigation.astro +110 -0
  109. package/src/components/ApiParameters.astro +204 -0
  110. package/src/components/ApiResponse.astro +144 -0
  111. package/src/components/Callout.astro +54 -0
  112. package/src/components/Card.astro +46 -0
  113. package/src/components/CodeBlock.astro +142 -0
  114. package/src/components/CodeBlockGroup.astro +196 -0
  115. package/src/components/CodeTabs.astro +53 -0
  116. package/src/components/Footer.astro +41 -0
  117. package/src/components/Header.astro +80 -0
  118. package/src/components/Sidebar.astro +117 -0
  119. package/src/components/TabItem.astro +24 -0
  120. package/src/components/TableOfContents.astro +111 -0
  121. package/src/components/Tabs.astro +185 -0
  122. package/src/islands/CodeTabs.tsx +212 -0
  123. package/src/islands/CopyButton.tsx +101 -0
  124. package/src/islands/SearchPalette.tsx +307 -0
  125. package/src/islands/SearchResults.tsx +301 -0
  126. package/src/islands/ThemeToggle.tsx +107 -0
  127. package/src/layouts/ApiReferencePage.astro +239 -0
  128. package/src/layouts/DocPage.astro +199 -0
  129. package/src/layouts/DocPage.test.ts +183 -0
  130. package/src/layouts/LandingPage.astro +143 -0
  131. package/src/lib/utils.ts +13 -0
  132. package/src/styles/global.css +241 -0
  133. package/src/utils/parse-highlight-range.test.ts +40 -0
  134. package/src/utils/parse-highlight-range.ts +41 -0
  135. package/src/utils/schema-renderer.test.ts +269 -0
  136. package/src/utils/schema-renderer.ts +152 -0
  137. package/src/utils/shiki.ts +99 -0
  138. package/src/utils/sidebar-helpers.ts +24 -0
  139. package/src/utils/theme-css.ts +101 -0
  140. package/src/utils/theme-helpers.ts +59 -0
  141. package/src/utils/toc-helpers.ts +11 -0
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Tests for DocPage layout supporting utilities and contracts.
3
+ *
4
+ * Note: .astro components cannot be unit tested directly in Vitest.
5
+ * These tests verify the shared utils that the layout components import,
6
+ * and validate data contracts. Build verification confirms rendering.
7
+ */
8
+ import { describe, it, expect } from "vitest";
9
+ import type { NavItem, NavigationTree } from "@specglass/core";
10
+ import {
11
+ filterVisibleItems,
12
+ isActiveOrAncestor,
13
+ } from "../utils/sidebar-helpers.js";
14
+ import { filterTocHeadings, type Heading } from "../utils/toc-helpers.js";
15
+
16
+ // ─── Tests ─────────────────────────────────────────────────────────
17
+
18
+ describe("Sidebar filtering", () => {
19
+ it("excludes hidden items", () => {
20
+ const items: NavItem[] = [
21
+ { title: "Visible", slug: "visible", type: "page" },
22
+ { title: "Hidden", slug: "hidden", type: "page", hidden: true },
23
+ { title: "Also Visible", slug: "also-visible", type: "page" },
24
+ ];
25
+ const result = filterVisibleItems(items);
26
+ expect(result).toHaveLength(2);
27
+ expect(result.map((i) => i.title)).toEqual(["Visible", "Also Visible"]);
28
+ });
29
+
30
+ it("includes all items when none are hidden", () => {
31
+ const items: NavItem[] = [
32
+ { title: "A", slug: "a", type: "page" },
33
+ { title: "B", slug: "b", type: "page" },
34
+ ];
35
+ expect(filterVisibleItems(items)).toHaveLength(2);
36
+ });
37
+
38
+ it("returns empty array for all-hidden items", () => {
39
+ const items: NavItem[] = [
40
+ { title: "A", slug: "a", type: "page", hidden: true },
41
+ ];
42
+ expect(filterVisibleItems(items)).toHaveLength(0);
43
+ });
44
+ });
45
+
46
+ describe("Sidebar active detection", () => {
47
+ it("detects direct page match", () => {
48
+ const item: NavItem = { title: "Guide", slug: "guide", type: "page" };
49
+ expect(isActiveOrAncestor(item, "guide")).toBe(true);
50
+ expect(isActiveOrAncestor(item, "other")).toBe(false);
51
+ });
52
+
53
+ it("detects ancestor section when child is active", () => {
54
+ const section: NavItem = {
55
+ title: "API",
56
+ slug: "api",
57
+ type: "section",
58
+ children: [
59
+ { title: "Auth", slug: "api/auth", type: "page" },
60
+ { title: "Users", slug: "api/users", type: "page" },
61
+ ],
62
+ };
63
+ expect(isActiveOrAncestor(section, "api/auth")).toBe(true);
64
+ expect(isActiveOrAncestor(section, "api/users")).toBe(true);
65
+ expect(isActiveOrAncestor(section, "other")).toBe(false);
66
+ });
67
+
68
+ it("handles deeply nested sections", () => {
69
+ const root: NavItem = {
70
+ title: "Docs",
71
+ slug: "docs",
72
+ type: "section",
73
+ children: [
74
+ {
75
+ title: "Advanced",
76
+ slug: "docs/advanced",
77
+ type: "section",
78
+ children: [
79
+ {
80
+ title: "Plugins",
81
+ slug: "docs/advanced/plugins",
82
+ type: "page",
83
+ },
84
+ ],
85
+ },
86
+ ],
87
+ };
88
+ expect(isActiveOrAncestor(root, "docs/advanced/plugins")).toBe(true);
89
+ expect(isActiveOrAncestor(root, "other")).toBe(false);
90
+ });
91
+
92
+ it("ignores external links", () => {
93
+ const item: NavItem = {
94
+ title: "External",
95
+ slug: "",
96
+ type: "external-link",
97
+ href: "https://example.com",
98
+ };
99
+ expect(isActiveOrAncestor(item, "")).toBe(false);
100
+ });
101
+ });
102
+
103
+ describe("Table of contents heading filtering", () => {
104
+ it("includes h2 and h3 headings only", () => {
105
+ const headings: Heading[] = [
106
+ { depth: 1, slug: "title", text: "Title" },
107
+ { depth: 2, slug: "intro", text: "Introduction" },
108
+ { depth: 3, slug: "detail", text: "Detail" },
109
+ { depth: 4, slug: "deep", text: "Deep" },
110
+ ];
111
+ const result = filterTocHeadings(headings);
112
+ expect(result).toHaveLength(2);
113
+ expect(result.map((h) => h.text)).toEqual(["Introduction", "Detail"]);
114
+ });
115
+
116
+ it("returns empty array when no h2/h3 headings exist", () => {
117
+ const headings: Heading[] = [
118
+ { depth: 1, slug: "title", text: "Title" },
119
+ { depth: 4, slug: "deep", text: "Deep" },
120
+ ];
121
+ expect(filterTocHeadings(headings)).toHaveLength(0);
122
+ });
123
+
124
+ it("handles empty headings array", () => {
125
+ expect(filterTocHeadings([])).toHaveLength(0);
126
+ });
127
+ });
128
+
129
+ describe("NavigationTree contract", () => {
130
+ it("matches expected shape for layout props", () => {
131
+ const tree: NavigationTree = {
132
+ items: [
133
+ { title: "Getting Started", slug: "getting-started", type: "page" },
134
+ {
135
+ title: "API Reference",
136
+ slug: "api",
137
+ type: "section",
138
+ collapsed: true,
139
+ children: [
140
+ { title: "Auth", slug: "api/auth", type: "page" },
141
+ {
142
+ title: "GitHub",
143
+ slug: "",
144
+ type: "external-link",
145
+ href: "https://github.com",
146
+ },
147
+ ],
148
+ },
149
+ ],
150
+ };
151
+
152
+ expect(tree.items).toHaveLength(2);
153
+ expect(tree.items[0].type).toBe("page");
154
+ expect(tree.items[1].type).toBe("section");
155
+ expect(tree.items[1].collapsed).toBe(true);
156
+ expect(tree.items[1].children).toHaveLength(2);
157
+ expect(tree.items[1].children![1].type).toBe("external-link");
158
+ expect(tree.items[1].children![1].href).toBe("https://github.com");
159
+ });
160
+ });
161
+
162
+ describe("DocPage props contract", () => {
163
+ it("validates expected frontmatter shape", () => {
164
+ const frontmatter: Record<string, unknown> = {
165
+ title: "Getting Started",
166
+ description: "A guide to getting started",
167
+ };
168
+ expect(frontmatter.title).toBeDefined();
169
+ expect(typeof frontmatter.title).toBe("string");
170
+ });
171
+
172
+ it("headings array has correct shape", () => {
173
+ const headings = [
174
+ { depth: 2, slug: "installation", text: "Installation" },
175
+ { depth: 3, slug: "npm", text: "Using npm" },
176
+ ];
177
+ headings.forEach((h) => {
178
+ expect(typeof h.depth).toBe("number");
179
+ expect(typeof h.slug).toBe("string");
180
+ expect(typeof h.text).toBe("string");
181
+ });
182
+ });
183
+ });
@@ -0,0 +1,143 @@
1
+ ---
2
+ /**
3
+ * LandingPage layout — full-width, no sidebar.
4
+ *
5
+ * Used for the site index page or any page with `layout: landing` in frontmatter.
6
+ * Architecture mandate: one of three MVP layouts (DocPage, ApiReferencePage, LandingPage).
7
+ * Typed props from core: { frontmatter, content, navigation }.
8
+ */
9
+ import Header from "../components/Header.astro";
10
+ import Footer from "../components/Footer.astro";
11
+ import { ThemeToggle } from "../islands/ThemeToggle";
12
+ import { SearchPalette } from "../islands/SearchPalette";
13
+ import "../styles/global.css";
14
+ import type { NavigationTree } from "@specglass/core";
15
+ import { config } from "virtual:specglass/config";
16
+ import { generateThemeCSS } from "../utils/theme-css";
17
+
18
+ export interface Props {
19
+ /** Page title from frontmatter */
20
+ title: string;
21
+ /** Page description from frontmatter */
22
+ description?: string;
23
+ /** Full frontmatter data */
24
+ frontmatter: Record<string, unknown>;
25
+ /** Navigation tree (for header links) */
26
+ navigation: NavigationTree;
27
+ }
28
+
29
+ const { title, description, frontmatter } = Astro.props;
30
+ const themeCSS = generateThemeCSS(config);
31
+
32
+ // Hero section configuration from frontmatter
33
+ const heroTitle = (frontmatter.hero_title as string) ?? (frontmatter.title as string) ?? title;
34
+ const heroSubtitle = (frontmatter.hero_subtitle as string) ?? description;
35
+ const ctaLabel = (frontmatter.cta_label as string) ?? "Get Started";
36
+ const ctaHref = (frontmatter.cta_href as string) ?? "/getting-started";
37
+ const ctaSecondaryLabel = frontmatter.cta_secondary_label as string | undefined;
38
+ const ctaSecondaryHref = frontmatter.cta_secondary_href as string | undefined;
39
+ ---
40
+
41
+ <html lang="en" class="dark">
42
+ <head>
43
+ <meta charset="utf-8" />
44
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
45
+ <title>{title}</title>
46
+ {description && <meta name="description" content={description} />}
47
+ {config.theme?.favicon && <link rel="icon" href={config.theme.favicon} />}
48
+ {config.theme?.socialImage && <meta property="og:image" content={config.theme.socialImage} />}
49
+ {themeCSS && <style set:html={themeCSS} />}
50
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
51
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
52
+ <link
53
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
54
+ rel="stylesheet"
55
+ />
56
+ <!-- FOUC prevention: apply stored theme before first paint -->
57
+ <script is:inline>
58
+ (function () {
59
+ function applyStoredTheme() {
60
+ var stored = null;
61
+ try {
62
+ stored = localStorage.getItem("specglass-theme");
63
+ } catch (e) {}
64
+ var theme = stored === "dark" || stored === "light" ? stored : "dark";
65
+ if (theme === "dark") {
66
+ document.documentElement.classList.add("dark");
67
+ } else {
68
+ document.documentElement.classList.remove("dark");
69
+ }
70
+ }
71
+ applyStoredTheme();
72
+ if (!window.__sgThemeInit) {
73
+ window.__sgThemeInit = true;
74
+ document.addEventListener("astro:after-swap", applyStoredTheme);
75
+ }
76
+ })();
77
+ </script>
78
+ </head>
79
+ <body class="min-h-screen bg-surface text-text">
80
+ <!-- Skip to content link for accessibility -->
81
+ <a
82
+ href="#main-content"
83
+ class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:bg-primary focus:text-white focus:px-4 focus:py-2 focus:rounded"
84
+ >
85
+ Skip to content
86
+ </a>
87
+
88
+ <Header>
89
+ <SearchPalette client:idle />
90
+ <ThemeToggle client:load />
91
+ </Header>
92
+
93
+ <main id="main-content" class="pt-(--height-header)" data-pagefind-body>
94
+ <!-- Hero section -->
95
+ <section class="relative overflow-hidden py-24 px-6 text-center" aria-label="Hero">
96
+ <div
97
+ class="absolute inset-0 bg-linear-to-br from-primary/10 via-transparent to-primary/5 dark:from-primary/15 dark:to-primary/5"
98
+ aria-hidden="true"
99
+ >
100
+ </div>
101
+ <div class="relative max-w-3xl mx-auto">
102
+ <h1
103
+ class="text-5xl font-bold tracking-tight mb-6 bg-linear-to-r from-primary to-primary-light bg-clip-text text-transparent"
104
+ >
105
+ {heroTitle}
106
+ </h1>
107
+ {
108
+ heroSubtitle && (
109
+ <p class="text-xl text-text-muted mb-10 max-w-2xl mx-auto leading-relaxed">
110
+ {heroSubtitle}
111
+ </p>
112
+ )
113
+ }
114
+ <div class="flex justify-center gap-4 flex-wrap">
115
+ <a
116
+ href={ctaHref}
117
+ class="inline-flex items-center px-6 py-3 bg-primary text-white font-medium rounded-lg hover:bg-primary-dark transition-colors shadow-lg shadow-primary/25"
118
+ >
119
+ {ctaLabel}
120
+ </a>
121
+ {
122
+ ctaSecondaryLabel && ctaSecondaryHref && (
123
+ <a
124
+ href={ctaSecondaryHref}
125
+ class="inline-flex items-center px-6 py-3 border border-border text-text font-medium rounded-lg hover:bg-hover-bg transition-colors"
126
+ >
127
+ {ctaSecondaryLabel}
128
+ </a>
129
+ )
130
+ }
131
+ </div>
132
+ </div>
133
+ </section>
134
+
135
+ <!-- Content area (renders MDX body) -->
136
+ <article class="sg-content max-w-(--width-content-max) mx-auto px-6 py-12">
137
+ <slot />
138
+ </article>
139
+ </main>
140
+
141
+ <Footer />
142
+ </body>
143
+ </html>
@@ -0,0 +1,13 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ /**
5
+ * Merge Tailwind CSS classes with clsx and tailwind-merge.
6
+ * This is the standard shadcn/ui utility function.
7
+ *
8
+ * Note: Scaffolding for future shadcn/ui component integration.
9
+ * Will be used by UI primitives in upcoming stories (e.g., search, dialogs).
10
+ */
11
+ export function cn(...inputs: ClassValue[]): string {
12
+ return twMerge(clsx(inputs));
13
+ }
@@ -0,0 +1,241 @@
1
+ @import "tailwindcss";
2
+ @plugin "tailwindcss-animate";
3
+
4
+ /*
5
+ * Specglass — Design Tokens (Tailwind v4 CSS-first configuration)
6
+ *
7
+ * These tokens define the visual identity of the default theme.
8
+ * Override via site-level CSS or tailwind @theme extensions.
9
+ */
10
+ @theme {
11
+ /* Primary brand colors */
12
+ --color-primary: oklch(0.546 0.245 262.881);
13
+ --color-primary-light: oklch(0.623 0.214 259.815);
14
+ --color-primary-dark: oklch(0.488 0.243 264.376);
15
+
16
+ /* Surface / background colors */
17
+ --color-surface: oklch(0.985 0.002 247.839);
18
+ --color-surface-dark: oklch(0.21 0.034 264.665);
19
+ --color-sidebar-bg: oklch(0.968 0.007 264.536);
20
+ --color-sidebar-bg-dark: oklch(0.179 0.04 264.376);
21
+
22
+ /* Text colors */
23
+ --color-text: oklch(0.21 0.034 264.665);
24
+ --color-text-dark: oklch(0.968 0.007 264.536);
25
+ --color-text-muted: oklch(0.553 0.013 264.364);
26
+ --color-text-muted-dark: oklch(0.708 0.014 264.364);
27
+
28
+ /* Border colors */
29
+ --color-border: oklch(0.872 0.01 258.338);
30
+ --color-border-dark: oklch(0.303 0.034 264.376);
31
+
32
+ /* Raised surfaces (e.g., kbd badges, chips) */
33
+ --color-surface-raised: oklch(0.937 0.006 264.364);
34
+ --color-surface-raised-dark: oklch(0.269 0.036 264.376);
35
+
36
+ /* Interactive states */
37
+ --color-hover-bg: oklch(0.951 0.004 264.364);
38
+ --color-hover-bg-dark: oklch(0.269 0.036 264.376);
39
+
40
+ /* Layout dimensions */
41
+ --width-sidebar: 16rem;
42
+ --width-toc: 14rem;
43
+ --width-content-max: 48rem;
44
+ --height-header: 3.5rem;
45
+
46
+ /* Spacing */
47
+ --spacing-page: 1.5rem;
48
+
49
+ /* Typography */
50
+ --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
51
+ --font-mono: "JetBrains Mono", ui-monospace, "Cascadia Code", monospace;
52
+
53
+ /* Transitions */
54
+ --transition-normal: 150ms;
55
+ }
56
+
57
+ /*
58
+ * Tailwind v4 dark mode — class-based strategy.
59
+ * Activates dark: variant when .dark class is on <html>.
60
+ */
61
+ @custom-variant dark (&:where(.dark, .dark *));
62
+
63
+ /*
64
+ * Dark mode token remapping.
65
+ * When .dark is present on <html>, base tokens remap to their dark variants.
66
+ * This means ALL existing Tailwind classes (bg-surface, text-text, etc.)
67
+ * automatically respond to dark mode — zero component template changes needed.
68
+ *
69
+ * Note: --color-primary is intentionally NOT remapped. The brand blue
70
+ * oklch(0.546 0.245 262.881) has sufficient contrast (~7:1) on dark surface
71
+ * oklch(0.21 0.034 264.665). For lighter accents, use dark:text-primary-light.
72
+ */
73
+ .dark {
74
+ --color-surface: var(--color-surface-dark);
75
+ --color-sidebar-bg: var(--color-sidebar-bg-dark);
76
+ --color-text: var(--color-text-dark);
77
+ --color-text-muted: var(--color-text-muted-dark);
78
+ --color-border: var(--color-border-dark);
79
+ --color-hover-bg: var(--color-hover-bg-dark);
80
+ --color-surface-raised: var(--color-surface-raised-dark);
81
+ }
82
+
83
+ /* ─── Base Styles ───────────────────────────────────────────── */
84
+
85
+ html {
86
+ scroll-behavior: smooth;
87
+ scroll-padding-top: calc(var(--height-header) + 1rem);
88
+ }
89
+
90
+ body {
91
+ font-family: var(--font-sans);
92
+ color: var(--color-text);
93
+ background-color: var(--color-surface);
94
+ line-height: 1.7;
95
+ -webkit-font-smoothing: antialiased;
96
+ -moz-osx-font-smoothing: grayscale;
97
+ transition: color var(--transition-normal) ease,
98
+ background-color var(--transition-normal) ease;
99
+ }
100
+
101
+ /* ─── Content Prose Styles ──────────────────────────────────── */
102
+
103
+ .sg-content h1 {
104
+ @apply text-3xl font-bold mt-0 mb-4;
105
+ }
106
+ .sg-content h2 {
107
+ @apply text-2xl font-semibold mt-10 mb-4 pb-2 border-b border-border;
108
+ }
109
+ .sg-content h3 {
110
+ @apply text-xl font-semibold mt-8 mb-3;
111
+ }
112
+ .sg-content h4 {
113
+ @apply text-lg font-semibold mt-6 mb-2;
114
+ }
115
+
116
+ .sg-content p {
117
+ @apply my-4 leading-7;
118
+ }
119
+ .sg-content ul,
120
+ .sg-content ol {
121
+ @apply my-4 pl-6;
122
+ }
123
+ .sg-content li {
124
+ @apply my-1;
125
+ }
126
+
127
+ .sg-content a {
128
+ @apply text-primary underline decoration-primary/30 underline-offset-2
129
+ hover:decoration-primary/80 transition-colors;
130
+ }
131
+
132
+ .sg-content code:not(pre code) {
133
+ @apply bg-gray-100 dark:bg-gray-800 text-sm px-1.5 py-0.5 rounded font-mono;
134
+ }
135
+
136
+ .sg-content pre {
137
+ @apply my-6 rounded-lg overflow-x-auto text-sm leading-6;
138
+ padding: 1rem 1.25rem;
139
+ }
140
+
141
+ /* ─── Shiki Dual-Theme CSS Variables ────────────────────────── */
142
+ /*
143
+ * With shikiConfig.defaultColor: false, Shiki emits CSS variables
144
+ * (--shiki-light, --shiki-dark) on each token instead of hardcoded colors.
145
+ * Map the right variable set based on the active color mode.
146
+ *
147
+ * Fallback colors ensure code is visible even if Shiki variables
148
+ * aren't present (e.g., inline <pre> without Shiki processing).
149
+ */
150
+ pre > code,
151
+ pre > code span {
152
+ color: var(--shiki-light, var(--color-text, #1f2937));
153
+ background-color: transparent;
154
+ }
155
+ pre {
156
+ background-color: var(--shiki-light-bg, #f6f8fa) !important;
157
+ }
158
+ .dark pre > code,
159
+ .dark pre > code span {
160
+ color: var(--shiki-dark, var(--color-text, #e5e7eb));
161
+ }
162
+ .dark pre {
163
+ background-color: var(--shiki-dark-bg, #161b22) !important;
164
+ }
165
+
166
+ /* Line highlighting — Astro/Shiki marks highlighted lines with .line.highlighted */
167
+ pre code .line.highlighted {
168
+ background-color: oklch(0.546 0.245 262.881 / 0.1);
169
+ display: inline-block;
170
+ width: 100%;
171
+ margin: 0 -1.25rem;
172
+ padding: 0 1.25rem;
173
+ }
174
+ .dark pre code .line.highlighted {
175
+ background-color: oklch(0.623 0.214 259.815 / 0.15);
176
+ }
177
+
178
+ /* ─── Code Block Copy Button (fenced code blocks) ──────────── */
179
+
180
+ .code-block--fenced .code-block-body {
181
+ position: relative;
182
+ }
183
+
184
+ .code-block--fenced .code-block-body pre {
185
+ margin: 0;
186
+ border-radius: 0;
187
+ padding: 1rem 1.25rem;
188
+ }
189
+
190
+ .sg-copy-btn-native {
191
+ position: absolute;
192
+ top: 0.5rem;
193
+ right: 0.5rem;
194
+ display: flex;
195
+ align-items: center;
196
+ justify-content: center;
197
+ width: 2rem;
198
+ height: 2rem;
199
+ border-radius: 0.375rem;
200
+ border: 1px solid var(--color-border, #e5e7eb);
201
+ background-color: var(--color-surface, #ffffff);
202
+ color: var(--color-text-muted, #6b7280);
203
+ cursor: pointer;
204
+ opacity: 0;
205
+ transition: opacity 150ms ease, background-color 150ms ease;
206
+ }
207
+
208
+ .code-block--fenced:hover .sg-copy-btn-native,
209
+ .sg-copy-btn-native:focus-visible {
210
+ opacity: 1;
211
+ }
212
+
213
+ .sg-copy-btn-native:focus-visible {
214
+ outline: 2px solid var(--color-primary, #3b82f6);
215
+ outline-offset: 2px;
216
+ }
217
+
218
+ .sg-content blockquote {
219
+ @apply border-l-4 border-primary/30 pl-4 my-4 italic text-text-muted;
220
+ }
221
+
222
+ .sg-content table {
223
+ @apply w-full my-6 text-sm;
224
+ }
225
+
226
+ .sg-content th {
227
+ @apply text-left font-semibold p-2 border-b-2 border-border;
228
+ }
229
+
230
+ .sg-content td {
231
+ @apply p-2 border-b border-border;
232
+ }
233
+
234
+ .sg-content hr {
235
+ @apply my-8 border-border;
236
+ }
237
+
238
+ .sg-content img {
239
+ @apply rounded-lg max-w-full my-6;
240
+ }
241
+
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseHighlightRange } from "./parse-highlight-range.js";
3
+
4
+ describe("parseHighlightRange", () => {
5
+ it("parses a single number", () => {
6
+ expect(parseHighlightRange("3")).toEqual([3]);
7
+ });
8
+
9
+ it("parses a range", () => {
10
+ expect(parseHighlightRange("3-5")).toEqual([3, 4, 5]);
11
+ });
12
+
13
+ it("parses comma-separated numbers", () => {
14
+ expect(parseHighlightRange("1,3,8")).toEqual([1, 3, 8]);
15
+ });
16
+
17
+ it("parses mixed numbers and ranges", () => {
18
+ expect(parseHighlightRange("1,3-5,8")).toEqual([1, 3, 4, 5, 8]);
19
+ });
20
+
21
+ it("returns empty array for empty string", () => {
22
+ expect(parseHighlightRange("")).toEqual([]);
23
+ });
24
+
25
+ it("returns empty array for whitespace-only string", () => {
26
+ expect(parseHighlightRange(" ")).toEqual([]);
27
+ });
28
+
29
+ it("handles whitespace around numbers", () => {
30
+ expect(parseHighlightRange(" 1 , 3 - 5 , 8 ")).toEqual([1, 3, 4, 5, 8]);
31
+ });
32
+
33
+ it("handles a single range at start of line", () => {
34
+ expect(parseHighlightRange("1-3")).toEqual([1, 2, 3]);
35
+ });
36
+
37
+ it("skips invalid segments gracefully", () => {
38
+ expect(parseHighlightRange("1,abc,3")).toEqual([1, 3]);
39
+ });
40
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Parses a highlight range string into an array of line numbers.
3
+ *
4
+ * Supports single numbers, ranges (e.g., "3-5"), and comma-separated
5
+ * combinations (e.g., "1,3-5,8").
6
+ *
7
+ * @example
8
+ * parseHighlightRange("1,3-5,8") // → [1, 3, 4, 5, 8]
9
+ * parseHighlightRange("") // → []
10
+ */
11
+ export function parseHighlightRange(range: string): number[] {
12
+ const trimmed = range.trim();
13
+ if (!trimmed) return [];
14
+
15
+ const result: number[] = [];
16
+ const parts = trimmed.split(",");
17
+
18
+ for (const part of parts) {
19
+ const segment = part.trim();
20
+ if (!segment) continue;
21
+
22
+ if (segment.includes("-")) {
23
+ const [startStr, endStr] = segment.split("-");
24
+ const start = parseInt(startStr.trim(), 10);
25
+ const end = parseInt(endStr.trim(), 10);
26
+
27
+ if (isNaN(start) || isNaN(end)) continue;
28
+
29
+ for (let i = start; i <= end; i++) {
30
+ result.push(i);
31
+ }
32
+ } else {
33
+ const num = parseInt(segment, 10);
34
+ if (!isNaN(num)) {
35
+ result.push(num);
36
+ }
37
+ }
38
+ }
39
+
40
+ return result;
41
+ }