@claude-code-mastery/starter-kit 1.0.0 → 1.1.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/README.npm.md ADDED
@@ -0,0 +1,190 @@
1
+ # @claude-code-mastery/starter-kit
2
+
3
+ A Claude Code toolkit that installs 27 commands, 10 skills, 10 hooks, and scaffolding templates into Claude Code globally - so every project you open already has them available.
4
+
5
+ **Requires:** Node.js 20+ and [Claude Code](https://claude.ai/code)
6
+
7
+ ---
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npx @claude-code-mastery/starter-kit init
13
+ ```
14
+
15
+ That's the only command you need to run once. Everything else happens inside Claude Code.
16
+
17
+ ---
18
+
19
+ ## What gets installed
20
+
21
+ After `init`, your `~/.claude/` directory looks like this:
22
+
23
+ ```
24
+ ~/.claude/
25
+ ├── starter-kit/ <- package files live here (single source of truth)
26
+ │ ├── commands/ <- 27 slash commands
27
+ │ ├── hooks/ <- 10 lifecycle hooks
28
+ │ ├── skills/ <- 10 skill files
29
+ │ └── .starter-kit/ <- scaffolding templates for /new-project
30
+ ├── commands/
31
+ │ ├── new-project.md -> (symlink to starter-kit/commands/new-project.md)
32
+ │ ├── review.md -> (symlink to starter-kit/commands/review.md)
33
+ │ └── ... -> one symlink per command
34
+ ├── skills/
35
+ │ ├── code-review/ -> (symlink to starter-kit/skills/code-review/)
36
+ │ └── ...
37
+ └── settings.json <- hooks registered with absolute paths
38
+ ```
39
+
40
+ Commands and skills are **symlinked**, not copied. When you update the package, the symlinks point to the new files immediately - no re-linking needed.
41
+
42
+ Hooks are registered by absolute path in `~/.claude/settings.json`, which is the same pattern Claude Code uses for its own global configuration.
43
+
44
+ ---
45
+
46
+ ## Commands
47
+
48
+ Once installed, these slash commands are available in every Claude Code session:
49
+
50
+ | Command | What it does |
51
+ |---|---|
52
+ | `/new-project <name> [profile]` | Scaffold a new project with full Claude Code tooling |
53
+ | `/convert-project-to-starter-kit` | Add kit infrastructure to an existing project (non-destructive) |
54
+ | `/add-feature <capability>` | Add MongoDB, Docker, testing, etc. to an existing project |
55
+ | `/review` | Review code for bugs, security issues, and best practices |
56
+ | `/commit` | Generate a conventional commit message from staged changes |
57
+ | `/create-api <resource>` | Scaffold a full API endpoint with route, handler, types, and tests |
58
+ | `/create-e2e <feature>` | Create a Playwright E2E test with explicit success criteria |
59
+ | `/refactor <file>` | Refactor a file following project best practices |
60
+ | `/security-check` | Scan for exposed secrets, missing .gitignore entries, unsafe patterns |
61
+ | `/optimize-docker` | Audit a Dockerfile against 12 best practices |
62
+ | `/diagram <type>` | Generate architecture, API, database, or infrastructure diagrams |
63
+ | `/worktree <name>` | Create an isolated git worktree and branch for a task |
64
+ | `/setup` | Configure .env, GitHub, Docker, analytics, and services interactively |
65
+ | `/test-plan <feature>` | Generate a structured test plan |
66
+ | `/progress` | Show what's done, pending, and next in the project |
67
+ | `/starter-kit update` | Update to the latest version from npm |
68
+ | `/starter-kit status` | Show installed version vs latest |
69
+ | `/starter-kit add` | Install the kit into the current project's .claude/ directory |
70
+
71
+ ---
72
+
73
+ ## Skills
74
+
75
+ Skills are expertise files Claude loads when relevant. These install automatically:
76
+
77
+ - `code-review` - security, performance, and correctness checks with severity rankings
78
+ - `test-writer` - tests with explicit assertions and realistic data
79
+ - `debugger` - root cause diagnosis for crashes, stack traces, and intermittent bugs
80
+ - `dependency-vetting` - supply-chain risk assessment before adding a package
81
+ - `design-review` - usability, accessibility, and visual feedback on UI
82
+ - `mongodb-rules` - native-driver and StrictDB rules for MongoDB data access
83
+ - `api-conventions` - routing and layering conventions for service code
84
+ - `create-service` - scaffolding patterns for new services
85
+ - `mcp-builder` - MCP server development patterns
86
+ - `terminal-tui` - Ink + React TUI patterns, resize handling
87
+
88
+ ---
89
+
90
+ ## Hooks
91
+
92
+ Hooks fire automatically during Claude Code sessions:
93
+
94
+ | Hook | When it fires | What it does |
95
+ |---|---|---|
96
+ | `check-branch.sh` | Before commit | Blocks commits directly to main |
97
+ | `check-rybbit.sh` | Before deploy commands | Ensures analytics are configured |
98
+ | `check-ports.sh` | Before starting servers | Checks for port conflicts |
99
+ | `check-e2e.sh` | Before E2E tests | Validates test configuration |
100
+ | `check-env-sync.sh` | After editing .env | Flags when .env.example is out of sync |
101
+ | `check-rulecatch.sh` | After commits | Runs RuleCatch violation checks |
102
+ | `block-dangerous-bash.py` | Before Bash tool | Blocks destructive shell commands |
103
+ | `check-file-length.py` | After Write/Edit | Warns when files exceed 300 lines |
104
+ | `lint-on-save.sh` | After Write/Edit | Runs linter on changed files |
105
+ | `verify-no-secrets.sh` | Before commit | Scans staged files for secrets |
106
+
107
+ ---
108
+
109
+ ## How Claude Code discovers the installed content
110
+
111
+ Claude Code does not scan `node_modules/`. Instead it looks in specific directories:
112
+
113
+ | Content type | Where Claude Code looks |
114
+ |---|---|
115
+ | Commands | `~/.claude/commands/` and `<project>/.claude/commands/` |
116
+ | Skills | `~/.claude/skills/` and `<project>/.claude/skills/` |
117
+ | Hooks | Absolute paths registered in `~/.claude/settings.json` |
118
+
119
+ After `init`, the package files live in `~/.claude/starter-kit/` and are exposed via symlinks and hook registrations in exactly the places Claude Code already checks. No configuration changes to Claude Code are needed.
120
+
121
+ ---
122
+
123
+ ## Updating
124
+
125
+ ```bash
126
+ npx @claude-code-mastery/starter-kit update
127
+ ```
128
+
129
+ Or from inside Claude Code after the first install:
130
+
131
+ ```
132
+ /starter-kit update
133
+ ```
134
+
135
+ This overwrites `~/.claude/starter-kit/` with the latest package content. Because commands and skills are symlinked, they reflect the update without any re-linking step.
136
+
137
+ ---
138
+
139
+ ## Adding to a specific project
140
+
141
+ To give a project its own local copy of the commands and hooks (useful for team repos):
142
+
143
+ ```
144
+ /starter-kit add
145
+ ```
146
+
147
+ This copies from `~/.claude/starter-kit/` into the current project's `.claude/` directory. Existing files are not overwritten.
148
+
149
+ ---
150
+
151
+ ## Checking what's installed
152
+
153
+ ```bash
154
+ npx @claude-code-mastery/starter-kit status
155
+ ```
156
+
157
+ Or from Claude Code:
158
+
159
+ ```
160
+ /starter-kit status
161
+ ```
162
+
163
+ ---
164
+
165
+ ## Profiles for /new-project
166
+
167
+ The `/new-project` command uses profiles to scaffold different project types:
168
+
169
+ | Profile | What it creates |
170
+ |---|---|
171
+ | `clean` | Minimal structure - just the Claude Code tooling, no framework |
172
+ | `node` (default) | Node.js + TypeScript, Express or similar, StrictDB |
173
+ | `go` | Go project with standard layout, golangci-lint, Makefile |
174
+ | `python` | Python + FastAPI or Django, pytest, ruff, Pydantic |
175
+
176
+ ```bash
177
+ # Examples inside Claude Code:
178
+ /new-project my-api node
179
+ /new-project my-service go
180
+ /new-project my-site clean
181
+ ```
182
+
183
+ ---
184
+
185
+ ## Source and contributing
186
+
187
+ The package content lives in the starter kit repo:
188
+ [github.com/TheDecipherist/claude-code-mastery-project-starter-kit](https://github.com/TheDecipherist/claude-code-mastery-project-starter-kit)
189
+
190
+ Clone the repo to customize commands, hooks, or skills for your own team, or fork it and publish under your own org.
package/bin/cli.js CHANGED
@@ -80,7 +80,29 @@ export function rewriteHookPath(command, starterKitDir) {
80
80
  export function copyPackageFiles(homeDir = os.homedir()) {
81
81
  const { starterKitDir } = paths(homeDir);
82
82
  ensureDir(starterKitDir);
83
+ // Copy .claude/ subdirectory contents (commands, hooks, skills, agents, .starter-kit, settings.json)
83
84
  fs.cpSync(path.join(PKG_ROOT, '.claude'), starterKitDir, { recursive: true, force: true });
85
+ // Copy root template files that command files reference via $SOURCE/<file>
86
+ const rootTemplates = [
87
+ 'CLAUDE.md',
88
+ '.env.example',
89
+ '.gitignore',
90
+ '.dockerignore',
91
+ 'vitest.config.ts',
92
+ 'playwright.config.ts',
93
+ 'tsconfig.json',
94
+ 'scripts',
95
+ ];
96
+ for (const name of rootTemplates) {
97
+ const src = path.join(PKG_ROOT, name);
98
+ if (!fs.existsSync(src)) continue;
99
+ const dest = path.join(starterKitDir, name);
100
+ if (fs.statSync(src).isDirectory()) {
101
+ fs.cpSync(src, dest, { recursive: true, force: true });
102
+ } else {
103
+ fs.copyFileSync(src, dest);
104
+ }
105
+ }
84
106
  const pkg = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8'));
85
107
  fs.writeFileSync(path.join(starterKitDir, 'version'), pkg.version, 'utf8');
86
108
  console.log(` Copied package files → ${starterKitDir}`);
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@claude-code-mastery/starter-kit",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Production-ready Claude Code starter kit — commands, hooks, skills, and agents for AI-assisted development",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
9
9
  "bin": {
10
- "@claude-code-mastery/starter-kit": "./bin/cli.js"
10
+ "starter-kit": "bin/cli.js"
11
11
  },
12
12
  "files": [
13
13
  ".claude/commands/",
@@ -18,7 +18,15 @@
18
18
  ".claude/settings.json",
19
19
  "bin/",
20
20
  "claude-mastery-project.conf",
21
- "global-claude-md/"
21
+ "global-claude-md/",
22
+ "CLAUDE.md",
23
+ ".env.example",
24
+ ".gitignore",
25
+ ".dockerignore",
26
+ "vitest.config.ts",
27
+ "playwright.config.ts",
28
+ "tsconfig.json",
29
+ "scripts/"
22
30
  ],
23
31
  "scripts": {
24
32
  "dev": "tsx watch src/index.ts",
@@ -53,7 +61,9 @@
53
61
  "ai:monitor": "npx @rulecatch/ai-pooler monitor --no-api-key",
54
62
  "docker:optimize": "echo 'Run /optimize-docker in Claude Code to audit your Dockerfile'",
55
63
  "clean": "rm -rf dist coverage test-results playwright-report",
56
- "precommit": "tsc --noEmit"
64
+ "precommit": "tsc --noEmit",
65
+ "prepack": "mv README.md _gh_readme.md && cp README.npm.md README.md",
66
+ "postpack": "mv _gh_readme.md README.md"
57
67
  },
58
68
  "engines": {
59
69
  "node": ">=20.0.0"
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Playwright E2E Test Configuration
3
+ *
4
+ * IMPORTANT: E2E tests run on TEST PORTS, not dev ports.
5
+ * This prevents conflicts with running dev servers.
6
+ *
7
+ * Test Ports (from CLAUDE.md — NEVER CHANGE):
8
+ * Website: 4000
9
+ * API: 4010
10
+ * Dashboard: 4020
11
+ *
12
+ * Dev Ports (for development — NOT used in tests):
13
+ * Website: 3000
14
+ * API: 3001
15
+ * Dashboard: 3002
16
+ */
17
+
18
+ import { defineConfig, devices } from '@playwright/test';
19
+
20
+ export default defineConfig({
21
+ testDir: './tests/e2e',
22
+ fullyParallel: true,
23
+ forbidOnly: !!process.env.CI,
24
+ retries: process.env.CI ? 2 : 0,
25
+ workers: process.env.CI ? 1 : undefined,
26
+ reporter: [
27
+ ['html', { outputFolder: 'playwright-report' }],
28
+ ['list'],
29
+ ],
30
+ outputDir: 'test-results',
31
+
32
+ use: {
33
+ /* Base URL for E2E tests — uses TEST port, not dev port */
34
+ baseURL: 'http://localhost:4000',
35
+ trace: 'on-first-retry',
36
+ screenshot: 'only-on-failure',
37
+ },
38
+
39
+ projects: [
40
+ {
41
+ name: 'chromium',
42
+ use: { ...devices['Desktop Chrome'] },
43
+ },
44
+ {
45
+ name: 'firefox',
46
+ use: { ...devices['Desktop Firefox'] },
47
+ },
48
+ {
49
+ name: 'webkit',
50
+ use: { ...devices['Desktop Safari'] },
51
+ },
52
+ {
53
+ name: 'mobile-chrome',
54
+ use: { ...devices['Pixel 5'] },
55
+ },
56
+ ],
57
+
58
+ /* Start services on TEST ports before running E2E tests */
59
+ webServer: [
60
+ {
61
+ command: 'pnpm dev:test:website',
62
+ port: 4000,
63
+ reuseExistingServer: !process.env.CI,
64
+ timeout: 30_000,
65
+ },
66
+ {
67
+ command: 'pnpm dev:test:api',
68
+ port: 4010,
69
+ reuseExistingServer: !process.env.CI,
70
+ timeout: 30_000,
71
+ },
72
+ {
73
+ command: 'pnpm dev:test:dashboard',
74
+ port: 4020,
75
+ reuseExistingServer: !process.env.CI,
76
+ timeout: 30_000,
77
+ },
78
+ ],
79
+ });
File without changes
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * build-content.ts — Markdown-to-HTML Article Builder (CLI)
4
+ *
5
+ * Converts markdown files to fully SEO-ready static HTML pages using a
6
+ * JSON config file as the single source of truth.
7
+ *
8
+ * Based on the production article builder from TheDecipherist.
9
+ * https://github.com/TheDecipherist/claude-code-mastery
10
+ *
11
+ * USAGE:
12
+ * npx tsx scripts/build-content.ts # Build all published
13
+ * npx tsx scripts/build-content.ts --id getting-started # Build one article
14
+ * npx tsx scripts/build-content.ts --list # List all articles
15
+ * npx tsx scripts/build-content.ts --dry-run # Show what would build
16
+ *
17
+ * CONFIG:
18
+ * Edit scripts/content.config.json to add/modify articles.
19
+ * Each article needs: id, mdSource, htmlOutput, title, description, url
20
+ *
21
+ * MODULES:
22
+ * scripts/content/types.ts — Types & Zod validation
23
+ * scripts/content/markdown-processor.ts — Markdown → HTML conversion
24
+ * scripts/content/seo-generator.ts — Schema.org JSON-LD
25
+ * scripts/content/sidebar-generator.ts — Sidebar TOC & navigation
26
+ * scripts/content/html-template.ts — Full HTML page assembly
27
+ */
28
+
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { ContentConfigSchema } from './content/types.js';
32
+ import { buildArticle } from './content/html-template.js';
33
+
34
+ function main(): void {
35
+ const args = process.argv.slice(2);
36
+ const configPath = path.resolve(import.meta.dirname ?? __dirname, 'content.config.json');
37
+
38
+ if (!fs.existsSync(configPath)) {
39
+ console.error(`Config not found: ${configPath}`);
40
+ console.error('Create scripts/content.config.json first.');
41
+ process.exit(1);
42
+ }
43
+
44
+ const raw: unknown = JSON.parse(fs.readFileSync(configPath, 'utf8'));
45
+ const parsed = ContentConfigSchema.safeParse(raw);
46
+ if (!parsed.success) {
47
+ console.error('\n Invalid content.config.json:\n');
48
+ for (const issue of parsed.error.issues) {
49
+ console.error(` - ${issue.path.join('.')}: ${issue.message}`);
50
+ }
51
+ console.error('');
52
+ process.exit(1);
53
+ }
54
+ const config = parsed.data;
55
+
56
+ const published = config.articles.filter((a) => a.published);
57
+
58
+ // --list
59
+ if (args.includes('--list')) {
60
+ console.log(`\n ${config.articles.length} articles (${published.length} published):\n`);
61
+ for (const a of config.articles) {
62
+ const status = a.published ? '\u2713' : '\u25CB';
63
+ console.log(` ${status} ${a.id.padEnd(35)} ${a.category ?? ''}`);
64
+ }
65
+ console.log('');
66
+ return;
67
+ }
68
+
69
+ // --dry-run
70
+ if (args.includes('--dry-run')) {
71
+ console.log(`\n Would build ${published.length} articles:\n`);
72
+ for (const a of published) {
73
+ console.log(` ${a.mdSource} \u2192 ${a.htmlOutput}`);
74
+ }
75
+ console.log('');
76
+ return;
77
+ }
78
+
79
+ // --id <article-id>
80
+ const idIdx = args.indexOf('--id');
81
+ if (idIdx !== -1) {
82
+ const targetId = args[idIdx + 1];
83
+ const article = config.articles.find((a) => a.id === targetId);
84
+ if (!article) {
85
+ console.error(`Article not found: ${targetId}`);
86
+ console.error('Run with --list to see available articles.');
87
+ process.exit(1);
88
+ }
89
+ console.log(`\n Building: ${article.id}\n`);
90
+ buildArticle(article, config);
91
+ console.log('\n Done.\n');
92
+ return;
93
+ }
94
+
95
+ // Default: build all published
96
+ if (published.length === 0) {
97
+ console.log('\n No published articles to build.\n');
98
+ return;
99
+ }
100
+
101
+ console.log(`\n Building ${published.length} published article(s)...\n`);
102
+ for (const article of published) {
103
+ buildArticle(article, config);
104
+ }
105
+ console.log(`\n Done. ${published.length} articles built.\n`);
106
+ }
107
+
108
+ main();
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Content Builder — HTML Page Template
3
+ *
4
+ * Assembles final HTML pages from processed content, SEO data, and sidebar.
5
+ */
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import type { ArticleConfig, ContentConfig, CollectedHeading } from './types.js';
10
+ import { convertMarkdownToHtml } from './markdown-processor.js';
11
+ import { generateSchemaJson } from './seo-generator.js';
12
+ import { generateSidebarHtml } from './sidebar-generator.js';
13
+
14
+ export function buildArticle(article: ArticleConfig, config: ContentConfig): void {
15
+ const markdown = fs.readFileSync(article.mdSource, 'utf8').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
16
+
17
+ const headings: CollectedHeading[] = [];
18
+
19
+ // Remove first H1 (shown in header)
20
+ const processedMd = markdown.replace(/^# .*\n+/, '');
21
+ const articleContent = convertMarkdownToHtml(processedMd.trim(), headings);
22
+
23
+ const sidebarHtml = generateSidebarHtml(article, headings);
24
+ const hasSidebar = article.sidebar === true;
25
+
26
+ const mainSection = hasSidebar
27
+ ? ` <div class="article-layout">
28
+ ${sidebarHtml}
29
+ <main>
30
+ <article class="article-content">
31
+ ${articleContent}
32
+ </article>
33
+ </main>
34
+ </div>
35
+ <div class="sidebar-overlay" id="sidebarOverlay"></div>
36
+ <button class="sidebar-toggle" id="sidebarToggle">Contents</button>`
37
+ : ` <main>
38
+ <article class="article-content">
39
+ ${articleContent}
40
+ </article>
41
+ </main>`;
42
+
43
+ const sidebarJs = hasSidebar ? generateSidebarJs() : '';
44
+
45
+ const fullHtml = `<!DOCTYPE html>
46
+ <html lang="en">
47
+ <head>
48
+ <meta charset="UTF-8">
49
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
50
+ <title>${article.title} — ${config.siteName}</title>
51
+ <meta name="description" content="${article.description}">
52
+ <meta name="author" content="${config.author}">
53
+ <meta name="robots" content="index, follow">
54
+ <link rel="canonical" href="${article.url}">
55
+
56
+ <!-- Open Graph / Facebook -->
57
+ <meta property="og:type" content="article">
58
+ <meta property="og:url" content="${article.url}">
59
+ <meta property="og:title" content="${article.title} — ${config.siteName}">
60
+ <meta property="og:description" content="${article.description}">
61
+ ${article.bannerImage ? `<meta property="og:image" content="${config.siteUrl}${article.bannerImage}">` : ''}
62
+ <meta property="og:site_name" content="${config.siteName}">
63
+
64
+ <!-- Twitter -->
65
+ <meta name="twitter:card" content="summary_large_image">
66
+ <meta name="twitter:title" content="${article.title} — ${config.siteName}">
67
+ <meta name="twitter:description" content="${article.description}">
68
+ ${article.bannerImage ? `<meta name="twitter:image" content="${config.siteUrl}${article.bannerImage}">` : ''}
69
+
70
+ <!-- Schema.org -->
71
+ ${generateSchemaJson(article, config)}
72
+
73
+ <!-- Syntax highlighting -->
74
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
75
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
76
+
77
+ <!-- Add your CSS here -->
78
+ <link rel="stylesheet" href="/css/global.css">
79
+ <link rel="stylesheet" href="/css/article.css">
80
+ </head>
81
+ <body>
82
+ <header>
83
+ <h1>${article.titleHtml ?? article.title}</h1>
84
+ ${article.subtitle ? `<p class="subtitle">${article.subtitle}</p>` : ''}
85
+ </header>
86
+
87
+ ${article.bannerImage ? `<div class="hero-banner" role="img" aria-label="${article.bannerAlt ?? article.title}" style="background-image: url('${article.bannerImage}')"></div>` : ''}
88
+
89
+ ${mainSection}
90
+
91
+ <script>
92
+ document.addEventListener('DOMContentLoaded', function() {
93
+ document.querySelectorAll('pre code').forEach(function(block) {
94
+ hljs.highlightElement(block);
95
+ });
96
+ });
97
+ ${sidebarJs}
98
+ </script>
99
+ </body>
100
+ </html>`;
101
+
102
+ const outputDir = path.dirname(article.htmlOutput);
103
+ if (!fs.existsSync(outputDir)) {
104
+ fs.mkdirSync(outputDir, { recursive: true });
105
+ }
106
+
107
+ fs.writeFileSync(article.htmlOutput, fullHtml);
108
+ const sizeKb = (fullHtml.length / 1024).toFixed(1);
109
+ console.log(` \u2713 ${article.id} \u2192 ${article.htmlOutput} (${sizeKb} KB, ${headings.length} headings)`);
110
+ }
111
+
112
+ function generateSidebarJs(): string {
113
+ return `
114
+ // Sidebar scroll spy
115
+ (function() {
116
+ var tocLinks = document.querySelectorAll('.sidebar-toc a');
117
+ var headings = [];
118
+ tocLinks.forEach(function(link) {
119
+ var id = link.getAttribute('href').slice(1);
120
+ var heading = document.getElementById(id);
121
+ if (heading) headings.push({ el: heading, link: link });
122
+ });
123
+ if (headings.length > 0) {
124
+ var observer = new IntersectionObserver(function(entries) {
125
+ entries.forEach(function(entry) {
126
+ if (entry.isIntersecting) {
127
+ tocLinks.forEach(function(l) { l.classList.remove('active'); });
128
+ var match = headings.find(function(h) { return h.el === entry.target; });
129
+ if (match) match.link.classList.add('active');
130
+ }
131
+ });
132
+ }, { rootMargin: '-80px 0px -70% 0px' });
133
+ headings.forEach(function(h) { observer.observe(h.el); });
134
+ }
135
+ var sidebar = document.getElementById('articleSidebar');
136
+ var overlay = document.getElementById('sidebarOverlay');
137
+ var toggle = document.getElementById('sidebarToggle');
138
+ function openSidebar() { if (sidebar) sidebar.classList.add('open'); if (overlay) overlay.classList.add('open'); }
139
+ function closeSidebar() { if (sidebar) sidebar.classList.remove('open'); if (overlay) overlay.classList.remove('open'); }
140
+ if (toggle) toggle.addEventListener('click', openSidebar);
141
+ if (overlay) overlay.addEventListener('click', closeSidebar);
142
+ tocLinks.forEach(function(link) { link.addEventListener('click', closeSidebar); });
143
+ })();`;
144
+ }