mdorigin 0.1.1 → 0.1.3

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.
Files changed (40) hide show
  1. package/README.md +97 -9
  2. package/dist/adapters/cloudflare.d.ts +7 -1
  3. package/dist/adapters/cloudflare.js +11 -1
  4. package/dist/adapters/node.d.ts +4 -0
  5. package/dist/adapters/node.js +51 -11
  6. package/dist/cli/build-cloudflare.js +11 -4
  7. package/dist/cli/build-index.js +17 -3
  8. package/dist/cli/build-search.d.ts +1 -0
  9. package/dist/cli/build-search.js +45 -0
  10. package/dist/cli/dev.js +14 -4
  11. package/dist/cli/main.js +15 -3
  12. package/dist/cli/search.d.ts +1 -0
  13. package/dist/cli/search.js +36 -0
  14. package/dist/cloudflare.d.ts +3 -0
  15. package/dist/cloudflare.js +67 -6
  16. package/dist/core/api.d.ts +13 -0
  17. package/dist/core/api.js +160 -0
  18. package/dist/core/content-store.js +5 -0
  19. package/dist/core/content-type.d.ts +1 -0
  20. package/dist/core/content-type.js +3 -0
  21. package/dist/core/directory-index.d.ts +1 -1
  22. package/dist/core/directory-index.js +5 -1
  23. package/dist/core/extensions.d.ts +66 -0
  24. package/dist/core/extensions.js +86 -0
  25. package/dist/core/markdown.d.ts +4 -0
  26. package/dist/core/markdown.js +53 -0
  27. package/dist/core/request-handler.d.ts +6 -0
  28. package/dist/core/request-handler.js +211 -68
  29. package/dist/core/site-config.d.ts +18 -0
  30. package/dist/core/site-config.js +88 -16
  31. package/dist/html/template.d.ts +8 -0
  32. package/dist/html/template.js +228 -12
  33. package/dist/html/theme.js +254 -9
  34. package/dist/index-builder.d.ts +3 -1
  35. package/dist/index-builder.js +82 -45
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.js +1 -0
  38. package/dist/search.d.ts +59 -0
  39. package/dist/search.js +370 -0
  40. package/package.json +20 -5
@@ -1,4 +1,4 @@
1
- import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
1
+ import { mkdir, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { createCloudflareWorker } from './adapters/cloudflare.js';
4
4
  import { getMediaTypeForPath, isLikelyTextPath, normalizeContentPath, } from './core/content-store.js';
@@ -30,10 +30,14 @@ export async function buildCloudflareManifest(options) {
30
30
  base64: (await readFile(filePath)).toString('base64'),
31
31
  });
32
32
  }
33
+ const searchEntries = options.searchDir
34
+ ? await readBundleEntries(path.resolve(options.searchDir))
35
+ : undefined;
33
36
  entries.sort((left, right) => left.path.localeCompare(right.path));
34
37
  return {
35
38
  entries,
36
39
  siteConfig: options.siteConfig,
40
+ searchEntries,
37
41
  };
38
42
  }
39
43
  export async function writeCloudflareBundle(options) {
@@ -41,15 +45,41 @@ export async function writeCloudflareBundle(options) {
41
45
  const manifest = await buildCloudflareManifest({
42
46
  rootDir: options.rootDir,
43
47
  siteConfig: options.siteConfig,
48
+ searchDir: options.searchDir,
44
49
  });
45
50
  const packageImport = options.packageImport ?? 'mdorigin/cloudflare-runtime';
46
51
  const workerFile = path.join(outDir, 'worker.mjs');
52
+ const configImportPath = options.configModulePath
53
+ ? toPosixPath(path.relative(outDir, options.configModulePath))
54
+ : null;
47
55
  const workerSource = [
48
56
  `import { createCloudflareWorker } from '${packageImport}';`,
57
+ configImportPath
58
+ ? `import * as userConfigModule from '${configImportPath.startsWith('.') ? configImportPath : `./${configImportPath}`}';`
59
+ : '',
49
60
  '',
50
61
  `const manifest = ${JSON.stringify(manifest, null, 2)};`,
51
62
  '',
52
- 'export default createCloudflareWorker(manifest);',
63
+ configImportPath
64
+ ? [
65
+ 'function unwrapUserConfigModule(moduleValue) {',
66
+ ' let current = moduleValue;',
67
+ " while (current && typeof current === 'object' && 'default' in current && current.default !== undefined) {",
68
+ ' current = current.default;',
69
+ ' }',
70
+ " if (current && typeof current === 'object' && 'config' in current && current.config !== undefined) {",
71
+ ' return current.config;',
72
+ ' }',
73
+ ' return current;',
74
+ '}',
75
+ '',
76
+ 'const userConfig = unwrapUserConfigModule(userConfigModule);',
77
+ '',
78
+ ].join('\n')
79
+ : '',
80
+ configImportPath
81
+ ? 'export default createCloudflareWorker(manifest, { plugins: Array.isArray(userConfig?.plugins) ? userConfig.plugins : [] });'
82
+ : 'export default createCloudflareWorker(manifest);',
53
83
  '',
54
84
  ].join('\n');
55
85
  await mkdir(outDir, { recursive: true });
@@ -84,21 +114,52 @@ export async function initCloudflareProject(options) {
84
114
  await writeFile(configFile, wranglerConfig, 'utf8');
85
115
  return { configFile };
86
116
  }
87
- async function listFiles(directory) {
117
+ async function listFiles(directory, visitedRealDirectories = new Set()) {
118
+ const directoryRealPath = await realpath(directory);
119
+ if (visitedRealDirectories.has(directoryRealPath)) {
120
+ return [];
121
+ }
122
+ visitedRealDirectories.add(directoryRealPath);
88
123
  const entries = await readdir(directory, { withFileTypes: true });
89
124
  const files = [];
90
125
  for (const entry of entries) {
91
126
  const fullPath = path.join(directory, entry.name);
92
- if (entry.isDirectory()) {
93
- files.push(...(await listFiles(fullPath)));
127
+ const entryStats = await stat(fullPath);
128
+ if (entryStats.isDirectory()) {
129
+ files.push(...(await listFiles(fullPath, visitedRealDirectories)));
94
130
  continue;
95
131
  }
96
- if (entry.isFile()) {
132
+ if (entryStats.isFile()) {
97
133
  files.push(fullPath);
98
134
  }
99
135
  }
100
136
  return files;
101
137
  }
138
+ async function readBundleEntries(directory) {
139
+ const files = await listFiles(directory);
140
+ const entries = [];
141
+ for (const filePath of files) {
142
+ const relativePath = path.relative(directory, filePath).replaceAll(path.sep, '/');
143
+ const mediaType = getMediaTypeForPath(relativePath);
144
+ if (isLikelyTextPath(relativePath) || relativePath.endsWith('.json')) {
145
+ entries.push({
146
+ path: relativePath,
147
+ kind: 'text',
148
+ mediaType,
149
+ text: await readFile(filePath, 'utf8'),
150
+ });
151
+ continue;
152
+ }
153
+ entries.push({
154
+ path: relativePath,
155
+ kind: 'binary',
156
+ mediaType,
157
+ base64: (await readFile(filePath)).toString('base64'),
158
+ });
159
+ }
160
+ entries.sort((left, right) => left.path.localeCompare(right.path));
161
+ return entries;
162
+ }
102
163
  async function pathExists(filePath) {
103
164
  try {
104
165
  await stat(filePath);
@@ -0,0 +1,13 @@
1
+ import type { ResolvedSiteConfig } from './site-config.js';
2
+ import type { SearchApi } from '../search.js';
3
+ export interface ApiRouteOptions {
4
+ searchApi?: SearchApi;
5
+ siteConfig: ResolvedSiteConfig;
6
+ requestUrl?: string;
7
+ }
8
+ export interface ApiRouteResponse {
9
+ status: number;
10
+ headers: Record<string, string>;
11
+ body: string;
12
+ }
13
+ export declare function handleApiRoute(pathname: string, searchParams: URLSearchParams | undefined, options: ApiRouteOptions): Promise<ApiRouteResponse | null>;
@@ -0,0 +1,160 @@
1
+ export async function handleApiRoute(pathname, searchParams, options) {
2
+ if (pathname === '/api/openapi.json') {
3
+ return json(200, buildOpenApiDocument(options));
4
+ }
5
+ if (pathname === '/api/search') {
6
+ if (!options.searchApi) {
7
+ return json(404, { error: 'search is not enabled for this site' });
8
+ }
9
+ const query = searchParams?.get('q')?.trim() ?? '';
10
+ if (query === '') {
11
+ return json(400, {
12
+ error: 'missing required query parameter: q',
13
+ });
14
+ }
15
+ const topK = normalizePositiveInteger(searchParams?.get('topK')) ?? 10;
16
+ const hits = await options.searchApi.search(query, { topK });
17
+ return json(200, {
18
+ query,
19
+ topK,
20
+ count: hits.length,
21
+ hits: hits.map(serializeSearchHit),
22
+ });
23
+ }
24
+ return null;
25
+ }
26
+ function buildOpenApiDocument(options) {
27
+ return {
28
+ openapi: '3.1.0',
29
+ info: {
30
+ title: `${options.siteConfig.siteTitle} API`,
31
+ version: '1.0.0',
32
+ description: 'Search API for a mdorigin site.',
33
+ },
34
+ servers: options.siteConfig.siteUrl
35
+ ? [{ url: options.siteConfig.siteUrl }]
36
+ : options.requestUrl
37
+ ? [{ url: new URL(options.requestUrl).origin }]
38
+ : undefined,
39
+ paths: {
40
+ '/api/search': {
41
+ get: {
42
+ operationId: 'searchSite',
43
+ summary: 'Search published site content',
44
+ parameters: [
45
+ {
46
+ name: 'q',
47
+ in: 'query',
48
+ required: true,
49
+ schema: { type: 'string' },
50
+ description: 'Search query string.',
51
+ },
52
+ {
53
+ name: 'topK',
54
+ in: 'query',
55
+ required: false,
56
+ schema: { type: 'integer', minimum: 1, default: 10 },
57
+ description: 'Maximum number of hits to return.',
58
+ },
59
+ ],
60
+ responses: {
61
+ '200': {
62
+ description: 'Search results.',
63
+ content: {
64
+ 'application/json': {
65
+ schema: {
66
+ type: 'object',
67
+ required: ['query', 'topK', 'count', 'hits'],
68
+ properties: {
69
+ query: { type: 'string' },
70
+ topK: { type: 'integer' },
71
+ count: { type: 'integer' },
72
+ hits: {
73
+ type: 'array',
74
+ items: {
75
+ type: 'object',
76
+ required: [
77
+ 'docId',
78
+ 'relativePath',
79
+ 'score',
80
+ 'metadata',
81
+ 'bestMatch',
82
+ ],
83
+ properties: {
84
+ docId: { type: 'string' },
85
+ relativePath: { type: 'string' },
86
+ canonicalUrl: { type: 'string' },
87
+ title: { type: 'string' },
88
+ summary: { type: 'string' },
89
+ score: { type: 'number' },
90
+ metadata: { type: 'object', additionalProperties: true },
91
+ bestMatch: {
92
+ type: 'object',
93
+ required: [
94
+ 'chunkId',
95
+ 'excerpt',
96
+ 'headingPath',
97
+ 'charStart',
98
+ 'charEnd',
99
+ 'score',
100
+ ],
101
+ properties: {
102
+ chunkId: { type: 'number' },
103
+ excerpt: { type: 'string' },
104
+ headingPath: {
105
+ type: 'array',
106
+ items: { type: 'string' },
107
+ },
108
+ charStart: { type: 'integer' },
109
+ charEnd: { type: 'integer' },
110
+ score: { type: 'number' },
111
+ },
112
+ },
113
+ },
114
+ },
115
+ },
116
+ },
117
+ },
118
+ },
119
+ },
120
+ },
121
+ '400': {
122
+ description: 'Invalid request.',
123
+ },
124
+ '404': {
125
+ description: 'Search API not enabled.',
126
+ },
127
+ },
128
+ },
129
+ },
130
+ },
131
+ };
132
+ }
133
+ function serializeSearchHit(hit) {
134
+ return {
135
+ docId: hit.docId,
136
+ relativePath: hit.relativePath,
137
+ canonicalUrl: hit.canonicalUrl,
138
+ title: hit.title,
139
+ summary: hit.summary,
140
+ metadata: hit.metadata,
141
+ score: hit.score,
142
+ bestMatch: hit.bestMatch,
143
+ };
144
+ }
145
+ function normalizePositiveInteger(value) {
146
+ if (!value) {
147
+ return null;
148
+ }
149
+ const parsed = Number.parseInt(value, 10);
150
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
151
+ }
152
+ function json(status, body) {
153
+ return {
154
+ status,
155
+ headers: {
156
+ 'content-type': 'application/json; charset=utf-8',
157
+ },
158
+ body: JSON.stringify(body, null, 2),
159
+ };
160
+ }
@@ -54,10 +54,15 @@ const MEDIA_TYPES = new Map([
54
54
  ['.json', 'application/json; charset=utf-8'],
55
55
  ['.md', 'text/markdown; charset=utf-8'],
56
56
  ['.pdf', 'application/pdf'],
57
+ ['.py', 'text/plain; charset=utf-8'],
57
58
  ['.png', 'image/png'],
59
+ ['.sh', 'text/plain; charset=utf-8'],
58
60
  ['.svg', 'image/svg+xml'],
59
61
  ['.txt', 'text/plain; charset=utf-8'],
62
+ ['.toml', 'text/plain; charset=utf-8'],
60
63
  ['.webp', 'image/webp'],
64
+ ['.yaml', 'text/plain; charset=utf-8'],
65
+ ['.yml', 'text/plain; charset=utf-8'],
61
66
  ]);
62
67
  export function getMediaTypeForPath(contentPath) {
63
68
  const extension = path.posix.extname(contentPath).toLowerCase();
@@ -1,6 +1,7 @@
1
1
  import type { ParsedDocumentMeta } from './markdown.js';
2
2
  export type ContentType = 'page' | 'post';
3
3
  export interface DirectoryShape {
4
+ hasSkillIndex: boolean;
4
5
  hasChildDirectories: boolean;
5
6
  hasExtraMarkdownFiles: boolean;
6
7
  hasAssetFiles: boolean;
@@ -9,6 +9,9 @@ export function inferDirectoryContentType(meta, shape) {
9
9
  if (typeof meta.date === 'string' && meta.date !== '') {
10
10
  return 'post';
11
11
  }
12
+ if (shape.hasSkillIndex) {
13
+ return 'post';
14
+ }
12
15
  if (shape.hasChildDirectories || shape.hasExtraMarkdownFiles) {
13
16
  return 'page';
14
17
  }
@@ -1,2 +1,2 @@
1
- export declare const DIRECTORY_INDEX_FILENAMES: readonly ["index.md", "README.md"];
1
+ export declare const DIRECTORY_INDEX_FILENAMES: readonly ["index.md", "README.md", "SKILL.md"];
2
2
  export declare function getDirectoryIndexCandidates(directoryPath: string): string[];
@@ -1,5 +1,9 @@
1
1
  import path from 'node:path';
2
- export const DIRECTORY_INDEX_FILENAMES = ['index.md', 'README.md'];
2
+ export const DIRECTORY_INDEX_FILENAMES = [
3
+ 'index.md',
4
+ 'README.md',
5
+ 'SKILL.md',
6
+ ];
3
7
  export function getDirectoryIndexCandidates(directoryPath) {
4
8
  return DIRECTORY_INDEX_FILENAMES.map((filename) => directoryPath === '' ? filename : path.posix.join(directoryPath, filename));
5
9
  }
@@ -0,0 +1,66 @@
1
+ import type { ManagedIndexEntry } from './markdown.js';
2
+ import type { EditLinkConfig, ResolvedSiteConfig, SiteLogo, SiteNavItem, SiteSocialLink } from './site-config.js';
3
+ import type { TemplateName } from '../html/template-kind.js';
4
+ import type { BuiltInThemeName } from '../html/theme.js';
5
+ type MaybePromise<T> = T | Promise<T>;
6
+ export interface IndexTransformContext {
7
+ mode: 'build' | 'render';
8
+ directoryPath?: string;
9
+ requestPath?: string;
10
+ sourcePath?: string;
11
+ siteConfig?: ResolvedSiteConfig;
12
+ }
13
+ export interface PageRenderModel {
14
+ kind: 'document' | 'catalog';
15
+ requestPath: string;
16
+ sourcePath: string;
17
+ siteTitle: string;
18
+ siteDescription?: string;
19
+ siteUrl?: string;
20
+ favicon?: string;
21
+ socialImage?: string;
22
+ logo?: SiteLogo;
23
+ title: string;
24
+ bodyHtml: string;
25
+ summary?: string;
26
+ date?: string;
27
+ showSummary: boolean;
28
+ showDate: boolean;
29
+ theme: BuiltInThemeName;
30
+ template: TemplateName;
31
+ topNav: SiteNavItem[];
32
+ footerNav: SiteNavItem[];
33
+ footerText?: string;
34
+ socialLinks: SiteSocialLink[];
35
+ editLink?: EditLinkConfig;
36
+ editLinkHref?: string;
37
+ stylesheetContent?: string;
38
+ canonicalPath?: string;
39
+ alternateMarkdownPath?: string;
40
+ catalogEntries: ManagedIndexEntry[];
41
+ catalogRequestPath: string;
42
+ catalogInitialPostCount: number;
43
+ catalogLoadMoreStep: number;
44
+ searchEnabled: boolean;
45
+ }
46
+ export interface RenderHookContext {
47
+ page: PageRenderModel;
48
+ siteConfig: ResolvedSiteConfig;
49
+ }
50
+ export interface MdoPlugin {
51
+ name?: string;
52
+ transformIndex?(entries: ManagedIndexEntry[], context: IndexTransformContext): MaybePromise<ManagedIndexEntry[]>;
53
+ renderHeader?(context: RenderHookContext): MaybePromise<string | undefined | null>;
54
+ renderFooter?(context: RenderHookContext): MaybePromise<string | undefined | null>;
55
+ renderPage?(page: PageRenderModel, context: RenderHookContext, next: (page: PageRenderModel) => MaybePromise<string>): MaybePromise<string | undefined | null>;
56
+ transformHtml?(html: string, context: RenderHookContext): MaybePromise<string>;
57
+ }
58
+ export declare function applyIndexTransforms(entries: ManagedIndexEntry[], plugins: MdoPlugin[], context: IndexTransformContext): Promise<ManagedIndexEntry[]>;
59
+ export declare function renderHeaderOverride(plugins: MdoPlugin[], context: RenderHookContext): Promise<string | undefined>;
60
+ export declare function renderFooterOverride(plugins: MdoPlugin[], context: RenderHookContext): Promise<string | undefined>;
61
+ export declare function renderPageWithPlugins(page: PageRenderModel, plugins: MdoPlugin[], context: RenderHookContext, renderDefault: (page: PageRenderModel) => MaybePromise<string>): Promise<{
62
+ html: string;
63
+ page: PageRenderModel;
64
+ }>;
65
+ export declare function transformHtmlWithPlugins(html: string, plugins: MdoPlugin[], context: RenderHookContext): Promise<string>;
66
+ export {};
@@ -0,0 +1,86 @@
1
+ export async function applyIndexTransforms(entries, plugins, context) {
2
+ let current = [...entries];
3
+ for (const plugin of plugins) {
4
+ if (!plugin.transformIndex) {
5
+ continue;
6
+ }
7
+ current = [...(await plugin.transformIndex(current, context))];
8
+ }
9
+ return current;
10
+ }
11
+ export async function renderHeaderOverride(plugins, context) {
12
+ let rendered;
13
+ for (const plugin of plugins) {
14
+ if (!plugin.renderHeader) {
15
+ continue;
16
+ }
17
+ const value = await plugin.renderHeader(context);
18
+ if (typeof value === 'string') {
19
+ rendered = value;
20
+ }
21
+ }
22
+ return rendered;
23
+ }
24
+ export async function renderFooterOverride(plugins, context) {
25
+ let rendered;
26
+ for (const plugin of plugins) {
27
+ if (!plugin.renderFooter) {
28
+ continue;
29
+ }
30
+ const value = await plugin.renderFooter(context);
31
+ if (typeof value === 'string') {
32
+ rendered = value;
33
+ }
34
+ }
35
+ return rendered;
36
+ }
37
+ export async function renderPageWithPlugins(page, plugins, context, renderDefault) {
38
+ const renderers = plugins
39
+ .map((plugin) => plugin.renderPage)
40
+ .filter((renderPage) => typeof renderPage === 'function');
41
+ let finalPage = page;
42
+ const dispatch = async (index, currentPage) => {
43
+ const renderPage = renderers[index];
44
+ if (!renderPage) {
45
+ finalPage = currentPage;
46
+ return renderDefault(currentPage);
47
+ }
48
+ const currentContext = {
49
+ ...context,
50
+ page: currentPage,
51
+ };
52
+ let nextInvoked = false;
53
+ const next = async (nextPage) => {
54
+ nextInvoked = true;
55
+ finalPage = nextPage;
56
+ return dispatch(index + 1, nextPage);
57
+ };
58
+ const rendered = await renderPage(currentPage, currentContext, next);
59
+ if (typeof rendered === 'string') {
60
+ if (!nextInvoked) {
61
+ finalPage = currentPage;
62
+ }
63
+ return rendered;
64
+ }
65
+ return next(currentPage);
66
+ };
67
+ return {
68
+ html: await dispatch(0, page),
69
+ page: finalPage,
70
+ };
71
+ }
72
+ export async function transformHtmlWithPlugins(html, plugins, context) {
73
+ let current = html;
74
+ for (const plugin of plugins) {
75
+ if (!plugin.transformHtml) {
76
+ continue;
77
+ }
78
+ const result = await plugin.transformHtml(current, context);
79
+ if (typeof result !== 'string') {
80
+ const pluginName = plugin.name ?? 'unknown plugin';
81
+ throw new Error(`transformHtmlWithPlugins expected plugin "${pluginName}" to return a string, but got ${typeof result}`);
82
+ }
83
+ current = result;
84
+ }
85
+ return current;
86
+ }
@@ -1,7 +1,9 @@
1
1
  export interface ParsedDocumentMeta {
2
2
  title?: string;
3
+ name?: string;
3
4
  date?: string;
4
5
  summary?: string;
6
+ description?: string;
5
7
  draft?: boolean;
6
8
  type?: string;
7
9
  order?: number;
@@ -20,6 +22,8 @@ export interface ManagedIndexEntry {
20
22
  href: string;
21
23
  detail?: string;
22
24
  }
25
+ export declare function getDocumentTitle(meta: ParsedDocumentMeta, body: string, fallback: string): string;
26
+ export declare function getDocumentSummary(meta: ParsedDocumentMeta, body: string): string | undefined;
23
27
  export declare function parseMarkdownDocument(sourcePath: string, markdown: string): Promise<ParsedDocument>;
24
28
  export declare function renderMarkdown(markdown: string): Promise<string>;
25
29
  export declare function rewriteMarkdownLinksInHtml(html: string): string;
@@ -2,6 +2,15 @@ import matter from 'gray-matter';
2
2
  import { remark } from 'remark';
3
3
  import remarkGfm from 'remark-gfm';
4
4
  import remarkHtml from 'remark-html';
5
+ export function getDocumentTitle(meta, body, fallback) {
6
+ return (firstNonEmptyString(meta.title, meta.name) ??
7
+ extractFirstHeading(body) ??
8
+ fallback);
9
+ }
10
+ export function getDocumentSummary(meta, body) {
11
+ return (firstNonEmptyString(meta.summary, meta.description) ??
12
+ extractFirstParagraph(body));
13
+ }
5
14
  export async function parseMarkdownDocument(sourcePath, markdown) {
6
15
  const parsed = matter(markdown);
7
16
  const html = rewriteMarkdownLinksInHtml(await renderMarkdown(parsed.content));
@@ -83,6 +92,9 @@ function normalizeMeta(data) {
83
92
  if (typeof data.title === 'string') {
84
93
  meta.title = data.title;
85
94
  }
95
+ if (typeof data.name === 'string') {
96
+ meta.name = data.name;
97
+ }
86
98
  if (typeof data.date === 'string') {
87
99
  meta.date = data.date;
88
100
  }
@@ -92,6 +104,9 @@ function normalizeMeta(data) {
92
104
  if (typeof data.summary === 'string') {
93
105
  meta.summary = data.summary;
94
106
  }
107
+ if (typeof data.description === 'string') {
108
+ meta.description = data.description;
109
+ }
95
110
  if (typeof data.draft === 'boolean') {
96
111
  meta.draft = data.draft;
97
112
  }
@@ -143,6 +158,12 @@ function rewriteMarkdownPath(pathname) {
143
158
  if (pathname.toLowerCase() === 'readme.md') {
144
159
  return './';
145
160
  }
161
+ if (pathname.toLowerCase().endsWith('/skill.md')) {
162
+ return pathname.slice(0, -'SKILL.md'.length);
163
+ }
164
+ if (pathname.toLowerCase() === 'skill.md') {
165
+ return './';
166
+ }
146
167
  return pathname.slice(0, -'.md'.length);
147
168
  }
148
169
  function shouldPreserveHref(href) {
@@ -168,3 +189,35 @@ function normalizeManagedIndexHref(href) {
168
189
  }
169
190
  return href;
170
191
  }
192
+ function extractFirstHeading(markdown) {
193
+ for (const line of markdown.split('\n')) {
194
+ const trimmed = line.trim();
195
+ if (!trimmed.startsWith('#')) {
196
+ continue;
197
+ }
198
+ const heading = trimmed.replace(/^#+\s*/, '').trim();
199
+ if (heading !== '') {
200
+ return heading;
201
+ }
202
+ }
203
+ return undefined;
204
+ }
205
+ function extractFirstParagraph(markdown) {
206
+ const paragraphs = markdown
207
+ .split(/\n\s*\n/g)
208
+ .map((paragraph) => paragraph.trim())
209
+ .filter((paragraph) => paragraph !== '');
210
+ for (const paragraph of paragraphs) {
211
+ if (paragraph.startsWith('#') ||
212
+ paragraph.startsWith('<!--') ||
213
+ paragraph.startsWith('- ') ||
214
+ paragraph.startsWith('* ')) {
215
+ continue;
216
+ }
217
+ return paragraph.replace(/\s+/g, ' ');
218
+ }
219
+ return undefined;
220
+ }
221
+ function firstNonEmptyString(...values) {
222
+ return values.find((value) => typeof value === 'string' && value !== '');
223
+ }
@@ -1,9 +1,15 @@
1
1
  import type { ContentStore } from './content-store.js';
2
+ import type { MdoPlugin } from './extensions.js';
2
3
  import type { ResolvedSiteConfig } from './site-config.js';
4
+ import type { SearchApi } from '../search.js';
3
5
  export interface HandleSiteRequestOptions {
4
6
  draftMode: 'include' | 'exclude';
5
7
  siteConfig: ResolvedSiteConfig;
6
8
  acceptHeader?: string;
9
+ searchParams?: URLSearchParams;
10
+ requestUrl?: string;
11
+ searchApi?: SearchApi;
12
+ plugins?: MdoPlugin[];
7
13
  }
8
14
  export interface SiteResponse {
9
15
  status: number;