mkdnsite 1.0.1 → 1.1.1

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": "mkdnsite",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "Markdown for the web. HTML for humans, Markdown for agents.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -48,9 +48,11 @@
48
48
  ],
49
49
  "dependencies": {
50
50
  "@modelcontextprotocol/sdk": "^1.27.1",
51
+ "@types/picomatch": "^4.0.2",
51
52
  "gray-matter": "^4.0.3",
52
53
  "katex": "^0.16.38",
53
54
  "lucide-react": "^0.577.0",
55
+ "picomatch": "^4.0.3",
54
56
  "react": "^19.0.0",
55
57
  "react-dom": "^19.0.0",
56
58
  "react-markdown": "^9.0.0",
@@ -65,7 +65,11 @@ export class CloudflareAdapter implements DeploymentAdapter {
65
65
  ref: this.env.GITHUB_REF,
66
66
  token: this.env.GITHUB_TOKEN
67
67
  }
68
- return new GitHubSource(ghConfig)
68
+ return new GitHubSource({
69
+ ...ghConfig,
70
+ include: config.include,
71
+ exclude: config.exclude
72
+ })
69
73
  }
70
74
 
71
75
  // R2 source: explicit CONTENT_SOURCE=r2 or CONTENT_BUCKET binding present
@@ -139,6 +143,54 @@ export class CloudflareAdapter implements DeploymentAdapter {
139
143
  if (this.env.ANALYTICS == null) return undefined
140
144
  return new WorkersAnalyticsEngineAnalytics(this.env.ANALYTICS)
141
145
  }
146
+
147
+ /**
148
+ * Create a static file handler backed by an R2 bucket.
149
+ *
150
+ * When a `STATIC_BUCKET` binding is present in the Worker environment, this
151
+ * method returns a `staticHandler` function that reads static assets (images,
152
+ * fonts, CSS, etc.) from R2. Pass the result to `createHandler` via the
153
+ * `staticHandler` option:
154
+ *
155
+ * ```ts
156
+ * const handler = createHandler({
157
+ * source: adapter.createContentSource(config),
158
+ * renderer: await adapter.createRenderer(config),
159
+ * config,
160
+ * staticHandler: adapter.createStaticHandler()
161
+ * })
162
+ * ```
163
+ *
164
+ * Returns `undefined` when no `STATIC_BUCKET` binding is configured, so
165
+ * callers can pass the result unconditionally — `createHandler` ignores
166
+ * `undefined` staticHandler values.
167
+ *
168
+ * The handler strips the leading `/` from the pathname before fetching from
169
+ * R2 (e.g. `/logo.png` → key `logo.png`). Returns `null` for missing objects
170
+ * so the request falls through to the built-in `serveStatic` or a 404.
171
+ */
172
+ createStaticHandler (): ((pathname: string) => Promise<Response | null>) | undefined {
173
+ const bucket = this.env.STATIC_BUCKET
174
+ if (bucket == null) return undefined
175
+
176
+ return async (pathname: string): Promise<Response | null> => {
177
+ const key = pathname.replace(/^\//, '')
178
+ const obj = await bucket.get(key)
179
+ if (obj == null) return null
180
+
181
+ const ext = key.split('.').pop() ?? ''
182
+ const contentType = MIME_TYPES[ext] ?? 'application/octet-stream'
183
+ const body = await obj.text()
184
+
185
+ return new Response(body, {
186
+ status: 200,
187
+ headers: {
188
+ 'Content-Type': contentType,
189
+ 'Cache-Control': 'public, max-age=31536000, immutable'
190
+ }
191
+ })
192
+ }
193
+ }
142
194
  }
143
195
 
144
196
  /**
@@ -203,6 +255,9 @@ export interface CloudflareEnv {
203
255
  /** KV namespace for caching (future use) */
204
256
  CACHE_KV?: KVNamespace
205
257
 
258
+ /** R2 bucket for serving static assets (images, fonts, CSS, etc.) */
259
+ STATIC_BUCKET?: R2Bucket
260
+
206
261
  /** GitHub owner (used if config.github not set) */
207
262
  GITHUB_OWNER?: string
208
263
  /** GitHub repo (used if config.github not set) */
@@ -224,6 +279,25 @@ export interface CloudflareEnv {
224
279
  ANALYTICS?: AnalyticsEngineDataset
225
280
  }
226
281
 
282
+ // ─── Static asset MIME type map ──────────────────────────────────────────────
283
+ const MIME_TYPES: Record<string, string> = {
284
+ png: 'image/png',
285
+ jpg: 'image/jpeg',
286
+ jpeg: 'image/jpeg',
287
+ gif: 'image/gif',
288
+ webp: 'image/webp',
289
+ svg: 'image/svg+xml',
290
+ ico: 'image/x-icon',
291
+ css: 'text/css; charset=utf-8',
292
+ js: 'text/javascript; charset=utf-8',
293
+ woff: 'font/woff',
294
+ woff2: 'font/woff2',
295
+ ttf: 'font/ttf',
296
+ otf: 'font/otf',
297
+ pdf: 'application/pdf',
298
+ json: 'application/json'
299
+ }
300
+
227
301
  // ─── Cloudflare R2 type stubs ─────────────────────────────────────────────────
228
302
  // These types are provided by the CF Workers runtime; stubs here for type-checking
229
303
  // in non-CF environments.
@@ -18,9 +18,16 @@ export class LocalAdapter implements DeploymentAdapter {
18
18
 
19
19
  createContentSource (config: MkdnSiteConfig): ContentSource {
20
20
  if (config.github != null) {
21
- return new GitHubSource(config.github)
21
+ return new GitHubSource({
22
+ ...config.github,
23
+ include: config.include,
24
+ exclude: config.exclude
25
+ })
22
26
  }
23
- return new FilesystemSource(config.contentDir)
27
+ return new FilesystemSource(config.contentDir, {
28
+ include: config.include,
29
+ exclude: config.exclude
30
+ })
24
31
  }
25
32
 
26
33
  async createRenderer (config: MkdnSiteConfig): Promise<MarkdownRenderer> {
@@ -154,8 +161,10 @@ export class LocalAdapter implements DeploymentAdapter {
154
161
 
155
162
  row('Runtime', `local (${this.name})`)
156
163
  if (config.github != null) {
157
- const ref = config.github.ref ?? 'main'
158
- row('GitHub', `${config.github.owner}/${config.github.repo}@${ref}`)
164
+ const owner: string = config.github.owner
165
+ const repo: string = config.github.repo
166
+ const ref: string = config.github.ref ?? 'main'
167
+ row('GitHub', `${owner}/${repo}@${ref}`)
159
168
  } else {
160
169
  row('Content', config.contentDir)
161
170
  }
package/src/cli.ts CHANGED
@@ -147,6 +147,14 @@ export function parseArgs (args: string[]): ParsedArgs {
147
147
  result.renderer = args[++i]
148
148
  } else if (arg === '--static') {
149
149
  result.staticDir = resolve(args[++i])
150
+ } else if (arg === '--include') {
151
+ const patterns = (result.include as string[] | undefined) ?? []
152
+ patterns.push(args[++i])
153
+ result.include = patterns
154
+ } else if (arg === '--exclude') {
155
+ const patterns = (result.exclude as string[] | undefined) ?? []
156
+ patterns.push(args[++i])
157
+ result.exclude = patterns
150
158
  } else if (arg === '--help' || arg === '-h') {
151
159
  printHelp()
152
160
  process.exit(0)
@@ -179,6 +187,8 @@ ${section('Server:')}
179
187
  ${flag('-p, --port <n>', 'Port to listen on (default: 3000)')}
180
188
  ${flag('--config <path>', 'Path to config file (default: mkdnsite.config.ts)')}
181
189
  ${flag('--static <dir>', 'Directory for static assets')}
190
+ ${flag('--include <pattern>', 'Glob pattern to include (repeatable, e.g. docs/**)')}
191
+ ${flag('--exclude <pattern>', 'Glob pattern to exclude (repeatable, e.g. **/*.draft.md)')}
182
192
  ${section('Site:')}
183
193
  ${flag('--title <text>', 'Site title')}
184
194
  ${flag('--url <url>', 'Base URL for absolute links')}
@@ -9,6 +9,7 @@ export function CLIENT_SCRIPTS (client: ClientConfig): string {
9
9
 
10
10
  const scripts: string[] = []
11
11
 
12
+ scripts.push(NAV_TOGGLE_SCRIPT)
12
13
  scripts.push(STICKY_TABLE_SCRIPT)
13
14
 
14
15
  if (client.themeToggle) {
@@ -37,6 +38,31 @@ export function CLIENT_SCRIPTS (client: ClientConfig): string {
37
38
  return `<script>${scripts.join('\n')}</script>`
38
39
  }
39
40
 
41
+ const NAV_TOGGLE_SCRIPT = `
42
+ (function(){
43
+ var toggle = document.querySelector('.mkdn-nav-toggle');
44
+ var nav = document.querySelector('.mkdn-nav');
45
+ var backdrop = document.querySelector('.mkdn-nav-backdrop');
46
+ if (!toggle || !nav) return;
47
+ function open() {
48
+ nav.classList.add('mkdn-nav--open');
49
+ if (backdrop) backdrop.classList.add('mkdn-nav-backdrop--visible');
50
+ toggle.setAttribute('aria-expanded', 'true');
51
+ }
52
+ function close() {
53
+ nav.classList.remove('mkdn-nav--open');
54
+ if (backdrop) backdrop.classList.remove('mkdn-nav-backdrop--visible');
55
+ toggle.setAttribute('aria-expanded', 'false');
56
+ }
57
+ toggle.addEventListener('click', function() {
58
+ nav.classList.contains('mkdn-nav--open') ? close() : open();
59
+ });
60
+ if (backdrop) backdrop.addEventListener('click', close);
61
+ nav.addEventListener('click', function(e) {
62
+ if (e.target && e.target.closest && e.target.closest('a')) close();
63
+ });
64
+ })();`
65
+
40
66
  const THEME_TOGGLE_SCRIPT = `
41
67
  (function(){
42
68
  var btn = document.querySelector('.mkdn-theme-toggle');
@@ -106,6 +106,8 @@ export function resolveConfig (
106
106
  llmsTxt: { ...DEFAULT_CONFIG.llmsTxt, ...userConfig.llmsTxt },
107
107
  client: { ...DEFAULT_CONFIG.client, ...userConfig.client },
108
108
  github: userConfig.github,
109
+ include: userConfig.include,
110
+ exclude: userConfig.exclude,
109
111
  mcp: { ...DEFAULT_CONFIG.mcp, ...userConfig.mcp },
110
112
  analytics: userConfig.analytics != null
111
113
  ? {
@@ -42,6 +42,20 @@ export interface MkdnSiteConfig {
42
42
  /** Static files directory for images, videos, etc. */
43
43
  staticDir?: string
44
44
 
45
+ /**
46
+ * Glob patterns to include. Only matching files will be served.
47
+ * Mutually exclusive with `exclude` — define one or the other, not both.
48
+ * e.g. ['docs', 'guides/*.md']
49
+ */
50
+ include?: string[]
51
+
52
+ /**
53
+ * Glob patterns to exclude. Matching files will not be served.
54
+ * Mutually exclusive with `include` — define one or the other, not both.
55
+ * e.g. ['private', '*.draft.md']
56
+ */
57
+ exclude?: string[]
58
+
45
59
  /** GitHub repository source (alternative to local contentDir) */
46
60
  github?: import('../content/types.ts').GitHubSourceConfig
47
61
 
@@ -1,16 +1,41 @@
1
1
  import { readdir, readFile, stat } from 'node:fs/promises'
2
2
  import { join, relative, extname, basename, dirname } from 'node:path'
3
+ import picomatch from 'picomatch'
3
4
  import { parseFrontmatter } from './frontmatter.ts'
4
5
  import type { ContentSource, ContentPage, NavNode } from './types.ts'
5
6
 
7
+ export interface FilesystemSourceOptions {
8
+ /** Glob patterns to include. Only matching files will be served. Mutually exclusive with exclude. */
9
+ include?: string[]
10
+ /** Glob patterns to exclude. Matching files will not be served. Mutually exclusive with include. */
11
+ exclude?: string[]
12
+ }
13
+
6
14
  export class FilesystemSource implements ContentSource {
7
15
  private readonly rootDir: string
8
16
  private readonly cache = new Map<string, ContentPage>()
9
17
  private navCache: NavNode | null = null
10
18
  private allPagesCache: ContentPage[] | null = null
19
+ private readonly includeMatcher: picomatch.Matcher | null
20
+ private readonly excludeMatcher: picomatch.Matcher | null
11
21
 
12
- constructor (rootDir: string) {
22
+ constructor (rootDir: string, opts: FilesystemSourceOptions = {}) {
13
23
  this.rootDir = rootDir
24
+ this.includeMatcher = opts.include != null && opts.include.length > 0
25
+ ? picomatch(opts.include)
26
+ : null
27
+ this.excludeMatcher = opts.exclude != null && opts.exclude.length > 0
28
+ ? picomatch(opts.exclude)
29
+ : null
30
+ }
31
+
32
+ /** Returns true if a relative path should be served, based on include/exclude patterns. */
33
+ private shouldInclude (relPath: string): boolean {
34
+ // Normalise to forward slashes for consistent glob matching
35
+ const p = relPath.replace(/\\/g, '/')
36
+ if (this.includeMatcher != null) return this.includeMatcher(p)
37
+ if (this.excludeMatcher != null) return !this.excludeMatcher(p)
38
+ return true
14
39
  }
15
40
 
16
41
  async getPage (slug: string): Promise<ContentPage | null> {
@@ -42,6 +67,10 @@ export class FilesystemSource implements ContentSource {
42
67
  const parsed = parseFrontmatter(raw)
43
68
  const fileStat = await stat(filePath)
44
69
 
70
+ // Check include/exclude patterns against relative path
71
+ const relPath = relative(this.rootDir, filePath)
72
+ if (!this.shouldInclude(relPath)) return null
73
+
45
74
  const page: ContentPage = {
46
75
  slug: `/${normalized === 'index' ? '' : normalized}`,
47
76
  sourcePath: filePath,
@@ -101,6 +130,9 @@ export class FilesystemSource implements ContentSource {
101
130
  await this.walkDir(fullPath, pages)
102
131
  } else if (entry.isFile() && extname(entry.name) === '.md') {
103
132
  const relPath = relative(this.rootDir, fullPath)
133
+
134
+ if (!this.shouldInclude(relPath)) continue
135
+
104
136
  const slug = this.filePathToSlug(relPath)
105
137
 
106
138
  try {
@@ -189,6 +221,9 @@ export class FilesystemSource implements ContentSource {
189
221
  entry.name !== 'README.md' &&
190
222
  entry.name !== 'readme.md'
191
223
  ) {
224
+ const relPath = relative(this.rootDir, fullPath)
225
+ if (!this.shouldInclude(relPath)) continue
226
+
192
227
  const raw = await readFile(fullPath, 'utf-8')
193
228
  const parsed = parseFrontmatter(raw)
194
229
 
@@ -1,3 +1,4 @@
1
+ import picomatch from 'picomatch'
1
2
  import { parseFrontmatter } from './frontmatter.ts'
2
3
  import type {
3
4
  ContentSource,
@@ -37,11 +38,13 @@ interface ParsedFile {
37
38
  * - raw.githubusercontent.com: no documented rate limit (generous)
38
39
  */
39
40
  export class GitHubSource implements ContentSource {
40
- private readonly config: Required<GitHubSourceConfig>
41
+ private readonly config: Required<Omit<GitHubSourceConfig, 'include' | 'exclude'>>
41
42
  private readonly pageCache = new Map<string, CacheEntry<ContentPage | null>>()
42
43
  private navCache: CacheEntry<NavNode> | null = null
43
44
  private treeCache: CacheEntry<GitHubTreeEntry[]> | null = null
44
45
  private prefetchPromise: Promise<void> | null = null
46
+ private readonly includeMatcher: picomatch.Matcher | null
47
+ private readonly excludeMatcher: picomatch.Matcher | null
45
48
 
46
49
  constructor (config: GitHubSourceConfig) {
47
50
  this.config = {
@@ -51,6 +54,20 @@ export class GitHubSource implements ContentSource {
51
54
  path: config.path ?? '',
52
55
  token: config.token ?? ''
53
56
  }
57
+ this.includeMatcher = config.include != null && config.include.length > 0
58
+ ? picomatch(config.include)
59
+ : null
60
+ this.excludeMatcher = config.exclude != null && config.exclude.length > 0
61
+ ? picomatch(config.exclude)
62
+ : null
63
+ }
64
+
65
+ /** Returns true if a relative file path should be served, based on include/exclude patterns. */
66
+ private shouldInclude (relPath: string): boolean {
67
+ const p = relPath.replace(/\\/g, '/')
68
+ if (this.includeMatcher != null) return this.includeMatcher(p)
69
+ if (this.excludeMatcher != null) return !this.excludeMatcher(p)
70
+ return true
54
71
  }
55
72
 
56
73
  async getPage (slug: string): Promise<ContentPage | null> {
@@ -119,7 +136,11 @@ export class GitHubSource implements ContentSource {
119
136
 
120
137
  private async prefetchAll (): Promise<void> {
121
138
  const tree = await this.fetchRepoTree()
122
- const mdFiles = tree.filter(e => e.type === 'blob' && e.path.endsWith('.md'))
139
+ const mdFiles = tree.filter(e =>
140
+ e.type === 'blob' &&
141
+ e.path.endsWith('.md') &&
142
+ this.shouldInclude(e.path)
143
+ )
123
144
 
124
145
  // Fetch all file contents in parallel
125
146
  const fetched = await Promise.all(
@@ -167,6 +188,9 @@ export class GitHubSource implements ContentSource {
167
188
  ]
168
189
 
169
190
  for (const filePath of candidates) {
191
+ // Respect include/exclude patterns before fetching
192
+ if (!this.shouldInclude(filePath)) return null
193
+
170
194
  const raw = await this.fetchFile(filePath)
171
195
  if (raw != null) {
172
196
  const { meta, body } = parseFrontmatter(raw)
@@ -83,4 +83,14 @@ export interface GitHubSourceConfig {
83
83
  path?: string
84
84
  /** GitHub personal access token (for private repos or higher rate limits) */
85
85
  token?: string
86
+ /**
87
+ * Glob patterns to include. Only matching files will be served.
88
+ * Mutually exclusive with `exclude`.
89
+ */
90
+ include?: string[]
91
+ /**
92
+ * Glob patterns to exclude. Matching files will not be served.
93
+ * Mutually exclusive with `include`.
94
+ */
95
+ exclude?: string[]
86
96
  }
package/src/handler.ts CHANGED
@@ -43,6 +43,15 @@ export interface HandlerOptions {
43
43
  * When set, requests without `Authorization: Bearer <refreshToken>` are rejected with 401.
44
44
  */
45
45
  refreshToken?: string
46
+ /**
47
+ * Optional custom static file handler.
48
+ * When provided, this function is called instead of the built-in filesystem-based
49
+ * serveStatic for requests matching static file extensions.
50
+ * Return a Response to serve it, or null to fall through to the built-in serveStatic
51
+ * (if config.staticDir is set) or ultimately a 404.
52
+ * Use this for non-filesystem deployments (R2, S3, KV, GitHub API, etc.).
53
+ */
54
+ staticHandler?: (pathname: string) => Promise<Response | null>
46
55
  }
47
56
 
48
57
  /**
@@ -57,7 +66,7 @@ export interface HandlerOptions {
57
66
  * - Deno.serve()
58
67
  */
59
68
  export function createHandler (opts: HandlerOptions): (request: Request) => Promise<Response> {
60
- const { source, renderer, config, analytics, siteId, contentCache, responseCache, refreshToken } = opts
69
+ const { source, renderer, config, analytics, siteId, contentCache, responseCache, refreshToken, staticHandler } = opts
61
70
 
62
71
  let llmsTxtCache: string | null = null
63
72
  let mcpHandlerFn: ((req: Request) => Promise<Response>) | null = null
@@ -150,7 +159,7 @@ export function createHandler (opts: HandlerOptions): (request: Request) => Prom
150
159
  response: Response
151
160
  ): AnalyticsResponseFormat {
152
161
  // MCP: check endpoint first (MCP responses may have various Content-Types)
153
- const mcpEndpoint = config.mcp.endpoint ?? '/mcp'
162
+ const mcpEndpoint: string = config.mcp.endpoint ?? '/mcp'
154
163
  if (
155
164
  config.mcp.enabled &&
156
165
  (pathname === mcpEndpoint || pathname.startsWith(mcpEndpoint + '/'))
@@ -260,18 +269,24 @@ export function createHandler (opts: HandlerOptions): (request: Request) => Prom
260
269
  }
261
270
 
262
271
  // ---- MCP endpoint ----
272
+ const mcpPath: string = config.mcp.endpoint ?? '/mcp'
263
273
  if (
264
274
  config.mcp.enabled &&
265
- (pathname === (config.mcp.endpoint ?? '/mcp') ||
266
- pathname.startsWith((config.mcp.endpoint ?? '/mcp') + '/'))
275
+ (pathname === mcpPath || pathname.startsWith(mcpPath + '/'))
267
276
  ) {
268
277
  const mcp = await ensureMcpHandler()
269
278
  return ok(await mcp(request))
270
279
  }
271
280
 
272
281
  // ---- Static files passthrough ----
273
- if (config.staticDir != null && hasStaticExtension(pathname)) {
274
- return ok(await serveStatic(pathname, config.staticDir))
282
+ if (hasStaticExtension(pathname)) {
283
+ if (staticHandler != null) {
284
+ const staticResponse = await staticHandler(pathname)
285
+ if (staticResponse != null) return ok(staticResponse)
286
+ }
287
+ if (config.staticDir != null) {
288
+ return ok(await serveStatic(pathname, config.staticDir))
289
+ }
275
290
  }
276
291
 
277
292
  // ---- Content negotiation + page serving ----
@@ -95,6 +95,7 @@ export function renderPage (props: PageShellProps): string {
95
95
  <body>
96
96
  ${searchTriggerHtml}
97
97
  ${themeToggleHtml}
98
+ ${navHtml !== '' ? '<button class=\'mkdn-nav-toggle\' type=\'button\' aria-label=\'Toggle navigation\' aria-expanded=\'false\'><svg xmlns=\'http://www.w3.org/2000/svg\' width=\'18\' height=\'18\' viewBox=\'0 0 24 24\' fill=\'none\' stroke=\'currentColor\' stroke-width=\'2\' stroke-linecap=\'round\' stroke-linejoin=\'round\'><path d=\'M4 12h16\'/><path d=\'M4 6h16\'/><path d=\'M4 18h16\'/></svg></button><div class=\'mkdn-nav-backdrop\'></div>' : ''}
98
99
  <div class="mkdn-layout">
99
100
  ${navHtml !== '' ? `<nav class="mkdn-nav" aria-label="Site navigation">${navHtml}</nav>` : ''}
100
101
  <main class="mkdn-main">
@@ -460,14 +460,83 @@ body {
460
460
  .mkdn-mermaid { margin: 1em 0; text-align: center; }
461
461
  .mkdn-mermaid svg { max-width: 100%; }
462
462
 
463
+ .mkdn-nav-toggle { display: none; }
464
+ .mkdn-nav-backdrop { display: none; }
465
+
463
466
  @media (max-width: 768px) {
464
467
  .mkdn-layout { flex-direction: column; }
465
468
  .mkdn-nav {
466
- position: static; width: 100%; height: auto;
467
- border-right: none; border-bottom: 1px solid var(--mkdn-border);
468
- padding: 1rem 0;
469
+ position: fixed; top: 0; left: 0;
470
+ width: 280px; height: 100vh;
471
+ transform: translateX(-100%);
472
+ transition: transform 0.3s ease;
473
+ z-index: 900;
474
+ border-right: 1px solid var(--mkdn-border);
475
+ border-bottom: none;
476
+ }
477
+ .mkdn-nav.mkdn-nav--open { transform: translateX(0); }
478
+ .mkdn-nav-backdrop {
479
+ position: fixed; inset: 0; background: rgba(0,0,0,0.4);
480
+ z-index: 899; display: none;
481
+ }
482
+ .mkdn-nav-backdrop--visible { display: block; }
483
+ .mkdn-nav-toggle {
484
+ display: flex; position: fixed; top: 0.75rem; left: 0.75rem;
485
+ z-index: 200; width: 36px; height: 36px;
486
+ align-items: center; justify-content: center;
487
+ background: var(--mkdn-bg-alt); border: 1px solid var(--mkdn-border);
488
+ border-radius: 8px; cursor: pointer; color: var(--mkdn-text-muted);
489
+ transition: color 0.15s, background 0.15s;
490
+ }
491
+ .mkdn-nav-toggle:hover { color: var(--mkdn-text); background: var(--mkdn-code-bg); }
492
+ .mkdn-main { padding: 1.5rem 1rem; padding-top: 3.5rem; }
493
+ .mkdn-theme-toggle { top: 0.75rem; right: 0.75rem; }
494
+ .mkdn-search-trigger { right: 3rem; }
495
+ .mkdn-prev-next { flex-direction: column; }
496
+ .mkdn-prev-next a { max-width: 100%; font-size: 0.8rem; }
497
+
498
+ /* Scale down base font — everything using rem shrinks proportionally */
499
+ html { font-size: 14px; }
500
+
501
+ /* Tighter prose headings on mobile */
502
+ .mkdn-prose h1 { font-size: 1.75rem; }
503
+ .mkdn-prose h2 { font-size: 1.5rem; margin-top: 1.75rem; }
504
+ .mkdn-prose h3 { font-size: 1.25rem; margin-top: 1.5rem; }
505
+ .mkdn-prose h4 { font-size: 1.1rem; }
506
+
507
+ /* Constrain wide elements to viewport width */
508
+ .mkdn-prose pre,
509
+ .mkdn-code-block,
510
+ .mkdn-chart { max-width: calc(100vw - 2rem); }
511
+
512
+ /* Tighter code block padding on mobile */
513
+ .mkdn-prose pre { padding: 0.75rem; font-size: 0.8rem; }
514
+
515
+ /* Ensure images don't overflow */
516
+ .mkdn-prose img { max-width: 100%; height: auto; }
517
+
518
+ /* Smaller page title on mobile */
519
+ .mkdn-page-title { font-size: 1.75rem; }
520
+
521
+ /* Move heading anchors to the right on mobile — avoids left-side overflow */
522
+ .mkdn-prose a.mkdn-heading-anchor {
523
+ margin-left: 0;
524
+ margin-right: 0;
525
+ float: right;
526
+ padding-left: 0.3em;
527
+ padding-right: 0;
528
+ opacity: 0.5;
529
+ }
530
+
531
+ /* Top padding so first article content isn't hidden behind fixed hamburger */
532
+ .mkdn-article { padding-top: 0.5rem; }
533
+
534
+ /* Heading anchor scroll offset accounts for fixed hamburger button height */
535
+ .mkdn-prose h1 { scroll-margin-top: 4.5rem; }
536
+ .mkdn-prose h2, .mkdn-prose h3,
537
+ .mkdn-prose h4, .mkdn-prose h5, .mkdn-prose h6 {
538
+ scroll-margin-top: 3.5rem;
469
539
  }
470
- .mkdn-main { padding: 1.5rem 1rem; }
471
540
  }
472
541
 
473
542
  /* ---- Search trigger button ---- */
@@ -560,10 +629,11 @@ body {
560
629
  }
561
630
 
562
631
  @media (max-width: 640px) {
563
- .mkdn-search-overlay { padding-top: 0; align-items: flex-end; }
632
+ .mkdn-search-overlay { padding-top: 4vh; align-items: flex-start; }
564
633
  .mkdn-search-modal {
565
- max-width: 100%; border-radius: 12px 12px 0 0;
566
- max-height: 70vh;
634
+ max-width: 100%; border-radius: 12px;
635
+ max-height: 80vh;
636
+ margin: 0 0.5rem;
567
637
  }
568
638
  .mkdn-search-trigger { right: 3rem; }
569
639
  }