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 +3 -1
- package/src/adapters/cloudflare.ts +75 -1
- package/src/adapters/local.ts +13 -4
- package/src/cli.ts +10 -0
- package/src/client/scripts.ts +26 -0
- package/src/config/defaults.ts +2 -0
- package/src/config/schema.ts +14 -0
- package/src/content/filesystem.ts +36 -1
- package/src/content/github.ts +26 -2
- package/src/content/types.ts +10 -0
- package/src/handler.ts +21 -6
- package/src/render/page-shell.ts +1 -0
- package/src/theme/base-css.ts +77 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mkdnsite",
|
|
3
|
-
"version": "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(
|
|
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.
|
package/src/adapters/local.ts
CHANGED
|
@@ -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(
|
|
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
|
|
158
|
-
|
|
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')}
|
package/src/client/scripts.ts
CHANGED
|
@@ -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');
|
package/src/config/defaults.ts
CHANGED
|
@@ -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
|
? {
|
package/src/config/schema.ts
CHANGED
|
@@ -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
|
|
package/src/content/github.ts
CHANGED
|
@@ -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 =>
|
|
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)
|
package/src/content/types.ts
CHANGED
|
@@ -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 === (
|
|
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 (
|
|
274
|
-
|
|
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 ----
|
package/src/render/page-shell.ts
CHANGED
|
@@ -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">
|
package/src/theme/base-css.ts
CHANGED
|
@@ -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:
|
|
467
|
-
|
|
468
|
-
|
|
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:
|
|
632
|
+
.mkdn-search-overlay { padding-top: 4vh; align-items: flex-start; }
|
|
564
633
|
.mkdn-search-modal {
|
|
565
|
-
max-width: 100%; border-radius: 12px
|
|
566
|
-
max-height:
|
|
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
|
}
|