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.
@@ -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
  }
@@ -1,10 +1,13 @@
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;
10
+ aliases?: string[];
8
11
  [key: string]: unknown;
9
12
  }
10
13
  export interface ParsedDocument {
@@ -13,8 +16,17 @@ export interface ParsedDocument {
13
16
  html: string;
14
17
  meta: ParsedDocumentMeta;
15
18
  }
19
+ export interface ManagedIndexEntry {
20
+ kind: 'directory' | 'article';
21
+ title: string;
22
+ href: string;
23
+ detail?: string;
24
+ }
25
+ export declare function getDocumentTitle(meta: ParsedDocumentMeta, body: string, fallback: string): string;
26
+ export declare function getDocumentSummary(meta: ParsedDocumentMeta, body: string): string | undefined;
16
27
  export declare function parseMarkdownDocument(sourcePath: string, markdown: string): Promise<ParsedDocument>;
17
28
  export declare function renderMarkdown(markdown: string): Promise<string>;
18
29
  export declare function rewriteMarkdownLinksInHtml(html: string): string;
19
30
  export declare function stripManagedIndexBlock(markdown: string): string;
20
31
  export declare function stripManagedIndexLinks(markdown: string, hrefs: ReadonlySet<string>): string;
32
+ export declare function extractManagedIndexEntries(markdown: string): ManagedIndexEntry[];
@@ -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));
@@ -49,11 +58,43 @@ export function stripManagedIndexLinks(markdown, hrefs) {
49
58
  return `${start}${keptBlocks.join('\n\n')}\n${end}`;
50
59
  });
51
60
  }
61
+ export function extractManagedIndexEntries(markdown) {
62
+ const match = markdown.match(/<!-- INDEX:START -->\n?([\s\S]*?)\n?<!-- INDEX:END -->/);
63
+ if (!match) {
64
+ return [];
65
+ }
66
+ const blocks = match[1]
67
+ .trim()
68
+ .split(/\n\s*\n/g)
69
+ .map((block) => block.trim())
70
+ .filter((block) => block !== '');
71
+ const entries = [];
72
+ for (const block of blocks) {
73
+ const lines = block.split('\n');
74
+ const firstLine = lines[0]?.trim() ?? '';
75
+ const entryMatch = firstLine.match(/^- \[([^\]]+)\]\(([^)]+)\)$/);
76
+ if (!entryMatch) {
77
+ continue;
78
+ }
79
+ const rawHref = entryMatch[2];
80
+ const href = rewriteMarkdownHref(rawHref);
81
+ entries.push({
82
+ kind: href.endsWith('/') ? 'directory' : 'article',
83
+ title: entryMatch[1],
84
+ href,
85
+ detail: lines[1]?.trim() || undefined,
86
+ });
87
+ }
88
+ return entries;
89
+ }
52
90
  function normalizeMeta(data) {
53
91
  const meta = { ...data };
54
92
  if (typeof data.title === 'string') {
55
93
  meta.title = data.title;
56
94
  }
95
+ if (typeof data.name === 'string') {
96
+ meta.name = data.name;
97
+ }
57
98
  if (typeof data.date === 'string') {
58
99
  meta.date = data.date;
59
100
  }
@@ -63,6 +104,9 @@ function normalizeMeta(data) {
63
104
  if (typeof data.summary === 'string') {
64
105
  meta.summary = data.summary;
65
106
  }
107
+ if (typeof data.description === 'string') {
108
+ meta.description = data.description;
109
+ }
66
110
  if (typeof data.draft === 'boolean') {
67
111
  meta.draft = data.draft;
68
112
  }
@@ -78,6 +122,12 @@ function normalizeMeta(data) {
78
122
  meta.order = order;
79
123
  }
80
124
  }
125
+ if (typeof data.aliases === 'string' && data.aliases !== '') {
126
+ meta.aliases = [data.aliases];
127
+ }
128
+ if (Array.isArray(data.aliases)) {
129
+ meta.aliases = data.aliases.filter((value) => typeof value === 'string' && value !== '');
130
+ }
81
131
  return meta;
82
132
  }
83
133
  function rewriteMarkdownHref(href) {
@@ -108,6 +158,12 @@ function rewriteMarkdownPath(pathname) {
108
158
  if (pathname.toLowerCase() === 'readme.md') {
109
159
  return './';
110
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
+ }
111
167
  return pathname.slice(0, -'.md'.length);
112
168
  }
113
169
  function shouldPreserveHref(href) {
@@ -133,3 +189,35 @@ function normalizeManagedIndexHref(href) {
133
189
  }
134
190
  return href;
135
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,8 +1,13 @@
1
1
  import type { ContentStore } from './content-store.js';
2
2
  import type { ResolvedSiteConfig } from './site-config.js';
3
+ import type { SearchApi } from '../search.js';
3
4
  export interface HandleSiteRequestOptions {
4
5
  draftMode: 'include' | 'exclude';
5
6
  siteConfig: ResolvedSiteConfig;
7
+ acceptHeader?: string;
8
+ searchParams?: URLSearchParams;
9
+ requestUrl?: string;
10
+ searchApi?: SearchApi;
6
11
  }
7
12
  export interface SiteResponse {
8
13
  status: number;