mdorigin 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,22 +1,53 @@
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';
6
- import { escapeHtml, renderDocument } from '../html/template.js';
4
+ import { extractManagedIndexEntries, getDocumentSummary, getDocumentTitle as getParsedDocumentTitle, parseMarkdownDocument, stripManagedIndexBlock, stripManagedIndexLinks, } from './markdown.js';
5
+ import { handleApiRoute } from './api.js';
6
+ import { normalizeRequestPath, resolveRequest } from './router.js';
7
+ import { escapeHtml, renderCatalogArticleItems, renderDocument, } from '../html/template.js';
7
8
  export async function handleSiteRequest(store, pathname, options) {
9
+ const searchEnabled = options.searchApi !== undefined;
10
+ const apiRoute = await handleApiRoute(pathname, options.searchParams, {
11
+ searchApi: options.searchApi,
12
+ siteConfig: options.siteConfig,
13
+ requestUrl: options.requestUrl,
14
+ });
15
+ if (apiRoute !== null) {
16
+ return apiRoute;
17
+ }
18
+ if (pathname === '/sitemap.xml') {
19
+ return renderSitemap(store, options);
20
+ }
8
21
  const resolved = resolveRequest(pathname);
22
+ const catalogFragmentRequest = getCatalogFragmentRequest(options.searchParams);
23
+ const negotiatedMarkdown = shouldServeMarkdownForRequest(resolved, options.acceptHeader);
9
24
  if (resolved.kind === 'not-found' || !resolved.sourcePath) {
25
+ const aliasRedirect = await tryRedirectAlias(store, pathname, options);
26
+ if (aliasRedirect !== null) {
27
+ return aliasRedirect;
28
+ }
10
29
  return notFound();
11
30
  }
12
31
  const entry = await store.get(resolved.sourcePath);
13
32
  if (entry === null) {
33
+ const aliasRedirect = await tryRedirectAlias(store, pathname, options);
34
+ if (aliasRedirect !== null) {
35
+ return aliasRedirect;
36
+ }
37
+ const alternateDirectoryMarkdown = await tryServeAlternateDirectoryMarkdown(store, resolved, options, negotiatedMarkdown);
38
+ if (alternateDirectoryMarkdown !== null) {
39
+ return alternateDirectoryMarkdown;
40
+ }
41
+ const alternateMarkdownRedirect = await tryRedirectAlternateDirectoryMarkdown(store, resolved, options);
42
+ if (alternateMarkdownRedirect !== null) {
43
+ return alternateMarkdownRedirect;
44
+ }
14
45
  if (resolved.kind === 'html' && resolved.requestPath.endsWith('/')) {
15
46
  const directoryIndexResponse = await tryRenderAlternateDirectoryIndex(store, resolved.requestPath, options);
16
47
  if (directoryIndexResponse !== null) {
17
48
  return directoryIndexResponse;
18
49
  }
19
- return renderDirectoryListing(store, resolved.requestPath, options.siteConfig);
50
+ return renderDirectoryListing(store, resolved.requestPath, options.siteConfig, searchEnabled);
20
51
  }
21
52
  return notFound();
22
53
  }
@@ -26,16 +57,16 @@ export async function handleSiteRequest(store, pathname, options) {
26
57
  if (entry.kind !== 'text' || entry.text === undefined) {
27
58
  return notFound();
28
59
  }
29
- if (resolved.kind === 'markdown') {
60
+ if (resolved.kind === 'markdown' || negotiatedMarkdown) {
30
61
  const parsed = await parseMarkdownDocument(resolved.sourcePath, entry.text);
31
62
  if (parsed.meta.draft === true && options.draftMode === 'exclude') {
32
63
  return notFound();
33
64
  }
34
65
  return {
35
66
  status: 200,
36
- headers: {
67
+ headers: withVaryAcceptIfNeeded({
37
68
  'content-type': entry.mediaType,
38
- },
69
+ }, negotiatedMarkdown),
39
70
  body: entry.text,
40
71
  };
41
72
  }
@@ -49,27 +80,94 @@ export async function handleSiteRequest(store, pathname, options) {
49
80
  : isRootHomeRequest(resolved.requestPath) && navigation.items.length > 0
50
81
  ? stripManagedIndexLinks(entry.text, new Set(navigation.items.map((item) => item.href)))
51
82
  : entry.text;
52
- const renderedParsed = renderedBody === entry.text
83
+ const catalogEntries = options.siteConfig.template === 'catalog'
84
+ ? extractManagedIndexEntries(renderedBody)
85
+ : [];
86
+ if (catalogFragmentRequest !== null &&
87
+ options.siteConfig.template === 'catalog') {
88
+ return renderCatalogPostsFragment(catalogEntries, catalogFragmentRequest);
89
+ }
90
+ const documentBody = options.siteConfig.template === 'catalog'
91
+ ? stripManagedIndexBlock(renderedBody)
92
+ : renderedBody;
93
+ const renderedParsed = documentBody === entry.text
53
94
  ? parsed
54
- : await parseMarkdownDocument(resolved.sourcePath, renderedBody);
95
+ : await parseMarkdownDocument(resolved.sourcePath, documentBody);
55
96
  return {
56
97
  status: 200,
57
- headers: {
98
+ headers: withVaryAcceptIfNeeded({
58
99
  'content-type': 'text/html; charset=utf-8',
59
- },
100
+ }, shouldVaryOnAccept(resolved)),
60
101
  body: renderDocument({
61
102
  siteTitle: options.siteConfig.siteTitle,
62
103
  siteDescription: options.siteConfig.siteDescription,
104
+ siteUrl: options.siteConfig.siteUrl,
105
+ favicon: options.siteConfig.favicon,
106
+ logo: options.siteConfig.logo,
63
107
  title: getDocumentTitle(parsed),
64
108
  body: renderedParsed.html,
65
- summary: options.siteConfig.showSummary === false ? undefined : parsed.meta.summary,
109
+ summary: options.siteConfig.showSummary === false
110
+ ? undefined
111
+ : getDocumentSummary(parsed.meta, parsed.body),
66
112
  date: options.siteConfig.showDate === false ? undefined : parsed.meta.date,
67
113
  showSummary: options.siteConfig.showSummary,
68
114
  showDate: options.siteConfig.showDate,
69
115
  theme: options.siteConfig.theme,
70
116
  template: options.siteConfig.template,
71
117
  topNav: navigation.items,
118
+ footerNav: options.siteConfig.footerNav,
119
+ footerText: options.siteConfig.footerText,
120
+ socialLinks: options.siteConfig.socialLinks,
121
+ editLinkHref: getEditLinkHref(options.siteConfig, resolved.sourcePath),
72
122
  stylesheetContent: options.siteConfig.stylesheetContent,
123
+ canonicalPath: getCanonicalHtmlPathForContentPath(resolved.sourcePath),
124
+ alternateMarkdownPath: getMarkdownRequestPathForContentPath(resolved.sourcePath),
125
+ catalogEntries,
126
+ catalogRequestPath: resolved.requestPath,
127
+ catalogInitialPostCount: options.siteConfig.catalogInitialPostCount,
128
+ catalogLoadMoreStep: options.siteConfig.catalogLoadMoreStep,
129
+ searchEnabled,
130
+ }),
131
+ };
132
+ }
133
+ function getCatalogFragmentRequest(searchParams) {
134
+ if (searchParams?.get('catalog-format') !== 'posts') {
135
+ return null;
136
+ }
137
+ const offset = normalizeNonNegativeInteger(searchParams.get('catalog-offset'));
138
+ const limit = normalizePositiveInteger(searchParams.get('catalog-limit'));
139
+ if (offset === null || limit === null) {
140
+ return null;
141
+ }
142
+ return { offset, limit };
143
+ }
144
+ function normalizeNonNegativeInteger(value) {
145
+ if (value === null) {
146
+ return 0;
147
+ }
148
+ const parsed = Number.parseInt(value, 10);
149
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
150
+ }
151
+ function normalizePositiveInteger(value) {
152
+ if (value === null) {
153
+ return null;
154
+ }
155
+ const parsed = Number.parseInt(value, 10);
156
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
157
+ }
158
+ function renderCatalogPostsFragment(entries, request) {
159
+ const articles = entries.filter((entry) => entry.kind === 'article');
160
+ const visibleArticles = articles.slice(request.offset, request.offset + request.limit);
161
+ const nextOffset = request.offset + visibleArticles.length;
162
+ return {
163
+ status: 200,
164
+ headers: {
165
+ 'content-type': 'application/json; charset=utf-8',
166
+ },
167
+ body: JSON.stringify({
168
+ itemsHtml: renderCatalogArticleItems(visibleArticles),
169
+ hasMore: nextOffset < articles.length,
170
+ nextOffset,
73
171
  }),
74
172
  };
75
173
  }
@@ -103,16 +201,113 @@ function notFound() {
103
201
  body: 'Not Found',
104
202
  };
105
203
  }
106
- function getDocumentTitle(parsed) {
107
- if (parsed.meta.title) {
108
- return parsed.meta.title;
204
+ function redirect(location) {
205
+ return {
206
+ status: 308,
207
+ headers: {
208
+ location,
209
+ },
210
+ };
211
+ }
212
+ async function renderSitemap(store, options) {
213
+ if (!options.siteConfig.siteUrl) {
214
+ return {
215
+ status: 500,
216
+ headers: {
217
+ 'content-type': 'text/plain; charset=utf-8',
218
+ },
219
+ body: 'sitemap.xml requires siteUrl in mdorigin.config.json',
220
+ };
221
+ }
222
+ const entries = await collectSitemapEntries(store, '', options);
223
+ const body = [
224
+ '<?xml version="1.0" encoding="UTF-8"?>',
225
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
226
+ ...entries.map((entry) => {
227
+ const lastmod = entry.lastmod ? `<lastmod>${escapeHtml(entry.lastmod)}</lastmod>` : '';
228
+ return ` <url><loc>${escapeHtml(`${options.siteConfig.siteUrl}${entry.path}`)}</loc>${lastmod}</url>`;
229
+ }),
230
+ '</urlset>',
231
+ ].join('\n');
232
+ return {
233
+ status: 200,
234
+ headers: {
235
+ 'content-type': 'application/xml; charset=utf-8',
236
+ },
237
+ body,
238
+ };
239
+ }
240
+ function withVaryAcceptIfNeeded(headers, enabled) {
241
+ if (!enabled) {
242
+ return headers;
109
243
  }
244
+ return {
245
+ ...headers,
246
+ vary: appendVary(headers.vary, 'Accept'),
247
+ };
248
+ }
249
+ function appendVary(existing, value) {
250
+ if (!existing || existing.trim() === '') {
251
+ return value;
252
+ }
253
+ const parts = existing.split(',').map((part) => part.trim().toLowerCase());
254
+ if (parts.includes(value.toLowerCase())) {
255
+ return existing;
256
+ }
257
+ return `${existing}, ${value}`;
258
+ }
259
+ function getDocumentTitle(parsed) {
110
260
  const basename = path.posix.basename(parsed.sourcePath, '.md');
111
- return basename === 'index'
261
+ const fallback = basename === 'index' || basename === 'README' || basename === 'SKILL'
112
262
  ? path.posix.basename(path.posix.dirname(parsed.sourcePath)) || 'mdorigin'
113
263
  : basename;
264
+ return getParsedDocumentTitle(parsed.meta, parsed.body, fallback);
265
+ }
266
+ async function collectSitemapEntries(store, directoryPath, options) {
267
+ const entries = await store.listDirectory(directoryPath);
268
+ if (entries === null) {
269
+ return [];
270
+ }
271
+ const sitemapEntries = [];
272
+ for (const entry of entries) {
273
+ if (entry.kind === 'directory') {
274
+ sitemapEntries.push(...(await collectSitemapEntries(store, entry.path, options)));
275
+ continue;
276
+ }
277
+ if (!isMarkdownEntry(entry)) {
278
+ continue;
279
+ }
280
+ const document = await store.get(entry.path);
281
+ if (document === null || document.kind !== 'text' || document.text === undefined) {
282
+ continue;
283
+ }
284
+ const parsed = await parseMarkdownDocument(entry.path, document.text);
285
+ if (parsed.meta.draft === true && options.draftMode === 'exclude') {
286
+ continue;
287
+ }
288
+ sitemapEntries.push({
289
+ path: getCanonicalHtmlPathForContentPath(entry.path),
290
+ lastmod: parsed.meta.date,
291
+ });
292
+ }
293
+ sitemapEntries.sort((left, right) => left.path.localeCompare(right.path));
294
+ return dedupeSitemapEntries(sitemapEntries);
114
295
  }
115
- async function renderDirectoryListing(store, requestPath, siteConfig) {
296
+ function dedupeSitemapEntries(entries) {
297
+ const deduped = new Map();
298
+ for (const entry of entries) {
299
+ const existing = deduped.get(entry.path);
300
+ if (!existing) {
301
+ deduped.set(entry.path, entry);
302
+ continue;
303
+ }
304
+ if (!existing.lastmod && entry.lastmod) {
305
+ deduped.set(entry.path, entry);
306
+ }
307
+ }
308
+ return Array.from(deduped.values());
309
+ }
310
+ async function renderDirectoryListing(store, requestPath, siteConfig, searchEnabled) {
116
311
  const directoryPath = requestPath === '/' ? '' : requestPath.slice(1).replace(/\/$/, '');
117
312
  const entries = await store.listDirectory(directoryPath);
118
313
  if (entries === null) {
@@ -135,6 +330,9 @@ async function renderDirectoryListing(store, requestPath, siteConfig) {
135
330
  body: renderDocument({
136
331
  siteTitle: siteConfig.siteTitle,
137
332
  siteDescription: siteConfig.siteDescription,
333
+ siteUrl: siteConfig.siteUrl,
334
+ favicon: siteConfig.favicon,
335
+ logo: siteConfig.logo,
138
336
  title: getDirectoryTitle(requestPath),
139
337
  body,
140
338
  showSummary: false,
@@ -142,7 +340,13 @@ async function renderDirectoryListing(store, requestPath, siteConfig) {
142
340
  theme: siteConfig.theme,
143
341
  template: siteConfig.template,
144
342
  topNav: navigation.items,
343
+ footerNav: siteConfig.footerNav,
344
+ footerText: siteConfig.footerText,
345
+ socialLinks: siteConfig.socialLinks,
145
346
  stylesheetContent: siteConfig.stylesheetContent,
347
+ canonicalPath: requestPath,
348
+ alternateMarkdownPath: getMarkdownRequestPathForContentPath(getDirectoryIndexContentPathForRequestPath(requestPath)),
349
+ searchEnabled,
146
350
  }),
147
351
  };
148
352
  }
@@ -167,6 +371,11 @@ function getDirectoryEntryLabel(entry) {
167
371
  function getDirectoryTitle(requestPath) {
168
372
  return requestPath === '/' ? 'Index' : requestPath;
169
373
  }
374
+ function getDirectoryIndexContentPathForRequestPath(requestPath) {
375
+ return requestPath === '/'
376
+ ? 'index.md'
377
+ : `${requestPath.slice(1).replace(/\/$/, '')}/index.md`;
378
+ }
170
379
  async function tryRenderAlternateDirectoryIndex(store, requestPath, options) {
171
380
  const directoryPath = requestPath === '/' ? '' : requestPath.slice(1).replace(/\/$/, '');
172
381
  for (const candidatePath of getDirectoryIndexCandidates(directoryPath)) {
@@ -187,9 +396,20 @@ async function tryRenderAlternateDirectoryIndex(store, requestPath, options) {
187
396
  : isRootHomeRequest(requestPath) && navigation.items.length > 0
188
397
  ? stripManagedIndexLinks(entry.text, new Set(navigation.items.map((item) => item.href)))
189
398
  : entry.text;
190
- const renderedParsed = renderedBody === entry.text
399
+ const catalogEntries = options.siteConfig.template === 'catalog'
400
+ ? extractManagedIndexEntries(renderedBody)
401
+ : [];
402
+ const catalogFragmentRequest = getCatalogFragmentRequest(options.searchParams);
403
+ if (catalogFragmentRequest !== null &&
404
+ options.siteConfig.template === 'catalog') {
405
+ return renderCatalogPostsFragment(catalogEntries, catalogFragmentRequest);
406
+ }
407
+ const documentBody = options.siteConfig.template === 'catalog'
408
+ ? stripManagedIndexBlock(renderedBody)
409
+ : renderedBody;
410
+ const renderedParsed = documentBody === entry.text
191
411
  ? parsed
192
- : await parseMarkdownDocument(candidatePath, renderedBody);
412
+ : await parseMarkdownDocument(candidatePath, documentBody);
193
413
  return {
194
414
  status: 200,
195
415
  headers: {
@@ -198,24 +418,188 @@ async function tryRenderAlternateDirectoryIndex(store, requestPath, options) {
198
418
  body: renderDocument({
199
419
  siteTitle: options.siteConfig.siteTitle,
200
420
  siteDescription: options.siteConfig.siteDescription,
421
+ siteUrl: options.siteConfig.siteUrl,
422
+ favicon: options.siteConfig.favicon,
423
+ logo: options.siteConfig.logo,
201
424
  title: getDocumentTitle(parsed),
202
425
  body: renderedParsed.html,
203
- summary: options.siteConfig.showSummary === false ? undefined : parsed.meta.summary,
426
+ summary: options.siteConfig.showSummary === false
427
+ ? undefined
428
+ : getDocumentSummary(parsed.meta, parsed.body),
204
429
  date: options.siteConfig.showDate === false ? undefined : parsed.meta.date,
205
430
  showSummary: options.siteConfig.showSummary,
206
431
  showDate: options.siteConfig.showDate,
207
432
  theme: options.siteConfig.theme,
208
433
  template: options.siteConfig.template,
209
434
  topNav: navigation.items,
435
+ footerNav: options.siteConfig.footerNav,
436
+ footerText: options.siteConfig.footerText,
437
+ socialLinks: options.siteConfig.socialLinks,
438
+ editLinkHref: getEditLinkHref(options.siteConfig, candidatePath),
210
439
  stylesheetContent: options.siteConfig.stylesheetContent,
440
+ canonicalPath: requestPath,
441
+ alternateMarkdownPath: getMarkdownRequestPathForContentPath(candidatePath),
442
+ catalogEntries,
443
+ catalogRequestPath: requestPath,
444
+ catalogInitialPostCount: options.siteConfig.catalogInitialPostCount,
445
+ catalogLoadMoreStep: options.siteConfig.catalogLoadMoreStep,
446
+ searchEnabled: options.searchApi !== undefined,
211
447
  }),
212
448
  };
213
449
  }
214
450
  return null;
215
451
  }
452
+ async function tryServeAlternateDirectoryMarkdown(store, resolved, options, negotiatedMarkdown) {
453
+ if (!negotiatedMarkdown || resolved.kind !== 'html' || !resolved.sourcePath) {
454
+ return null;
455
+ }
456
+ if (!resolved.requestPath.endsWith('/')) {
457
+ return null;
458
+ }
459
+ const directoryPath = path.posix.dirname(resolved.sourcePath);
460
+ for (const candidatePath of getDirectoryIndexCandidates(directoryPath === '.' ? '' : directoryPath)) {
461
+ if (candidatePath === resolved.sourcePath) {
462
+ continue;
463
+ }
464
+ const entry = await store.get(candidatePath);
465
+ if (entry === null || entry.kind !== 'text' || entry.text === undefined) {
466
+ continue;
467
+ }
468
+ const parsed = await parseMarkdownDocument(candidatePath, entry.text);
469
+ if (parsed.meta.draft === true && options.draftMode === 'exclude') {
470
+ return notFound();
471
+ }
472
+ return {
473
+ status: 200,
474
+ headers: withVaryAcceptIfNeeded({
475
+ 'content-type': entry.mediaType,
476
+ }, true),
477
+ body: entry.text,
478
+ };
479
+ }
480
+ return null;
481
+ }
482
+ async function tryRedirectAlternateDirectoryMarkdown(store, resolved, options) {
483
+ if (resolved.kind !== 'markdown' || !resolved.sourcePath) {
484
+ return null;
485
+ }
486
+ const basename = path.posix.basename(resolved.sourcePath);
487
+ if (basename !== 'index.md' && basename !== 'README.md') {
488
+ return null;
489
+ }
490
+ const directoryPath = path.posix.dirname(resolved.sourcePath);
491
+ for (const candidatePath of getDirectoryIndexCandidates(directoryPath === '.' ? '' : directoryPath)) {
492
+ if (candidatePath === resolved.sourcePath) {
493
+ continue;
494
+ }
495
+ const entry = await store.get(candidatePath);
496
+ if (entry === null || entry.kind !== 'text' || entry.text === undefined) {
497
+ continue;
498
+ }
499
+ const parsed = await parseMarkdownDocument(candidatePath, entry.text);
500
+ if (parsed.meta.draft === true && options.draftMode === 'exclude') {
501
+ return null;
502
+ }
503
+ return redirect(getMarkdownRequestPathForContentPath(candidatePath));
504
+ }
505
+ return null;
506
+ }
507
+ function getMarkdownRequestPathForContentPath(contentPath) {
508
+ return `/${contentPath}`;
509
+ }
216
510
  function isRootHomeRequest(requestPath) {
217
511
  return requestPath === '/';
218
512
  }
513
+ function shouldServeMarkdownForRequest(resolved, acceptHeader) {
514
+ return shouldVaryOnAccept(resolved) && acceptsMarkdown(acceptHeader);
515
+ }
516
+ function shouldVaryOnAccept(resolved) {
517
+ if (resolved.kind !== 'html') {
518
+ return false;
519
+ }
520
+ return !resolved.requestPath.endsWith('.html');
521
+ }
522
+ function acceptsMarkdown(acceptHeader) {
523
+ if (!acceptHeader) {
524
+ return false;
525
+ }
526
+ return acceptHeader
527
+ .split(',')
528
+ .map((part) => part.split(';', 1)[0]?.trim().toLowerCase())
529
+ .includes('text/markdown');
530
+ }
531
+ async function tryRedirectAlias(store, pathname, options) {
532
+ const normalizedRequestPath = normalizeRequestPath(pathname);
533
+ if (normalizedRequestPath === null) {
534
+ return null;
535
+ }
536
+ const redirectLocation = await findAliasRedirectLocation(store, '', normalizedRequestPath, options);
537
+ if (!redirectLocation || redirectLocation === normalizedRequestPath) {
538
+ return null;
539
+ }
540
+ return redirect(redirectLocation);
541
+ }
542
+ async function findAliasRedirectLocation(store, directoryPath, requestPath, options) {
543
+ const entries = await store.listDirectory(directoryPath);
544
+ if (entries === null) {
545
+ return null;
546
+ }
547
+ for (const entry of entries) {
548
+ if (entry.kind === 'directory') {
549
+ const nestedMatch = await findAliasRedirectLocation(store, entry.path, requestPath, options);
550
+ if (nestedMatch !== null) {
551
+ return nestedMatch;
552
+ }
553
+ continue;
554
+ }
555
+ if (!isMarkdownEntry(entry)) {
556
+ continue;
557
+ }
558
+ const document = await store.get(entry.path);
559
+ if (document === null || document.kind !== 'text' || document.text === undefined) {
560
+ continue;
561
+ }
562
+ const parsed = await parseMarkdownDocument(entry.path, document.text);
563
+ if (parsed.meta.draft === true && options.draftMode === 'exclude') {
564
+ continue;
565
+ }
566
+ const aliases = normalizeAliases(parsed.meta.aliases);
567
+ if (!aliases.includes(requestPath)) {
568
+ continue;
569
+ }
570
+ return getCanonicalHtmlPathForContentPath(entry.path);
571
+ }
572
+ return null;
573
+ }
574
+ function isMarkdownEntry(entry) {
575
+ return path.posix.extname(entry.name).toLowerCase() === '.md';
576
+ }
577
+ function normalizeAliases(aliases) {
578
+ if (!Array.isArray(aliases)) {
579
+ return [];
580
+ }
581
+ return aliases.flatMap((alias) => {
582
+ if (typeof alias !== 'string') {
583
+ return [];
584
+ }
585
+ const normalized = normalizeRequestPath(alias);
586
+ return normalized === null ? [] : [normalized];
587
+ });
588
+ }
589
+ function getCanonicalHtmlPathForContentPath(contentPath) {
590
+ const basename = path.posix.basename(contentPath).toLowerCase();
591
+ if (basename === 'index.md' || basename === 'readme.md') {
592
+ const directory = path.posix.dirname(contentPath);
593
+ return directory === '.' ? '/' : `/${directory}/`;
594
+ }
595
+ return `/${contentPath.slice(0, -'.md'.length)}`;
596
+ }
597
+ function getEditLinkHref(siteConfig, sourcePath) {
598
+ if (!siteConfig.editLink || !sourcePath) {
599
+ return undefined;
600
+ }
601
+ return `${siteConfig.editLink.baseUrl}${sourcePath}`;
602
+ }
219
603
  async function resolveTopNav(store, siteConfig) {
220
604
  if (siteConfig.topNav.length > 0) {
221
605
  return {
@@ -273,9 +657,7 @@ async function resolveDirectoryNav(store, entry) {
273
657
  const parsed = await parseMarkdownDocument(candidatePath, contentEntry.text);
274
658
  const shape = await inspectDirectoryShape(store, entry.path);
275
659
  return {
276
- title: typeof parsed.meta.title === 'string' && parsed.meta.title !== ''
277
- ? parsed.meta.title
278
- : entry.name,
660
+ title: getParsedDocumentTitle(parsed.meta, parsed.body, entry.name),
279
661
  type: inferDirectoryContentType(parsed.meta, shape),
280
662
  order: parsed.meta.order,
281
663
  };
@@ -289,11 +671,13 @@ async function inspectDirectoryShape(store, directoryPath) {
289
671
  const entries = await store.listDirectory(directoryPath);
290
672
  if (entries === null) {
291
673
  return {
674
+ hasSkillIndex: false,
292
675
  hasChildDirectories: false,
293
676
  hasExtraMarkdownFiles: false,
294
677
  hasAssetFiles: false,
295
678
  };
296
679
  }
680
+ let hasSkillIndex = false;
297
681
  let hasChildDirectories = false;
298
682
  let hasExtraMarkdownFiles = false;
299
683
  let hasAssetFiles = false;
@@ -307,7 +691,10 @@ async function inspectDirectoryShape(store, directoryPath) {
307
691
  }
308
692
  const extension = path.posix.extname(entry.name).toLowerCase();
309
693
  if (extension === '.md') {
310
- if (entry.name !== 'index.md' && entry.name !== 'README.md') {
694
+ if (entry.name === 'SKILL.md') {
695
+ hasSkillIndex = true;
696
+ }
697
+ else if (entry.name !== 'index.md' && entry.name !== 'README.md') {
311
698
  hasExtraMarkdownFiles = true;
312
699
  }
313
700
  continue;
@@ -315,6 +702,7 @@ async function inspectDirectoryShape(store, directoryPath) {
315
702
  hasAssetFiles = true;
316
703
  }
317
704
  return {
705
+ hasSkillIndex,
318
706
  hasChildDirectories,
319
707
  hasExtraMarkdownFiles,
320
708
  hasAssetFiles,
@@ -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,26 +5,57 @@ 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;
38
+ catalogInitialPostCount?: number;
39
+ catalogLoadMoreStep?: number;
18
40
  }
19
41
  export interface ResolvedSiteConfig {
20
42
  siteTitle: string;
21
43
  siteDescription?: string;
44
+ siteUrl?: string;
45
+ favicon?: string;
46
+ logo?: SiteLogo;
22
47
  showDate: boolean;
23
48
  showSummary: boolean;
24
49
  theme: BuiltInThemeName;
25
50
  template: TemplateName;
26
51
  topNav: SiteNavItem[];
52
+ footerNav: SiteNavItem[];
53
+ footerText?: string;
54
+ socialLinks: SiteSocialLink[];
55
+ editLink?: EditLinkConfig;
27
56
  showHomeIndex: boolean;
57
+ catalogInitialPostCount: number;
58
+ catalogLoadMoreStep: number;
28
59
  stylesheetContent?: string;
29
60
  siteTitleConfigured: boolean;
30
61
  siteDescriptionConfigured: boolean;