mdorigin 0.1.0 → 0.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/README.md CHANGED
@@ -2,15 +2,21 @@
2
2
 
3
3
  `mdorigin` is a markdown-first publishing engine.
4
4
 
5
- It treats markdown as the only source of truth, serves raw `.md` directly for agents, and renders `.html` views for humans from the same directory tree.
5
+ It treats markdown as the only source of truth, serves raw `.md` directly for agents, renders `.html` views for humans from the same directory tree, can return markdown from extensionless routes when clients send `Accept: text/markdown`, and supports frontmatter aliases for old URL redirects.
6
6
 
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- npm install mdorigin
10
+ npm install --save-dev mdorigin
11
11
  ```
12
12
 
13
- ## Development
13
+ Then run it with `npx`:
14
+
15
+ ```bash
16
+ npx mdorigin dev --root docs/site
17
+ ```
18
+
19
+ ## Repo Development
14
20
 
15
21
  ```bash
16
22
  npm install
@@ -24,15 +24,23 @@ export function createCloudflareWorker(manifest) {
24
24
  draftMode: 'exclude',
25
25
  siteConfig: manifest.siteConfig ?? {
26
26
  siteTitle: 'mdorigin',
27
+ siteUrl: undefined,
28
+ favicon: undefined,
29
+ logo: undefined,
27
30
  showDate: true,
28
31
  showSummary: true,
29
32
  theme: 'paper',
30
33
  template: 'document',
31
34
  topNav: [],
35
+ footerNav: [],
36
+ footerText: undefined,
37
+ socialLinks: [],
38
+ editLink: undefined,
32
39
  showHomeIndex: true,
33
40
  siteTitleConfigured: false,
34
41
  siteDescriptionConfigured: false,
35
42
  },
43
+ acceptHeader: request.headers.get('accept') ?? undefined,
36
44
  });
37
45
  const headers = new Headers(siteResponse.headers);
38
46
  const body = siteResponse.body instanceof Uint8Array
@@ -86,6 +86,7 @@ export function createNodeRequestListener(options) {
86
86
  const siteResponse = await handleSiteRequest(store, url.pathname, {
87
87
  draftMode: options.draftMode,
88
88
  siteConfig: options.siteConfig,
89
+ acceptHeader: request.headers.accept,
89
90
  });
90
91
  response.statusCode = siteResponse.status;
91
92
  for (const [headerName, headerValue] of Object.entries(siteResponse.headers)) {
@@ -1 +1,2 @@
1
+ #!/usr/bin/env node
1
2
  export {};
package/dist/cli/main.js CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  import { runBuildIndexCommand } from './build-index.js';
2
3
  import { runBuildCloudflareCommand } from './build-cloudflare.js';
3
4
  import { runDevCommand } from './dev.js';
@@ -5,6 +5,7 @@ export interface ParsedDocumentMeta {
5
5
  draft?: boolean;
6
6
  type?: string;
7
7
  order?: number;
8
+ aliases?: string[];
8
9
  [key: string]: unknown;
9
10
  }
10
11
  export interface ParsedDocument {
@@ -13,8 +14,15 @@ export interface ParsedDocument {
13
14
  html: string;
14
15
  meta: ParsedDocumentMeta;
15
16
  }
17
+ export interface ManagedIndexEntry {
18
+ kind: 'directory' | 'article';
19
+ title: string;
20
+ href: string;
21
+ detail?: string;
22
+ }
16
23
  export declare function parseMarkdownDocument(sourcePath: string, markdown: string): Promise<ParsedDocument>;
17
24
  export declare function renderMarkdown(markdown: string): Promise<string>;
18
25
  export declare function rewriteMarkdownLinksInHtml(html: string): string;
19
26
  export declare function stripManagedIndexBlock(markdown: string): string;
20
27
  export declare function stripManagedIndexLinks(markdown: string, hrefs: ReadonlySet<string>): string;
28
+ export declare function extractManagedIndexEntries(markdown: string): ManagedIndexEntry[];
@@ -49,6 +49,35 @@ export function stripManagedIndexLinks(markdown, hrefs) {
49
49
  return `${start}${keptBlocks.join('\n\n')}\n${end}`;
50
50
  });
51
51
  }
52
+ export function extractManagedIndexEntries(markdown) {
53
+ const match = markdown.match(/<!-- INDEX:START -->\n?([\s\S]*?)\n?<!-- INDEX:END -->/);
54
+ if (!match) {
55
+ return [];
56
+ }
57
+ const blocks = match[1]
58
+ .trim()
59
+ .split(/\n\s*\n/g)
60
+ .map((block) => block.trim())
61
+ .filter((block) => block !== '');
62
+ const entries = [];
63
+ for (const block of blocks) {
64
+ const lines = block.split('\n');
65
+ const firstLine = lines[0]?.trim() ?? '';
66
+ const entryMatch = firstLine.match(/^- \[([^\]]+)\]\(([^)]+)\)$/);
67
+ if (!entryMatch) {
68
+ continue;
69
+ }
70
+ const rawHref = entryMatch[2];
71
+ const href = rewriteMarkdownHref(rawHref);
72
+ entries.push({
73
+ kind: href.endsWith('/') ? 'directory' : 'article',
74
+ title: entryMatch[1],
75
+ href,
76
+ detail: lines[1]?.trim() || undefined,
77
+ });
78
+ }
79
+ return entries;
80
+ }
52
81
  function normalizeMeta(data) {
53
82
  const meta = { ...data };
54
83
  if (typeof data.title === 'string') {
@@ -78,6 +107,12 @@ function normalizeMeta(data) {
78
107
  meta.order = order;
79
108
  }
80
109
  }
110
+ if (typeof data.aliases === 'string' && data.aliases !== '') {
111
+ meta.aliases = [data.aliases];
112
+ }
113
+ if (Array.isArray(data.aliases)) {
114
+ meta.aliases = data.aliases.filter((value) => typeof value === 'string' && value !== '');
115
+ }
81
116
  return meta;
82
117
  }
83
118
  function rewriteMarkdownHref(href) {
@@ -3,6 +3,7 @@ import type { ResolvedSiteConfig } from './site-config.js';
3
3
  export interface HandleSiteRequestOptions {
4
4
  draftMode: 'include' | 'exclude';
5
5
  siteConfig: ResolvedSiteConfig;
6
+ acceptHeader?: string;
6
7
  }
7
8
  export interface SiteResponse {
8
9
  status: number;
@@ -1,16 +1,36 @@
1
1
  import path from 'node:path';
2
2
  import { inferDirectoryContentType } from './content-type.js';
3
3
  import { getDirectoryIndexCandidates } from './directory-index.js';
4
- import { parseMarkdownDocument, stripManagedIndexBlock, stripManagedIndexLinks, } from './markdown.js';
5
- import { resolveRequest } from './router.js';
4
+ import { extractManagedIndexEntries, parseMarkdownDocument, stripManagedIndexBlock, stripManagedIndexLinks, } from './markdown.js';
5
+ import { normalizeRequestPath, resolveRequest } from './router.js';
6
6
  import { escapeHtml, renderDocument } from '../html/template.js';
7
7
  export async function handleSiteRequest(store, pathname, options) {
8
+ if (pathname === '/sitemap.xml') {
9
+ return renderSitemap(store, options);
10
+ }
8
11
  const resolved = resolveRequest(pathname);
12
+ const negotiatedMarkdown = shouldServeMarkdownForRequest(resolved, options.acceptHeader);
9
13
  if (resolved.kind === 'not-found' || !resolved.sourcePath) {
14
+ const aliasRedirect = await tryRedirectAlias(store, pathname, options);
15
+ if (aliasRedirect !== null) {
16
+ return aliasRedirect;
17
+ }
10
18
  return notFound();
11
19
  }
12
20
  const entry = await store.get(resolved.sourcePath);
13
21
  if (entry === null) {
22
+ const aliasRedirect = await tryRedirectAlias(store, pathname, options);
23
+ if (aliasRedirect !== null) {
24
+ return aliasRedirect;
25
+ }
26
+ const alternateDirectoryMarkdown = await tryServeAlternateDirectoryMarkdown(store, resolved, options, negotiatedMarkdown);
27
+ if (alternateDirectoryMarkdown !== null) {
28
+ return alternateDirectoryMarkdown;
29
+ }
30
+ const alternateMarkdownRedirect = await tryRedirectAlternateDirectoryMarkdown(store, resolved, options);
31
+ if (alternateMarkdownRedirect !== null) {
32
+ return alternateMarkdownRedirect;
33
+ }
14
34
  if (resolved.kind === 'html' && resolved.requestPath.endsWith('/')) {
15
35
  const directoryIndexResponse = await tryRenderAlternateDirectoryIndex(store, resolved.requestPath, options);
16
36
  if (directoryIndexResponse !== null) {
@@ -26,16 +46,16 @@ export async function handleSiteRequest(store, pathname, options) {
26
46
  if (entry.kind !== 'text' || entry.text === undefined) {
27
47
  return notFound();
28
48
  }
29
- if (resolved.kind === 'markdown') {
49
+ if (resolved.kind === 'markdown' || negotiatedMarkdown) {
30
50
  const parsed = await parseMarkdownDocument(resolved.sourcePath, entry.text);
31
51
  if (parsed.meta.draft === true && options.draftMode === 'exclude') {
32
52
  return notFound();
33
53
  }
34
54
  return {
35
55
  status: 200,
36
- headers: {
56
+ headers: withVaryAcceptIfNeeded({
37
57
  'content-type': entry.mediaType,
38
- },
58
+ }, negotiatedMarkdown),
39
59
  body: entry.text,
40
60
  };
41
61
  }
@@ -49,17 +69,26 @@ export async function handleSiteRequest(store, pathname, options) {
49
69
  : isRootHomeRequest(resolved.requestPath) && navigation.items.length > 0
50
70
  ? stripManagedIndexLinks(entry.text, new Set(navigation.items.map((item) => item.href)))
51
71
  : entry.text;
52
- const renderedParsed = renderedBody === entry.text
72
+ const catalogEntries = options.siteConfig.template === 'catalog'
73
+ ? extractManagedIndexEntries(renderedBody)
74
+ : [];
75
+ const documentBody = options.siteConfig.template === 'catalog'
76
+ ? stripManagedIndexBlock(renderedBody)
77
+ : renderedBody;
78
+ const renderedParsed = documentBody === entry.text
53
79
  ? parsed
54
- : await parseMarkdownDocument(resolved.sourcePath, renderedBody);
80
+ : await parseMarkdownDocument(resolved.sourcePath, documentBody);
55
81
  return {
56
82
  status: 200,
57
- headers: {
83
+ headers: withVaryAcceptIfNeeded({
58
84
  'content-type': 'text/html; charset=utf-8',
59
- },
85
+ }, shouldVaryOnAccept(resolved)),
60
86
  body: renderDocument({
61
87
  siteTitle: options.siteConfig.siteTitle,
62
88
  siteDescription: options.siteConfig.siteDescription,
89
+ siteUrl: options.siteConfig.siteUrl,
90
+ favicon: options.siteConfig.favicon,
91
+ logo: options.siteConfig.logo,
63
92
  title: getDocumentTitle(parsed),
64
93
  body: renderedParsed.html,
65
94
  summary: options.siteConfig.showSummary === false ? undefined : parsed.meta.summary,
@@ -69,7 +98,14 @@ export async function handleSiteRequest(store, pathname, options) {
69
98
  theme: options.siteConfig.theme,
70
99
  template: options.siteConfig.template,
71
100
  topNav: navigation.items,
101
+ footerNav: options.siteConfig.footerNav,
102
+ footerText: options.siteConfig.footerText,
103
+ socialLinks: options.siteConfig.socialLinks,
104
+ editLinkHref: getEditLinkHref(options.siteConfig, resolved.sourcePath),
72
105
  stylesheetContent: options.siteConfig.stylesheetContent,
106
+ canonicalPath: getCanonicalHtmlPathForContentPath(resolved.sourcePath),
107
+ alternateMarkdownPath: getMarkdownRequestPathForContentPath(resolved.sourcePath),
108
+ catalogEntries,
73
109
  }),
74
110
  };
75
111
  }
@@ -103,6 +139,61 @@ function notFound() {
103
139
  body: 'Not Found',
104
140
  };
105
141
  }
142
+ function redirect(location) {
143
+ return {
144
+ status: 308,
145
+ headers: {
146
+ location,
147
+ },
148
+ };
149
+ }
150
+ async function renderSitemap(store, options) {
151
+ if (!options.siteConfig.siteUrl) {
152
+ return {
153
+ status: 500,
154
+ headers: {
155
+ 'content-type': 'text/plain; charset=utf-8',
156
+ },
157
+ body: 'sitemap.xml requires siteUrl in mdorigin.config.json',
158
+ };
159
+ }
160
+ const entries = await collectSitemapEntries(store, '', options);
161
+ const body = [
162
+ '<?xml version="1.0" encoding="UTF-8"?>',
163
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
164
+ ...entries.map((entry) => {
165
+ const lastmod = entry.lastmod ? `<lastmod>${escapeHtml(entry.lastmod)}</lastmod>` : '';
166
+ return ` <url><loc>${escapeHtml(`${options.siteConfig.siteUrl}${entry.path}`)}</loc>${lastmod}</url>`;
167
+ }),
168
+ '</urlset>',
169
+ ].join('\n');
170
+ return {
171
+ status: 200,
172
+ headers: {
173
+ 'content-type': 'application/xml; charset=utf-8',
174
+ },
175
+ body,
176
+ };
177
+ }
178
+ function withVaryAcceptIfNeeded(headers, enabled) {
179
+ if (!enabled) {
180
+ return headers;
181
+ }
182
+ return {
183
+ ...headers,
184
+ vary: appendVary(headers.vary, 'Accept'),
185
+ };
186
+ }
187
+ function appendVary(existing, value) {
188
+ if (!existing || existing.trim() === '') {
189
+ return value;
190
+ }
191
+ const parts = existing.split(',').map((part) => part.trim().toLowerCase());
192
+ if (parts.includes(value.toLowerCase())) {
193
+ return existing;
194
+ }
195
+ return `${existing}, ${value}`;
196
+ }
106
197
  function getDocumentTitle(parsed) {
107
198
  if (parsed.meta.title) {
108
199
  return parsed.meta.title;
@@ -112,6 +203,50 @@ function getDocumentTitle(parsed) {
112
203
  ? path.posix.basename(path.posix.dirname(parsed.sourcePath)) || 'mdorigin'
113
204
  : basename;
114
205
  }
206
+ async function collectSitemapEntries(store, directoryPath, options) {
207
+ const entries = await store.listDirectory(directoryPath);
208
+ if (entries === null) {
209
+ return [];
210
+ }
211
+ const sitemapEntries = [];
212
+ for (const entry of entries) {
213
+ if (entry.kind === 'directory') {
214
+ sitemapEntries.push(...(await collectSitemapEntries(store, entry.path, options)));
215
+ continue;
216
+ }
217
+ if (!isMarkdownEntry(entry)) {
218
+ continue;
219
+ }
220
+ const document = await store.get(entry.path);
221
+ if (document === null || document.kind !== 'text' || document.text === undefined) {
222
+ continue;
223
+ }
224
+ const parsed = await parseMarkdownDocument(entry.path, document.text);
225
+ if (parsed.meta.draft === true && options.draftMode === 'exclude') {
226
+ continue;
227
+ }
228
+ sitemapEntries.push({
229
+ path: getCanonicalHtmlPathForContentPath(entry.path),
230
+ lastmod: parsed.meta.date,
231
+ });
232
+ }
233
+ sitemapEntries.sort((left, right) => left.path.localeCompare(right.path));
234
+ return dedupeSitemapEntries(sitemapEntries);
235
+ }
236
+ function dedupeSitemapEntries(entries) {
237
+ const deduped = new Map();
238
+ for (const entry of entries) {
239
+ const existing = deduped.get(entry.path);
240
+ if (!existing) {
241
+ deduped.set(entry.path, entry);
242
+ continue;
243
+ }
244
+ if (!existing.lastmod && entry.lastmod) {
245
+ deduped.set(entry.path, entry);
246
+ }
247
+ }
248
+ return Array.from(deduped.values());
249
+ }
115
250
  async function renderDirectoryListing(store, requestPath, siteConfig) {
116
251
  const directoryPath = requestPath === '/' ? '' : requestPath.slice(1).replace(/\/$/, '');
117
252
  const entries = await store.listDirectory(directoryPath);
@@ -135,6 +270,9 @@ async function renderDirectoryListing(store, requestPath, siteConfig) {
135
270
  body: renderDocument({
136
271
  siteTitle: siteConfig.siteTitle,
137
272
  siteDescription: siteConfig.siteDescription,
273
+ siteUrl: siteConfig.siteUrl,
274
+ favicon: siteConfig.favicon,
275
+ logo: siteConfig.logo,
138
276
  title: getDirectoryTitle(requestPath),
139
277
  body,
140
278
  showSummary: false,
@@ -142,7 +280,12 @@ async function renderDirectoryListing(store, requestPath, siteConfig) {
142
280
  theme: siteConfig.theme,
143
281
  template: siteConfig.template,
144
282
  topNav: navigation.items,
283
+ footerNav: siteConfig.footerNav,
284
+ footerText: siteConfig.footerText,
285
+ socialLinks: siteConfig.socialLinks,
145
286
  stylesheetContent: siteConfig.stylesheetContent,
287
+ canonicalPath: requestPath,
288
+ alternateMarkdownPath: getMarkdownRequestPathForContentPath(getDirectoryIndexContentPathForRequestPath(requestPath)),
146
289
  }),
147
290
  };
148
291
  }
@@ -167,6 +310,11 @@ function getDirectoryEntryLabel(entry) {
167
310
  function getDirectoryTitle(requestPath) {
168
311
  return requestPath === '/' ? 'Index' : requestPath;
169
312
  }
313
+ function getDirectoryIndexContentPathForRequestPath(requestPath) {
314
+ return requestPath === '/'
315
+ ? 'index.md'
316
+ : `${requestPath.slice(1).replace(/\/$/, '')}/index.md`;
317
+ }
170
318
  async function tryRenderAlternateDirectoryIndex(store, requestPath, options) {
171
319
  const directoryPath = requestPath === '/' ? '' : requestPath.slice(1).replace(/\/$/, '');
172
320
  for (const candidatePath of getDirectoryIndexCandidates(directoryPath)) {
@@ -187,9 +335,15 @@ async function tryRenderAlternateDirectoryIndex(store, requestPath, options) {
187
335
  : isRootHomeRequest(requestPath) && navigation.items.length > 0
188
336
  ? stripManagedIndexLinks(entry.text, new Set(navigation.items.map((item) => item.href)))
189
337
  : entry.text;
190
- const renderedParsed = renderedBody === entry.text
338
+ const catalogEntries = options.siteConfig.template === 'catalog'
339
+ ? extractManagedIndexEntries(renderedBody)
340
+ : [];
341
+ const documentBody = options.siteConfig.template === 'catalog'
342
+ ? stripManagedIndexBlock(renderedBody)
343
+ : renderedBody;
344
+ const renderedParsed = documentBody === entry.text
191
345
  ? parsed
192
- : await parseMarkdownDocument(candidatePath, renderedBody);
346
+ : await parseMarkdownDocument(candidatePath, documentBody);
193
347
  return {
194
348
  status: 200,
195
349
  headers: {
@@ -198,6 +352,9 @@ async function tryRenderAlternateDirectoryIndex(store, requestPath, options) {
198
352
  body: renderDocument({
199
353
  siteTitle: options.siteConfig.siteTitle,
200
354
  siteDescription: options.siteConfig.siteDescription,
355
+ siteUrl: options.siteConfig.siteUrl,
356
+ favicon: options.siteConfig.favicon,
357
+ logo: options.siteConfig.logo,
201
358
  title: getDocumentTitle(parsed),
202
359
  body: renderedParsed.html,
203
360
  summary: options.siteConfig.showSummary === false ? undefined : parsed.meta.summary,
@@ -207,15 +364,170 @@ async function tryRenderAlternateDirectoryIndex(store, requestPath, options) {
207
364
  theme: options.siteConfig.theme,
208
365
  template: options.siteConfig.template,
209
366
  topNav: navigation.items,
367
+ footerNav: options.siteConfig.footerNav,
368
+ footerText: options.siteConfig.footerText,
369
+ socialLinks: options.siteConfig.socialLinks,
370
+ editLinkHref: getEditLinkHref(options.siteConfig, candidatePath),
210
371
  stylesheetContent: options.siteConfig.stylesheetContent,
372
+ canonicalPath: requestPath,
373
+ alternateMarkdownPath: getMarkdownRequestPathForContentPath(candidatePath),
374
+ catalogEntries,
211
375
  }),
212
376
  };
213
377
  }
214
378
  return null;
215
379
  }
380
+ async function tryServeAlternateDirectoryMarkdown(store, resolved, options, negotiatedMarkdown) {
381
+ if (!negotiatedMarkdown || resolved.kind !== 'html' || !resolved.sourcePath) {
382
+ return null;
383
+ }
384
+ if (!resolved.requestPath.endsWith('/')) {
385
+ return null;
386
+ }
387
+ const directoryPath = path.posix.dirname(resolved.sourcePath);
388
+ for (const candidatePath of getDirectoryIndexCandidates(directoryPath === '.' ? '' : directoryPath)) {
389
+ if (candidatePath === resolved.sourcePath) {
390
+ continue;
391
+ }
392
+ const entry = await store.get(candidatePath);
393
+ if (entry === null || entry.kind !== 'text' || entry.text === undefined) {
394
+ continue;
395
+ }
396
+ const parsed = await parseMarkdownDocument(candidatePath, entry.text);
397
+ if (parsed.meta.draft === true && options.draftMode === 'exclude') {
398
+ return notFound();
399
+ }
400
+ return {
401
+ status: 200,
402
+ headers: withVaryAcceptIfNeeded({
403
+ 'content-type': entry.mediaType,
404
+ }, true),
405
+ body: entry.text,
406
+ };
407
+ }
408
+ return null;
409
+ }
410
+ async function tryRedirectAlternateDirectoryMarkdown(store, resolved, options) {
411
+ if (resolved.kind !== 'markdown' || !resolved.sourcePath) {
412
+ return null;
413
+ }
414
+ const basename = path.posix.basename(resolved.sourcePath);
415
+ if (basename !== 'index.md' && basename !== 'README.md') {
416
+ return null;
417
+ }
418
+ const directoryPath = path.posix.dirname(resolved.sourcePath);
419
+ for (const candidatePath of getDirectoryIndexCandidates(directoryPath === '.' ? '' : directoryPath)) {
420
+ if (candidatePath === resolved.sourcePath) {
421
+ continue;
422
+ }
423
+ const entry = await store.get(candidatePath);
424
+ if (entry === null || entry.kind !== 'text' || entry.text === undefined) {
425
+ continue;
426
+ }
427
+ const parsed = await parseMarkdownDocument(candidatePath, entry.text);
428
+ if (parsed.meta.draft === true && options.draftMode === 'exclude') {
429
+ return null;
430
+ }
431
+ return redirect(getMarkdownRequestPathForContentPath(candidatePath));
432
+ }
433
+ return null;
434
+ }
435
+ function getMarkdownRequestPathForContentPath(contentPath) {
436
+ return `/${contentPath}`;
437
+ }
216
438
  function isRootHomeRequest(requestPath) {
217
439
  return requestPath === '/';
218
440
  }
441
+ function shouldServeMarkdownForRequest(resolved, acceptHeader) {
442
+ return shouldVaryOnAccept(resolved) && acceptsMarkdown(acceptHeader);
443
+ }
444
+ function shouldVaryOnAccept(resolved) {
445
+ if (resolved.kind !== 'html') {
446
+ return false;
447
+ }
448
+ return !resolved.requestPath.endsWith('.html');
449
+ }
450
+ function acceptsMarkdown(acceptHeader) {
451
+ if (!acceptHeader) {
452
+ return false;
453
+ }
454
+ return acceptHeader
455
+ .split(',')
456
+ .map((part) => part.split(';', 1)[0]?.trim().toLowerCase())
457
+ .includes('text/markdown');
458
+ }
459
+ async function tryRedirectAlias(store, pathname, options) {
460
+ const normalizedRequestPath = normalizeRequestPath(pathname);
461
+ if (normalizedRequestPath === null) {
462
+ return null;
463
+ }
464
+ const redirectLocation = await findAliasRedirectLocation(store, '', normalizedRequestPath, options);
465
+ if (!redirectLocation || redirectLocation === normalizedRequestPath) {
466
+ return null;
467
+ }
468
+ return redirect(redirectLocation);
469
+ }
470
+ async function findAliasRedirectLocation(store, directoryPath, requestPath, options) {
471
+ const entries = await store.listDirectory(directoryPath);
472
+ if (entries === null) {
473
+ return null;
474
+ }
475
+ for (const entry of entries) {
476
+ if (entry.kind === 'directory') {
477
+ const nestedMatch = await findAliasRedirectLocation(store, entry.path, requestPath, options);
478
+ if (nestedMatch !== null) {
479
+ return nestedMatch;
480
+ }
481
+ continue;
482
+ }
483
+ if (!isMarkdownEntry(entry)) {
484
+ continue;
485
+ }
486
+ const document = await store.get(entry.path);
487
+ if (document === null || document.kind !== 'text' || document.text === undefined) {
488
+ continue;
489
+ }
490
+ const parsed = await parseMarkdownDocument(entry.path, document.text);
491
+ if (parsed.meta.draft === true && options.draftMode === 'exclude') {
492
+ continue;
493
+ }
494
+ const aliases = normalizeAliases(parsed.meta.aliases);
495
+ if (!aliases.includes(requestPath)) {
496
+ continue;
497
+ }
498
+ return getCanonicalHtmlPathForContentPath(entry.path);
499
+ }
500
+ return null;
501
+ }
502
+ function isMarkdownEntry(entry) {
503
+ return path.posix.extname(entry.name).toLowerCase() === '.md';
504
+ }
505
+ function normalizeAliases(aliases) {
506
+ if (!Array.isArray(aliases)) {
507
+ return [];
508
+ }
509
+ return aliases.flatMap((alias) => {
510
+ if (typeof alias !== 'string') {
511
+ return [];
512
+ }
513
+ const normalized = normalizeRequestPath(alias);
514
+ return normalized === null ? [] : [normalized];
515
+ });
516
+ }
517
+ function getCanonicalHtmlPathForContentPath(contentPath) {
518
+ const basename = path.posix.basename(contentPath).toLowerCase();
519
+ if (basename === 'index.md' || basename === 'readme.md') {
520
+ const directory = path.posix.dirname(contentPath);
521
+ return directory === '.' ? '/' : `/${directory}/`;
522
+ }
523
+ return `/${contentPath.slice(0, -'.md'.length)}`;
524
+ }
525
+ function getEditLinkHref(siteConfig, sourcePath) {
526
+ if (!siteConfig.editLink || !sourcePath) {
527
+ return undefined;
528
+ }
529
+ return `${siteConfig.editLink.baseUrl}${sourcePath}`;
530
+ }
219
531
  async function resolveTopNav(store, siteConfig) {
220
532
  if (siteConfig.topNav.length > 0) {
221
533
  return {
@@ -5,3 +5,4 @@ export interface ResolvedRequest {
5
5
  sourcePath?: string;
6
6
  }
7
7
  export declare function resolveRequest(pathname: string): ResolvedRequest;
8
+ export declare function normalizeRequestPath(pathname: string): string | null;
@@ -63,7 +63,7 @@ export function resolveRequest(pathname) {
63
63
  sourcePath,
64
64
  };
65
65
  }
66
- function normalizeRequestPath(pathname) {
66
+ export function normalizeRequestPath(pathname) {
67
67
  try {
68
68
  const decoded = decodeURIComponent(pathname || '/');
69
69
  const collapsed = decoded.replace(/\/{2,}/g, '/');
@@ -5,25 +5,52 @@ export interface SiteNavItem {
5
5
  label: string;
6
6
  href: string;
7
7
  }
8
+ export interface SiteLogo {
9
+ src: string;
10
+ alt?: string;
11
+ href?: string;
12
+ }
13
+ export interface SiteSocialLink {
14
+ icon: string;
15
+ label: string;
16
+ href: string;
17
+ }
18
+ export interface EditLinkConfig {
19
+ baseUrl: string;
20
+ }
8
21
  export interface SiteConfig {
9
22
  siteTitle?: string;
10
23
  siteDescription?: string;
24
+ siteUrl?: string;
25
+ favicon?: string;
26
+ logo?: SiteLogo;
11
27
  showDate?: boolean;
12
28
  showSummary?: boolean;
13
29
  stylesheet?: string;
14
30
  theme?: BuiltInThemeName;
15
31
  template?: TemplateName;
16
32
  topNav?: SiteNavItem[];
33
+ footerNav?: SiteNavItem[];
34
+ footerText?: string;
35
+ socialLinks?: SiteSocialLink[];
36
+ editLink?: EditLinkConfig;
17
37
  showHomeIndex?: boolean;
18
38
  }
19
39
  export interface ResolvedSiteConfig {
20
40
  siteTitle: string;
21
41
  siteDescription?: string;
42
+ siteUrl?: string;
43
+ favicon?: string;
44
+ logo?: SiteLogo;
22
45
  showDate: boolean;
23
46
  showSummary: boolean;
24
47
  theme: BuiltInThemeName;
25
48
  template: TemplateName;
26
49
  topNav: SiteNavItem[];
50
+ footerNav: SiteNavItem[];
51
+ footerText?: string;
52
+ socialLinks: SiteSocialLink[];
53
+ editLink?: EditLinkConfig;
27
54
  showHomeIndex: boolean;
28
55
  stylesheetContent?: string;
29
56
  siteTitleConfigured: boolean;