mdorigin 0.2.0 → 0.2.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
@@ -135,3 +135,5 @@ Runtime endpoints:
135
135
  - CLI: [`docs/site/reference/cli.md`](docs/site/reference/cli.md)
136
136
  - Search setup: [`docs/site/guides/getting-started.md`](docs/site/guides/getting-started.md#quick-start)
137
137
  - Cloudflare deployment: [`docs/site/guides/cloudflare.md`](docs/site/guides/cloudflare.md)
138
+
139
+ The docs site at <https://mdorigin.jolestar.workers.dev> is deployed automatically from `main` with GitHub Actions.
@@ -1,6 +1,6 @@
1
1
  import type { MdoPlugin } from '../core/extensions.js';
2
2
  import type { ResolvedSiteConfig } from '../core/site-config.js';
3
- import { type SearchBundleEntry } from '../search.js';
3
+ import { type ExternalSearchBundleEntry, type SearchBundleEntry } from '../search.js';
4
4
  export interface TextCloudflareManifestEntry {
5
5
  path: string;
6
6
  kind: 'text';
@@ -30,6 +30,7 @@ export interface CloudflareManifest {
30
30
  entries: CloudflareManifestEntry[];
31
31
  siteConfig?: ResolvedSiteConfig;
32
32
  searchEntries?: SearchBundleEntry[];
33
+ externalSearchEntries?: ExternalSearchBundleEntry[];
33
34
  runtime?: CloudflareBundleRuntimeConfig;
34
35
  }
35
36
  export interface CloudflareAssetsBindingLike {
@@ -1,7 +1,7 @@
1
1
  import { MemoryContentStore } from '../core/content-store.js';
2
2
  import { handleSiteRequest } from '../core/request-handler.js';
3
3
  import { resolveRequest } from '../core/router.js';
4
- import { createSearchApiFromBundle } from '../search.js';
4
+ import { createSearchApiFromBundle, createSearchApiFromExternalBundle, } from '../search.js';
5
5
  export function createCloudflareWorker(manifest, options = {}) {
6
6
  const storeIndex = new MemoryContentStore(manifest.entries.map((entry) => {
7
7
  if (entry.kind === 'text') {
@@ -26,12 +26,18 @@ export function createCloudflareWorker(manifest, options = {}) {
26
26
  bytes: decodeBase64(entry.base64 ?? ''),
27
27
  };
28
28
  }));
29
- const searchApi = manifest.searchEntries && manifest.searchEntries.length > 0
29
+ const inlineSearchApi = manifest.searchEntries && manifest.searchEntries.length > 0
30
30
  ? createSearchApiFromBundle(manifest.searchEntries)
31
31
  : undefined;
32
+ const externalSearchApis = new WeakMap();
33
+ let defaultExternalSearchApi;
32
34
  return {
33
35
  async fetch(request, env) {
34
36
  const url = new URL(request.url);
37
+ const externalSearchApi = getExternalSearchApi(manifest, env, externalSearchApis, defaultExternalSearchApi);
38
+ if (env === undefined && externalSearchApi !== undefined) {
39
+ defaultExternalSearchApi = externalSearchApi;
40
+ }
35
41
  const directBinaryResponse = await tryServeExternalBinary(manifest, request, env);
36
42
  if (directBinaryResponse !== null) {
37
43
  return directBinaryResponse;
@@ -60,7 +66,7 @@ export function createCloudflareWorker(manifest, options = {}) {
60
66
  acceptHeader: request.headers.get('accept') ?? undefined,
61
67
  searchParams: url.searchParams,
62
68
  requestUrl: request.url,
63
- searchApi,
69
+ searchApi: inlineSearchApi ?? externalSearchApi,
64
70
  plugins: options.plugins,
65
71
  });
66
72
  const headers = new Headers(siteResponse.headers);
@@ -76,6 +82,84 @@ export function createCloudflareWorker(manifest, options = {}) {
76
82
  },
77
83
  };
78
84
  }
85
+ function getExternalSearchApi(manifest, env, cache, defaultApi) {
86
+ if (!manifest.externalSearchEntries || manifest.externalSearchEntries.length === 0) {
87
+ return undefined;
88
+ }
89
+ if (!env) {
90
+ return (defaultApi ??
91
+ createSearchApiFromExternalBundle(manifest.externalSearchEntries, async (entry) => loadExternalSearchEntryResponse(entry, undefined, manifest.runtime?.r2Binding)));
92
+ }
93
+ const cached = cache.get(env);
94
+ if (cached) {
95
+ return cached;
96
+ }
97
+ const searchApi = createSearchApiFromExternalBundle(manifest.externalSearchEntries, async (entry) => loadExternalSearchEntryResponse(entry, env, manifest.runtime?.r2Binding));
98
+ cache.set(env, searchApi);
99
+ return searchApi;
100
+ }
101
+ async function loadExternalSearchEntryResponse(entry, env, r2Binding) {
102
+ if (entry.storageKind === 'assets') {
103
+ const assetsBinding = env?.ASSETS;
104
+ if (!assetsBinding) {
105
+ throw new Error(`Cloudflare ASSETS binding is required to serve search bundle file ${entry.path}.`);
106
+ }
107
+ const assetResponse = await assetsBinding.fetch(new Request(new URL(`/${entry.storageKey}`, 'https://mdorigin-search.invalid/'), {
108
+ method: 'GET',
109
+ }));
110
+ if (assetResponse.ok) {
111
+ return assetResponse;
112
+ }
113
+ return new Response('Not Found', {
114
+ status: 404,
115
+ headers: {
116
+ 'content-type': 'text/plain; charset=utf-8',
117
+ },
118
+ });
119
+ }
120
+ const bindingName = r2Binding ?? 'MDORIGIN_R2';
121
+ const bucket = env?.[bindingName];
122
+ if (!bucket) {
123
+ throw new Error(`Cloudflare R2 binding ${bindingName} is required to serve search bundle file ${entry.path}.`);
124
+ }
125
+ const object = await bucket.get(entry.storageKey);
126
+ if (!object) {
127
+ return new Response('Not Found', {
128
+ status: 404,
129
+ headers: {
130
+ 'content-type': 'text/plain; charset=utf-8',
131
+ },
132
+ });
133
+ }
134
+ const headers = new Headers({
135
+ 'content-type': entry.mediaType,
136
+ });
137
+ if (object.httpEtag) {
138
+ headers.set('etag', object.httpEtag);
139
+ }
140
+ if (object.body instanceof ReadableStream) {
141
+ return new Response(object.body, {
142
+ status: 200,
143
+ headers,
144
+ });
145
+ }
146
+ if (object.body && 'arrayBuffer' in object.body) {
147
+ return new Response(await object.body.arrayBuffer(), {
148
+ status: 200,
149
+ headers,
150
+ });
151
+ }
152
+ if (typeof object.arrayBuffer === 'function') {
153
+ return new Response(await object.arrayBuffer(), {
154
+ status: 200,
155
+ headers,
156
+ });
157
+ }
158
+ return new Response(null, {
159
+ status: 200,
160
+ headers,
161
+ });
162
+ }
79
163
  async function tryServeExternalBinary(manifest, request, env) {
80
164
  const resolved = resolveRequest(new URL(request.url).pathname);
81
165
  if (resolved.kind !== 'asset' || !resolved.sourcePath) {
@@ -5,11 +5,11 @@ import { createFileSystemContentStore } from '../adapters/node.js';
5
5
  export async function runBuildSearchCommand(rawArgs) {
6
6
  const args = parseArgs(rawArgs);
7
7
  if (args.help) {
8
- console.log('Usage: mdorigin build search --root <content-dir> [--out ./dist/search] [--embedding-backend model2vec|hashing] [--model sentence-transformers/all-MiniLM-L6-v2] [--config <config-file>]');
8
+ console.log('Usage: mdorigin build search --root <content-dir> [--out ./dist/search] [--embedding-backend model2vec|hashing] [--model sentence-transformers/all-MiniLM-L6-v2] [--config <config-file>] [--incremental]');
9
9
  return;
10
10
  }
11
11
  if (!args.root) {
12
- throw new Error('Usage: mdorigin build search --root <content-dir> [--out ./dist/search] [--embedding-backend model2vec|hashing] [--model sentence-transformers/all-MiniLM-L6-v2] [--config <config-file>]');
12
+ throw new Error('Usage: mdorigin build search --root <content-dir> [--out ./dist/search] [--embedding-backend model2vec|hashing] [--model sentence-transformers/all-MiniLM-L6-v2] [--config <config-file>] [--incremental]');
13
13
  }
14
14
  const rootDir = path.resolve(args.root);
15
15
  const store = createFileSystemContentStore(rootDir);
@@ -24,18 +24,28 @@ export async function runBuildSearchCommand(rawArgs) {
24
24
  siteConfig,
25
25
  embeddingBackend: args.embeddingBackend ?? 'model2vec',
26
26
  model: args.model,
27
+ incremental: args.incremental,
27
28
  });
28
- console.log(`search bundle written to ${result.outputDir} (${result.documentCount} documents, ${result.chunkCount} chunks)`);
29
+ console.log([
30
+ `search bundle written to ${result.outputDir} (${result.documentCount} documents, ${result.chunkCount} chunks)`,
31
+ result.incremental
32
+ ? `incremental cache: ${result.cachePath} (${result.incremental.newDocumentCount} new, ${result.incremental.changedDocumentCount} changed, ${result.incremental.unchangedDocumentCount} unchanged, ${result.incremental.removedDocumentCount} removed)`
33
+ : '',
34
+ ].filter(Boolean).join('\n'));
29
35
  }
30
36
  function parseArgs(rawArgs) {
31
37
  const parsed = {};
32
- const supportedFlags = new Set(['root', 'out', 'embedding-backend', 'model', 'config']);
38
+ const supportedFlags = new Set(['root', 'out', 'embedding-backend', 'model', 'config', 'incremental']);
33
39
  for (let index = 0; index < rawArgs.length; index += 1) {
34
40
  const arg = rawArgs[index];
35
41
  if (arg === '--help' || arg === '-h') {
36
42
  parsed.help = 'true';
37
43
  continue;
38
44
  }
45
+ if (arg === '--incremental') {
46
+ parsed.incremental = 'true';
47
+ continue;
48
+ }
39
49
  if (arg.startsWith('--')) {
40
50
  const flag = arg.slice(2);
41
51
  if (!supportedFlags.has(flag)) {
@@ -57,6 +67,7 @@ function parseArgs(rawArgs) {
57
67
  embeddingBackend: parsed['embedding-backend'],
58
68
  model: parsed.model,
59
69
  config: parsed.config,
70
+ incremental: parsed.incremental === 'true',
60
71
  help: parsed.help === 'true',
61
72
  };
62
73
  }
package/dist/cli/help.js CHANGED
@@ -2,11 +2,11 @@ export const ROOT_USAGE_LINES = [
2
2
  'Usage:',
3
3
  ' mdorigin dev --root <content-dir> [--port 3000] [--config <config-file>] [--search ./dist/search]',
4
4
  ' mdorigin build index (--root <content-dir> | --dir <content-dir>) [--config <config-file>]',
5
- ' mdorigin build search --root <content-dir> [--out ./dist/search] [--embedding-backend model2vec|hashing] [--model sentence-transformers/all-MiniLM-L6-v2] [--config <config-file>]',
5
+ ' mdorigin build search --root <content-dir> [--out ./dist/search] [--embedding-backend model2vec|hashing] [--model sentence-transformers/all-MiniLM-L6-v2] [--config <config-file>] [--incremental]',
6
6
  ' mdorigin build cloudflare --root <content-dir> [--out ./dist/cloudflare] [--config <config-file>] [--search ./dist/search] [--binary-mode inline|external] [--assets-max-bytes 26214400] [--r2-binding MDORIGIN_R2]',
7
7
  ' mdorigin init cloudflare [--dir .] [--entry ./dist/cloudflare/worker.mjs] [--name <worker-name>] [--compatibility-date 2026-03-20] [--r2-bucket <bucket-name>] [--force]',
8
8
  ' mdorigin sync cloudflare-r2 --dir ./dist/cloudflare --bucket <bucket-name> [--force]',
9
- ' mdorigin search --index <search-dir> [--top-k 10] <query>',
9
+ ' mdorigin search --index <search-dir> [--top-k 10] [--meta key=value] <query>',
10
10
  ' mdorigin version',
11
11
  '',
12
12
  'Global options:',
@@ -16,7 +16,7 @@ export const ROOT_USAGE_LINES = [
16
16
  export const BUILD_USAGE_LINES = [
17
17
  'Usage:',
18
18
  ' mdorigin build index (--root <content-dir> | --dir <content-dir>) [--config <config-file>]',
19
- ' mdorigin build search --root <content-dir> [--out ./dist/search] [--embedding-backend model2vec|hashing] [--model sentence-transformers/all-MiniLM-L6-v2] [--config <config-file>]',
19
+ ' mdorigin build search --root <content-dir> [--out ./dist/search] [--embedding-backend model2vec|hashing] [--model sentence-transformers/all-MiniLM-L6-v2] [--config <config-file>] [--incremental]',
20
20
  ' mdorigin build cloudflare --root <content-dir> [--out ./dist/cloudflare] [--config <config-file>] [--search ./dist/search] [--binary-mode inline|external] [--assets-max-bytes 26214400] [--r2-binding MDORIGIN_R2]',
21
21
  ];
22
22
  export const INIT_USAGE_LINES = [
@@ -3,23 +3,25 @@ import { searchBundle } from '../search.js';
3
3
  export async function runSearchCommand(rawArgs) {
4
4
  const args = parseArgs(rawArgs);
5
5
  if (args.help) {
6
- console.log('Usage: mdorigin search --index <search-dir> [--top-k 10] <query>');
6
+ console.log('Usage: mdorigin search --index <search-dir> [--top-k 10] [--meta key=value] <query>');
7
7
  return;
8
8
  }
9
9
  if (!args.indexDir || !args.query) {
10
- throw new Error('Usage: mdorigin search --index <search-dir> [--top-k 10] <query>');
10
+ throw new Error('Usage: mdorigin search --index <search-dir> [--top-k 10] [--meta key=value] <query>');
11
11
  }
12
12
  const hits = await searchBundle({
13
13
  indexDir: path.resolve(args.indexDir),
14
14
  query: args.query,
15
15
  topK: args.topK,
16
+ metadata: args.metadata,
16
17
  });
17
18
  console.log(JSON.stringify(hits, null, 2));
18
19
  }
19
20
  function parseArgs(rawArgs) {
20
21
  const flags = {};
22
+ const metadata = {};
21
23
  const positionals = [];
22
- const supportedFlags = new Set(['index', 'top-k']);
24
+ const supportedFlags = new Set(['index', 'top-k', 'meta']);
23
25
  for (let index = 0; index < rawArgs.length; index += 1) {
24
26
  const arg = rawArgs[index];
25
27
  if (arg === '--help' || arg === '-h') {
@@ -33,7 +35,16 @@ function parseArgs(rawArgs) {
33
35
  }
34
36
  const value = rawArgs[index + 1];
35
37
  if (value && !value.startsWith('--')) {
36
- flags[flag] = value;
38
+ if (flag === 'meta') {
39
+ const separator = value.indexOf('=');
40
+ if (separator <= 0 || separator === value.length - 1) {
41
+ throw new Error(`Invalid value for --meta: ${value}`);
42
+ }
43
+ metadata[value.slice(0, separator)] = value.slice(separator + 1);
44
+ }
45
+ else {
46
+ flags[flag] = value;
47
+ }
37
48
  index += 1;
38
49
  continue;
39
50
  }
@@ -46,6 +57,7 @@ function parseArgs(rawArgs) {
46
57
  indexDir: flags.index,
47
58
  topK: Number.isInteger(topK) && topK > 0 ? topK : undefined,
48
59
  query: positionals.join(' ').trim(),
60
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
49
61
  help: flags.help === 'true',
50
62
  };
51
63
  }
@@ -18,7 +18,7 @@ export interface WriteCloudflareBundleOptions extends BuildCloudflareManifestOpt
18
18
  configModulePath?: string;
19
19
  }
20
20
  export interface CloudflareBundleMetadata {
21
- version: 1;
21
+ version: 2;
22
22
  workerEntry: string;
23
23
  binaryMode: CloudflareBinaryMode;
24
24
  assetsMaxBytes?: number;
@@ -26,9 +26,11 @@ export interface CloudflareBundleMetadata {
26
26
  r2Dir?: string;
27
27
  r2Binding?: string;
28
28
  siteTitle?: string;
29
- r2Objects: Array<{
29
+ stagedObjects: Array<{
30
+ kind: 'binary' | 'search';
30
31
  path: string;
31
32
  mediaType: string;
33
+ storageKind: 'assets' | 'r2';
32
34
  storageKey: string;
33
35
  file: string;
34
36
  byteSize: number;
@@ -11,6 +11,7 @@ const DEFAULT_ASSETS_BINDING = 'ASSETS';
11
11
  const DEFAULT_R2_BINDING = 'MDORIGIN_R2';
12
12
  const BUNDLE_FILE_NAME = 'bundle.json';
13
13
  const R2_STATE_FILE_NAME = 'r2-sync-state.json';
14
+ const SEARCH_ASSETS_PREFIX = '__mdorigin/search';
14
15
  export async function buildCloudflareManifest(options) {
15
16
  const rootDir = path.resolve(options.rootDir);
16
17
  const files = await listFiles(rootDir);
@@ -50,15 +51,15 @@ export async function buildCloudflareManifest(options) {
50
51
  r2Binding,
51
52
  }));
52
53
  }
53
- const searchEntries = options.searchDir
54
- ? await readBundleEntries(path.resolve(options.searchDir))
54
+ const externalSearchEntries = options.searchDir
55
+ ? await buildSearchBundleEntries(path.resolve(options.searchDir), assetsMaxBytes)
55
56
  : undefined;
56
57
  entries.sort((left, right) => left.path.localeCompare(right.path));
57
58
  return {
58
59
  entries,
59
60
  siteConfig: options.siteConfig,
60
- searchEntries,
61
- runtime: binaryMode === 'external'
61
+ externalSearchEntries,
62
+ runtime: binaryMode === 'external' || (externalSearchEntries?.length ?? 0) > 0
62
63
  ? {
63
64
  binaryMode,
64
65
  r2Binding,
@@ -118,7 +119,7 @@ export async function writeCloudflareBundle(options) {
118
119
  '',
119
120
  ].join('\n');
120
121
  await mkdir(outDir, { recursive: true });
121
- const metadata = await writeExternalBinaryStaging(path.resolve(options.rootDir), outDir, manifest, {
122
+ const metadata = await writeExternalStaging(path.resolve(options.rootDir), options.searchDir ? path.resolve(options.searchDir) : undefined, outDir, manifest, {
122
123
  binaryMode,
123
124
  assetsMaxBytes,
124
125
  r2Binding,
@@ -140,10 +141,10 @@ export async function initCloudflareProject(options) {
140
141
  throw new Error(`Refusing to overwrite ${configFile}. Re-run with --force to replace it.`);
141
142
  }
142
143
  const bundleMetadata = await readCloudflareBundleMetadata(options.workerEntry);
143
- if (bundleMetadata?.binaryMode === 'external' &&
144
- bundleMetadata.r2Objects.length > 0 &&
144
+ if (bundleMetadata &&
145
+ bundleMetadata.stagedObjects.some((object) => object.storageKind === 'r2') &&
145
146
  !options.r2Bucket) {
146
- throw new Error('Cloudflare bundle contains R2-backed binaries. Re-run init cloudflare with --r2-bucket <bucket-name>.');
147
+ throw new Error('Cloudflare bundle contains R2-backed staged objects. Re-run init cloudflare with --r2-bucket <bucket-name>.');
147
148
  }
148
149
  const workerName = options.workerName ??
149
150
  slugifyWorkerName(bundleMetadata?.siteTitle ?? options.siteTitle) ??
@@ -156,7 +157,7 @@ export async function initCloudflareProject(options) {
156
157
  ` "main": ${JSON.stringify(toPosixPath(path.relative(projectDir, options.workerEntry)))},`,
157
158
  ` "compatibility_date": ${JSON.stringify(compatibilityDate)},`,
158
159
  ' "compatibility_flags": ["nodejs_compat"]',
159
- bundleMetadata?.binaryMode === 'external' && bundleMetadata.assetsDir
160
+ bundleMetadata?.assetsDir
160
161
  ? [
161
162
  ',',
162
163
  ' "assets": {',
@@ -166,8 +167,8 @@ export async function initCloudflareProject(options) {
166
167
  ' }',
167
168
  ].join('\n')
168
169
  : '',
169
- bundleMetadata?.binaryMode === 'external' &&
170
- bundleMetadata.r2Objects.length > 0 &&
170
+ bundleMetadata &&
171
+ bundleMetadata.stagedObjects.some((object) => object.storageKind === 'r2') &&
171
172
  bundleMetadata.r2Binding &&
172
173
  options.r2Bucket
173
174
  ? [
@@ -193,15 +194,16 @@ export async function syncCloudflareR2(options) {
193
194
  const outDir = path.resolve(options.dir);
194
195
  const bundleFile = path.join(outDir, BUNDLE_FILE_NAME);
195
196
  const metadata = await readBundleMetadataFile(bundleFile);
196
- if (metadata.binaryMode !== 'external' || metadata.r2Objects.length === 0) {
197
- throw new Error(`No R2-backed binaries found in ${bundleFile}.`);
197
+ const r2Objects = metadata.stagedObjects.filter((object) => object.storageKind === 'r2');
198
+ if (r2Objects.length === 0) {
199
+ throw new Error(`No R2-backed staged objects found in ${bundleFile}.`);
198
200
  }
199
201
  const stateFile = path.join(outDir, R2_STATE_FILE_NAME);
200
202
  const state = await readR2SyncState(stateFile);
201
203
  const runCommand = options.runCommand ?? runWranglerCommand;
202
204
  let uploadedCount = 0;
203
205
  let skippedCount = 0;
204
- for (const object of metadata.r2Objects) {
206
+ for (const object of r2Objects) {
205
207
  const stateKey = `${options.bucketName}:${object.storageKey}`;
206
208
  if (!options.force && state.uploaded[stateKey]) {
207
209
  skippedCount += 1;
@@ -254,12 +256,14 @@ async function buildExternalBinaryEntry(filePath, normalizedPath, mediaType, byt
254
256
  byteSize,
255
257
  };
256
258
  }
257
- async function writeExternalBinaryStaging(rootDir, outDir, manifest, options) {
259
+ async function writeExternalStaging(rootDir, searchDir, outDir, manifest, options) {
258
260
  const assetsDir = path.join(outDir, 'assets');
259
261
  const r2Dir = path.join(outDir, 'r2');
260
262
  await rm(assetsDir, { recursive: true, force: true });
261
263
  await rm(r2Dir, { recursive: true, force: true });
262
- const r2Objects = new Map();
264
+ const stagedObjects = new Map();
265
+ let hasAssets = false;
266
+ let hasR2 = false;
263
267
  if (options.binaryMode === 'external') {
264
268
  for (const entry of manifest.entries) {
265
269
  if (entry.kind !== 'binary' || !('storageKind' in entry)) {
@@ -270,16 +274,66 @@ async function writeExternalBinaryStaging(rootDir, outDir, manifest, options) {
270
274
  const targetFile = path.join(assetsDir, entry.storageKey);
271
275
  await mkdir(path.dirname(targetFile), { recursive: true });
272
276
  await copyFile(sourceFile, targetFile);
277
+ hasAssets = true;
278
+ stagedObjects.set(`assets:${entry.storageKey}`, {
279
+ kind: 'binary',
280
+ path: entry.path,
281
+ mediaType: entry.mediaType,
282
+ storageKind: 'assets',
283
+ storageKey: entry.storageKey,
284
+ file: toPosixPath(path.join('assets', entry.storageKey)),
285
+ byteSize: entry.byteSize,
286
+ });
287
+ continue;
288
+ }
289
+ const relativeFile = toPosixPath(path.join('r2', entry.storageKey));
290
+ const targetFile = path.join(outDir, relativeFile);
291
+ if (!stagedObjects.has(`r2:${entry.storageKey}`)) {
292
+ await mkdir(path.dirname(targetFile), { recursive: true });
293
+ await copyFile(sourceFile, targetFile);
294
+ hasR2 = true;
295
+ stagedObjects.set(`r2:${entry.storageKey}`, {
296
+ kind: 'binary',
297
+ path: entry.path,
298
+ mediaType: entry.mediaType,
299
+ storageKind: 'r2',
300
+ storageKey: entry.storageKey,
301
+ file: relativeFile,
302
+ byteSize: entry.byteSize,
303
+ });
304
+ }
305
+ }
306
+ }
307
+ if (searchDir && manifest.externalSearchEntries) {
308
+ for (const entry of manifest.externalSearchEntries) {
309
+ const sourceFile = path.join(searchDir, entry.path);
310
+ if (entry.storageKind === 'assets') {
311
+ const targetFile = path.join(assetsDir, entry.storageKey);
312
+ await mkdir(path.dirname(targetFile), { recursive: true });
313
+ await copyFile(sourceFile, targetFile);
314
+ hasAssets = true;
315
+ stagedObjects.set(`assets:${entry.storageKey}`, {
316
+ kind: 'search',
317
+ path: entry.path,
318
+ mediaType: entry.mediaType,
319
+ storageKind: 'assets',
320
+ storageKey: entry.storageKey,
321
+ file: toPosixPath(path.join('assets', entry.storageKey)),
322
+ byteSize: entry.byteSize,
323
+ });
273
324
  continue;
274
325
  }
275
326
  const relativeFile = toPosixPath(path.join('r2', entry.storageKey));
276
327
  const targetFile = path.join(outDir, relativeFile);
277
- if (!r2Objects.has(entry.storageKey)) {
328
+ if (!stagedObjects.has(`r2:${entry.storageKey}`)) {
278
329
  await mkdir(path.dirname(targetFile), { recursive: true });
279
330
  await copyFile(sourceFile, targetFile);
280
- r2Objects.set(entry.storageKey, {
331
+ hasR2 = true;
332
+ stagedObjects.set(`r2:${entry.storageKey}`, {
333
+ kind: 'search',
281
334
  path: entry.path,
282
335
  mediaType: entry.mediaType,
336
+ storageKind: 'r2',
283
337
  storageKey: entry.storageKey,
284
338
  file: relativeFile,
285
339
  byteSize: entry.byteSize,
@@ -288,17 +342,15 @@ async function writeExternalBinaryStaging(rootDir, outDir, manifest, options) {
288
342
  }
289
343
  }
290
344
  return {
291
- version: 1,
345
+ version: 2,
292
346
  workerEntry: 'worker.mjs',
293
347
  binaryMode: options.binaryMode,
294
- assetsMaxBytes: options.binaryMode === 'external' ? options.assetsMaxBytes : undefined,
295
- assetsDir: options.binaryMode === 'external' ? 'assets' : undefined,
296
- r2Dir: options.binaryMode === 'external' ? 'r2' : undefined,
297
- r2Binding: options.binaryMode === 'external' && r2Objects.size > 0
298
- ? options.r2Binding
299
- : undefined,
348
+ assetsMaxBytes: options.binaryMode === 'external' || searchDir ? options.assetsMaxBytes : undefined,
349
+ assetsDir: hasAssets ? 'assets' : undefined,
350
+ r2Dir: hasR2 ? 'r2' : undefined,
351
+ r2Binding: hasR2 ? options.r2Binding : undefined,
300
352
  siteTitle: options.siteTitle,
301
- r2Objects: Array.from(r2Objects.values()).sort((left, right) => left.storageKey.localeCompare(right.storageKey)),
353
+ stagedObjects: Array.from(stagedObjects.values()).sort((left, right) => left.storageKey.localeCompare(right.storageKey)),
302
354
  };
303
355
  }
304
356
  async function readCloudflareBundleMetadata(workerEntry) {
@@ -309,7 +361,32 @@ async function readCloudflareBundleMetadata(workerEntry) {
309
361
  return readBundleMetadataFile(bundleFile);
310
362
  }
311
363
  async function readBundleMetadataFile(bundleFile) {
312
- return JSON.parse(await readFile(bundleFile, 'utf8'));
364
+ const parsed = JSON.parse(await readFile(bundleFile, 'utf8'));
365
+ if ('stagedObjects' in parsed && Array.isArray(parsed.stagedObjects)) {
366
+ return parsed;
367
+ }
368
+ if ('r2Objects' in parsed && Array.isArray(parsed.r2Objects)) {
369
+ return {
370
+ version: 2,
371
+ workerEntry: parsed.workerEntry,
372
+ binaryMode: parsed.binaryMode,
373
+ assetsMaxBytes: parsed.assetsMaxBytes,
374
+ assetsDir: parsed.assetsDir,
375
+ r2Dir: parsed.r2Dir,
376
+ r2Binding: parsed.r2Binding,
377
+ siteTitle: parsed.siteTitle,
378
+ stagedObjects: parsed.r2Objects.map((object) => ({
379
+ kind: 'binary',
380
+ path: object.path,
381
+ mediaType: object.mediaType,
382
+ storageKind: 'r2',
383
+ storageKey: object.storageKey,
384
+ file: object.file,
385
+ byteSize: object.byteSize,
386
+ })),
387
+ };
388
+ }
389
+ throw new Error(`Bundle metadata in ${bundleFile} is not supported. Rebuild the Cloudflare bundle with the current mdorigin CLI.`);
313
390
  }
314
391
  async function readR2SyncState(stateFile) {
315
392
  if (!(await pathExists(stateFile))) {
@@ -371,31 +448,39 @@ async function listFiles(directory, visitedRealDirectories = new Set()) {
371
448
  }
372
449
  return files;
373
450
  }
374
- async function readBundleEntries(directory) {
451
+ async function buildSearchBundleEntries(directory, assetsMaxBytes) {
375
452
  const files = await listFiles(directory);
376
453
  const entries = [];
377
454
  for (const filePath of files) {
378
455
  const relativePath = path.relative(directory, filePath).replaceAll(path.sep, '/');
379
456
  const mediaType = getMediaTypeForPath(relativePath);
380
- if (isLikelyTextPath(relativePath) || relativePath.endsWith('.json')) {
457
+ const fileStats = await stat(filePath);
458
+ if (fileStats.size <= assetsMaxBytes) {
381
459
  entries.push({
382
460
  path: relativePath,
383
- kind: 'text',
384
461
  mediaType,
385
- text: await readFile(filePath, 'utf8'),
462
+ storageKind: 'assets',
463
+ storageKey: `${SEARCH_ASSETS_PREFIX}/${relativePath}`,
464
+ byteSize: fileStats.size,
386
465
  });
387
466
  continue;
388
467
  }
389
468
  entries.push({
390
469
  path: relativePath,
391
- kind: 'binary',
392
470
  mediaType,
393
- base64: (await readFile(filePath)).toString('base64'),
471
+ storageKind: 'r2',
472
+ storageKey: await buildSearchStorageKey(filePath, relativePath),
473
+ byteSize: fileStats.size,
394
474
  });
395
475
  }
396
476
  entries.sort((left, right) => left.path.localeCompare(right.path));
397
477
  return entries;
398
478
  }
479
+ async function buildSearchStorageKey(filePath, relativePath) {
480
+ const extension = path.posix.extname(relativePath).toLowerCase();
481
+ const hash = await hashFile(filePath);
482
+ return extension ? `search/${hash}${extension}` : `search/${hash}`;
483
+ }
399
484
  async function pathExists(filePath) {
400
485
  try {
401
486
  await stat(filePath);
package/dist/core/api.js CHANGED
@@ -13,10 +13,15 @@ export async function handleApiRoute(pathname, searchParams, options) {
13
13
  });
14
14
  }
15
15
  const topK = normalizePositiveInteger(searchParams?.get('topK')) ?? 10;
16
- const hits = await options.searchApi.search(query, { topK });
16
+ const metadata = readSearchMetadataFilters(searchParams);
17
+ const hits = await options.searchApi.search(query, {
18
+ topK,
19
+ metadata,
20
+ });
17
21
  return json(200, {
18
22
  query,
19
23
  topK,
24
+ metadata,
20
25
  count: hits.length,
21
26
  hits: hits.map(serializeSearchHit),
22
27
  });
@@ -56,6 +61,13 @@ function buildOpenApiDocument(options) {
56
61
  schema: { type: 'integer', minimum: 1, default: 10 },
57
62
  description: 'Maximum number of hits to return.',
58
63
  },
64
+ {
65
+ name: 'meta.<field>',
66
+ in: 'query',
67
+ required: false,
68
+ schema: { type: 'string' },
69
+ description: 'Exact-match metadata filter. Use query parameters such as meta.type=post or meta.section=guides.',
70
+ },
59
71
  ],
60
72
  responses: {
61
73
  '200': {
@@ -68,6 +80,10 @@ function buildOpenApiDocument(options) {
68
80
  properties: {
69
81
  query: { type: 'string' },
70
82
  topK: { type: 'integer' },
83
+ metadata: {
84
+ type: 'object',
85
+ additionalProperties: { type: 'string' },
86
+ },
71
87
  count: { type: 'integer' },
72
88
  hits: {
73
89
  type: 'array',
@@ -130,6 +146,23 @@ function buildOpenApiDocument(options) {
130
146
  },
131
147
  };
132
148
  }
149
+ function readSearchMetadataFilters(searchParams) {
150
+ if (!searchParams) {
151
+ return undefined;
152
+ }
153
+ const metadata = {};
154
+ for (const [key, value] of searchParams.entries()) {
155
+ if (!key.startsWith('meta.') || value.trim() === '') {
156
+ continue;
157
+ }
158
+ const metadataKey = key.slice('meta.'.length).trim();
159
+ if (metadataKey === '') {
160
+ continue;
161
+ }
162
+ metadata[metadataKey] = value;
163
+ }
164
+ return Object.keys(metadata).length > 0 ? metadata : undefined;
165
+ }
133
166
  function serializeSearchHit(hit) {
134
167
  return {
135
168
  docId: hit.docId,
@@ -28,5 +28,6 @@ export declare function parseMarkdownDocument(sourcePath: string, markdown: stri
28
28
  export declare function renderMarkdown(markdown: string): Promise<string>;
29
29
  export declare function rewriteMarkdownLinksInHtml(html: string): string;
30
30
  export declare function stripManagedIndexBlock(markdown: string): string;
31
+ export declare function stripMachineOnlyMarkdownComments(markdown: string): string;
31
32
  export declare function stripManagedIndexLinks(markdown: string, hrefs: ReadonlySet<string>): string;
32
33
  export declare function extractManagedIndexEntries(markdown: string): ManagedIndexEntry[];
@@ -38,6 +38,9 @@ export function rewriteMarkdownLinksInHtml(html) {
38
38
  export function stripManagedIndexBlock(markdown) {
39
39
  return markdown.replace(/\n?<!-- INDEX:START -->[\s\S]*?<!-- INDEX:END -->\n?/g, '\n').trimEnd();
40
40
  }
41
+ export function stripMachineOnlyMarkdownComments(markdown) {
42
+ return markdown.replace(/<!--\s*mdorigin:[\s\S]*?-->/g, '');
43
+ }
41
44
  export function stripManagedIndexLinks(markdown, hrefs) {
42
45
  if (hrefs.size === 0) {
43
46
  return markdown;
package/dist/search.d.ts CHANGED
@@ -19,11 +19,13 @@ export interface SearchHit {
19
19
  score: number;
20
20
  };
21
21
  }
22
+ export interface SearchQueryOptions {
23
+ topK?: number;
24
+ relativePathPrefix?: string;
25
+ metadata?: Record<string, string>;
26
+ }
22
27
  export interface SearchApi {
23
- search(query: string, options?: {
24
- topK?: number;
25
- relativePathPrefix?: string;
26
- }): Promise<SearchHit[]>;
28
+ search(query: string, options?: SearchQueryOptions): Promise<SearchHit[]>;
27
29
  }
28
30
  export interface SearchBundleEntry {
29
31
  path: string;
@@ -32,6 +34,13 @@ export interface SearchBundleEntry {
32
34
  text?: string;
33
35
  base64?: string;
34
36
  }
37
+ export interface ExternalSearchBundleEntry {
38
+ path: string;
39
+ mediaType: string;
40
+ storageKind: 'assets' | 'r2';
41
+ storageKey: string;
42
+ byteSize: number;
43
+ }
35
44
  export interface BuildSearchBundleOptions {
36
45
  rootDir: string;
37
46
  outDir: string;
@@ -39,21 +48,35 @@ export interface BuildSearchBundleOptions {
39
48
  draftMode?: 'include' | 'exclude';
40
49
  embeddingBackend?: 'hashing' | 'model2vec';
41
50
  model?: string;
51
+ incremental?: boolean;
52
+ cachePath?: string;
42
53
  }
43
54
  export interface BuildSearchBundleResult {
44
55
  outputDir: string;
45
56
  documentCount: number;
46
57
  chunkCount: number;
47
58
  vectorDimensions: number;
59
+ cachePath?: string;
60
+ incremental?: {
61
+ scannedDocumentCount: number;
62
+ newDocumentCount: number;
63
+ changedDocumentCount: number;
64
+ unchangedDocumentCount: number;
65
+ removedDocumentCount: number;
66
+ activeDocumentCount: number;
67
+ activeChunkCount: number;
68
+ };
48
69
  }
49
70
  export interface SearchBundleOptions {
50
71
  indexDir: string;
51
72
  query: string;
52
73
  topK?: number;
53
74
  relativePathPrefix?: string;
75
+ metadata?: Record<string, string>;
54
76
  }
55
77
  export declare function buildSearchBundle(options: BuildSearchBundleOptions): Promise<BuildSearchBundleResult>;
56
78
  export declare function searchBundle(options: SearchBundleOptions): Promise<SearchHit[]>;
57
79
  export declare function createSearchApiFromDirectory(indexDir: string): Promise<SearchApi>;
58
80
  export declare function createSearchApiFromBundle(bundleEntries: SearchBundleEntry[]): SearchApi;
81
+ export declare function createSearchApiFromExternalBundle(bundleEntries: ExternalSearchBundleEntry[], loadResponse: (entry: ExternalSearchBundleEntry) => Promise<Response>): SearchApi;
59
82
  export {};
package/dist/search.js CHANGED
@@ -1,60 +1,100 @@
1
- import { readdir, readFile, realpath, stat } from 'node:fs/promises';
1
+ import { rmSync } from 'node:fs';
2
+ import { mkdtemp, mkdir, readdir, readFile, realpath, stat, writeFile, } from 'node:fs/promises';
3
+ import os from 'node:os';
2
4
  import path from 'node:path';
3
5
  import { inferDirectoryContentType } from './core/content-type.js';
4
6
  import { getDirectoryIndexCandidates } from './core/directory-index.js';
5
- import { getDocumentSummary, getDocumentTitle, parseMarkdownDocument, } from './core/markdown.js';
7
+ import { getDocumentSummary, getDocumentTitle, parseMarkdownDocument, stripMachineOnlyMarkdownComments, stripManagedIndexBlock, } from './core/markdown.js';
6
8
  import { isIgnoredContentName } from './core/content-store.js';
9
+ const OVERVIEW_CONTENT_FILENAMES = new Set(['readme.md', 'index.md', 'skill.md']);
10
+ const materializedSearchBundleDirectories = new Set();
11
+ let searchBundleDirectoryCleanupRegistered = false;
7
12
  export async function buildSearchBundle(options) {
8
13
  const buildModule = await loadIndexbindBuildModule();
9
14
  const rootDir = path.resolve(options.rootDir);
15
+ const outputDir = path.resolve(options.outDir);
10
16
  const documents = await collectSearchDocuments(rootDir, options.siteConfig, {
11
17
  draftMode: options.draftMode ?? 'exclude',
12
18
  });
13
- const stats = await buildModule.buildCanonicalBundle(path.resolve(options.outDir), documents, {
19
+ const buildOptions = {
14
20
  embeddingBackend: options.embeddingBackend ?? 'model2vec',
15
21
  model: options.model,
16
22
  sourceRootId: path.basename(rootDir),
17
23
  sourceRootPath: rootDir,
18
- });
24
+ };
25
+ if (options.incremental) {
26
+ const cachePath = path.resolve(options.cachePath ?? defaultSearchBuildCachePath(outputDir));
27
+ await mkdir(path.dirname(cachePath), { recursive: true });
28
+ const removedRelativePaths = await resolveRemovedRelativePaths(cachePath, documents.map((document) => document.relativePath));
29
+ const incrementalStats = await buildModule.updateBuildCache(cachePath, documents, buildOptions, removedRelativePaths);
30
+ const stats = await buildModule.exportCanonicalBundleFromBuildCache(cachePath, outputDir);
31
+ await writeSearchBuildState(cachePath, documents.map((document) => document.relativePath));
32
+ return {
33
+ outputDir,
34
+ documentCount: stats.documentCount,
35
+ chunkCount: stats.chunkCount,
36
+ vectorDimensions: stats.vectorDimensions,
37
+ cachePath,
38
+ incremental: incrementalStats,
39
+ };
40
+ }
41
+ const stats = await buildModule.buildCanonicalBundle(outputDir, documents, buildOptions);
19
42
  return {
20
- outputDir: path.resolve(options.outDir),
43
+ outputDir,
21
44
  documentCount: stats.documentCount,
22
45
  chunkCount: stats.chunkCount,
23
46
  vectorDimensions: stats.vectorDimensions,
24
47
  };
25
48
  }
26
49
  export async function searchBundle(options) {
27
- const webModule = await loadIndexbindWebModule();
28
- const index = await webModule.openWebIndex(path.resolve(options.indexDir));
29
- return rerankSearchHits(await index.search(options.query, {
50
+ const searchContext = await openSearchContextFromDirectory(path.resolve(options.indexDir));
51
+ return rerankSearchHits(hydrateSearchHits(await searchContext.index.search(options.query, buildIndexbindSearchOptions({
30
52
  topK: options.topK ?? 10,
31
53
  relativePathPrefix: options.relativePathPrefix,
32
- }));
54
+ metadata: options.metadata,
55
+ })), searchContext.documentsById));
33
56
  }
34
57
  export async function createSearchApiFromDirectory(indexDir) {
35
- const webModule = await loadIndexbindWebModule();
36
- const index = await webModule.openWebIndex(path.resolve(indexDir));
58
+ const searchContext = await openSearchContextFromDirectory(path.resolve(indexDir));
37
59
  return {
38
60
  async search(query, options) {
39
- return rerankSearchHits(await index.search(query, {
61
+ return rerankSearchHits(hydrateSearchHits(await searchContext.index.search(query, buildIndexbindSearchOptions({
40
62
  topK: options?.topK,
41
63
  relativePathPrefix: options?.relativePathPrefix,
42
- }));
64
+ metadata: options?.metadata,
65
+ })), searchContext.documentsById));
43
66
  },
44
67
  };
45
68
  }
46
69
  export function createSearchApiFromBundle(bundleEntries) {
47
- let indexPromise = null;
70
+ let searchContextPromise = null;
71
+ return {
72
+ async search(query, options) {
73
+ if (searchContextPromise === null) {
74
+ searchContextPromise = openSearchContextFromBundle(bundleEntries, async (entry) => createInlineSearchBundleResponse(entry));
75
+ }
76
+ const searchContext = await searchContextPromise;
77
+ return rerankSearchHits(hydrateSearchHits(await searchContext.index.search(query, buildIndexbindSearchOptions({
78
+ topK: options?.topK,
79
+ relativePathPrefix: options?.relativePathPrefix,
80
+ metadata: options?.metadata,
81
+ })), searchContext.documentsById));
82
+ },
83
+ };
84
+ }
85
+ export function createSearchApiFromExternalBundle(bundleEntries, loadResponse) {
86
+ let searchContextPromise = null;
48
87
  return {
49
88
  async search(query, options) {
50
- if (indexPromise === null) {
51
- indexPromise = openWebIndexFromBundle(bundleEntries);
89
+ if (searchContextPromise === null) {
90
+ searchContextPromise = openSearchContextFromBundle(bundleEntries, loadResponse);
52
91
  }
53
- const index = await indexPromise;
54
- return rerankSearchHits(await index.search(query, {
92
+ const searchContext = await searchContextPromise;
93
+ return rerankSearchHits(hydrateSearchHits(await searchContext.index.search(query, buildIndexbindSearchOptions({
55
94
  topK: options?.topK,
56
95
  relativePathPrefix: options?.relativePathPrefix,
57
- }));
96
+ metadata: options?.metadata,
97
+ })), searchContext.documentsById));
58
98
  },
59
99
  };
60
100
  }
@@ -78,12 +118,15 @@ async function collectSearchDocuments(rootDir, siteConfig, options) {
78
118
  canonicalUrl: absoluteCanonicalUrl,
79
119
  title: getDocumentTitle(parsed.meta, parsed.body, fallbackTitleFromRelativePath(document.relativePath)),
80
120
  summary: getDocumentSummary(parsed.meta, parsed.body),
81
- content: markdown,
121
+ content: buildSearchableMarkdownBody(parsed.body),
82
122
  metadata: buildSearchMetadata(document.relativePath, canonicalPath, parsed.meta, siteConfig),
83
123
  });
84
124
  }
85
125
  return documents;
86
126
  }
127
+ function buildSearchableMarkdownBody(markdownBody) {
128
+ return stripMachineOnlyMarkdownComments(stripManagedIndexBlock(markdownBody)).trim();
129
+ }
87
130
  async function listSearchDocuments(rootDir) {
88
131
  const results = [];
89
132
  await walkDirectory(rootDir, '', new Set(), results);
@@ -197,6 +240,8 @@ function buildSearchMetadata(relativePath, canonicalPath, meta, siteConfig) {
197
240
  markdownPath: `/${relativePath}`,
198
241
  canonicalPath,
199
242
  siteTitle: siteConfig.siteTitle,
243
+ section: getSearchSection(relativePath),
244
+ isOverview: isOverviewContentPath(relativePath),
200
245
  };
201
246
  if (meta.type === 'page' || meta.type === 'post') {
202
247
  metadata.type = meta.type;
@@ -212,6 +257,17 @@ function buildSearchMetadata(relativePath, canonicalPath, meta, siteConfig) {
212
257
  }
213
258
  return metadata;
214
259
  }
260
+ function getSearchSection(relativePath) {
261
+ const directory = path.posix.dirname(relativePath.replaceAll('\\', '/'));
262
+ if (directory === '.') {
263
+ return '';
264
+ }
265
+ const [firstSegment] = directory.split('/', 1);
266
+ return firstSegment ?? '';
267
+ }
268
+ function isOverviewContentPath(relativePath) {
269
+ return OVERVIEW_CONTENT_FILENAMES.has(path.posix.basename(relativePath).toLowerCase());
270
+ }
215
271
  function fallbackTitleFromRelativePath(relativePath) {
216
272
  const baseName = path.posix.basename(relativePath);
217
273
  if (DIRECTORY_INDEX_FILENAMES_LOWER.has(baseName.toLowerCase())) {
@@ -250,6 +306,33 @@ async function pathExists(filePath) {
250
306
  throw error;
251
307
  }
252
308
  }
309
+ function defaultSearchBuildCachePath(outputDir) {
310
+ return path.join(path.dirname(outputDir), `${path.basename(outputDir)}.indexbind-cache.sqlite`);
311
+ }
312
+ function getSearchBuildStatePath(cachePath) {
313
+ return `${cachePath}.state.json`;
314
+ }
315
+ async function resolveRemovedRelativePaths(cachePath, currentRelativePaths) {
316
+ const previousRelativePaths = await readSearchBuildState(cachePath);
317
+ const current = new Set(currentRelativePaths);
318
+ return previousRelativePaths.filter((relativePath) => !current.has(relativePath));
319
+ }
320
+ async function readSearchBuildState(cachePath) {
321
+ const statePath = getSearchBuildStatePath(cachePath);
322
+ if (!(await pathExists(statePath))) {
323
+ return [];
324
+ }
325
+ const parsed = JSON.parse(await readFile(statePath, 'utf8'));
326
+ return Array.isArray(parsed.relativePaths)
327
+ ? parsed.relativePaths.filter((value) => typeof value === 'string')
328
+ : [];
329
+ }
330
+ async function writeSearchBuildState(cachePath, relativePaths) {
331
+ await writeFile(getSearchBuildStatePath(cachePath), JSON.stringify({
332
+ version: 1,
333
+ relativePaths: [...relativePaths].sort((left, right) => left.localeCompare(right)),
334
+ }, null, 2), 'utf8');
335
+ }
253
336
  function isIgnoredSkillSupportDirectory(name) {
254
337
  return (name === 'scripts' ||
255
338
  name === 'references' ||
@@ -280,8 +363,10 @@ async function loadIndexbindCloudflareModule() {
280
363
  throw new Error(`Search query requires the optional package "indexbind". Install it first, for example: npm install indexbind`, { cause: error instanceof Error ? error : undefined });
281
364
  }
282
365
  }
283
- async function openWebIndexFromBundle(bundleEntries) {
284
- const cloudflareModule = await loadIndexbindCloudflareModule();
366
+ async function openWebIndexFromVirtualBundle(bundleEntries, loadResponse) {
367
+ if (isNodeRuntime()) {
368
+ return openWebIndexFromMaterializedBundle(bundleEntries, loadResponse);
369
+ }
285
370
  const baseUrl = 'https://mdorigin-search.invalid/';
286
371
  const originalFetch = globalThis.fetch;
287
372
  const bundleMap = new Map(bundleEntries.map((entry) => [new URL(entry.path, baseUrl).toString(), entry]));
@@ -293,31 +378,209 @@ async function openWebIndexFromBundle(bundleEntries) {
293
378
  : input.url;
294
379
  const entry = bundleMap.get(requestUrl);
295
380
  if (entry) {
296
- const headers = new Headers({ 'content-type': entry.mediaType });
297
- const binaryBody = decodeBase64(entry.base64 ?? '');
298
- const body = entry.kind === 'text'
299
- ? entry.text ?? ''
300
- : new Blob([new Uint8Array(binaryBody)], {
301
- type: entry.mediaType,
302
- });
303
- return new Response(body, {
304
- status: 200,
305
- headers,
306
- });
381
+ return loadResponse(entry);
307
382
  }
308
383
  return originalFetch(input, init);
309
384
  };
310
385
  try {
311
- return await cloudflareModule.openWebIndex(new URL(baseUrl));
386
+ try {
387
+ const cloudflareModule = await loadIndexbindCloudflareModule();
388
+ return await cloudflareModule.openWebIndex(new URL(baseUrl));
389
+ }
390
+ catch (error) {
391
+ if (!isCloudflareWasmImportError(error)) {
392
+ throw error;
393
+ }
394
+ const webModule = await loadIndexbindWebModule();
395
+ return await webModule.openWebIndex(new URL(baseUrl));
396
+ }
312
397
  }
313
398
  finally {
314
399
  globalThis.fetch = originalFetch;
315
400
  }
316
401
  }
402
+ async function openSearchContextFromDirectory(indexDir) {
403
+ const webModule = await loadIndexbindWebModule();
404
+ const [index, documentsById] = await Promise.all([
405
+ webModule.openWebIndex(indexDir),
406
+ readSearchBundleDocumentsFromDirectory(indexDir),
407
+ ]);
408
+ return { index, documentsById };
409
+ }
410
+ async function openSearchContextFromBundle(bundleEntries, loadResponse) {
411
+ const [index, documentsById] = await Promise.all([
412
+ openWebIndexFromVirtualBundle(bundleEntries, loadResponse),
413
+ readSearchBundleDocumentsFromResponses(bundleEntries, loadResponse),
414
+ ]);
415
+ return { index, documentsById };
416
+ }
417
+ async function openWebIndexFromMaterializedBundle(bundleEntries, loadResponse) {
418
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'mdorigin-search-bundle-'));
419
+ registerMaterializedSearchBundleDirectory(tempDir);
420
+ for (const entry of bundleEntries) {
421
+ const response = await loadResponse(entry);
422
+ if (!response.ok) {
423
+ throw new Error(`Failed to materialize search bundle file ${entry.path}: ${response.status} ${response.statusText}`);
424
+ }
425
+ const outputPath = resolveMaterializedSearchBundlePath(tempDir, entry.path);
426
+ await mkdir(path.dirname(outputPath), { recursive: true });
427
+ if (entry.mediaType.startsWith('text/') || entry.mediaType.includes('json')) {
428
+ await writeFile(outputPath, await response.text(), 'utf8');
429
+ continue;
430
+ }
431
+ await writeFile(outputPath, new Uint8Array(await response.arrayBuffer()));
432
+ }
433
+ const webModule = await loadIndexbindWebModule();
434
+ return webModule.openWebIndex(tempDir);
435
+ }
436
+ function createInlineSearchBundleResponse(entry) {
437
+ const headers = new Headers({ 'content-type': entry.mediaType });
438
+ const binaryBody = decodeBase64(entry.base64 ?? '');
439
+ const body = entry.kind === 'text'
440
+ ? entry.text ?? ''
441
+ : new Blob([new Uint8Array(binaryBody)], {
442
+ type: entry.mediaType,
443
+ });
444
+ return new Response(body, {
445
+ status: 200,
446
+ headers,
447
+ });
448
+ }
449
+ function isCloudflareWasmImportError(error) {
450
+ const targetMessages = [
451
+ 'Unknown file extension ".wasm"',
452
+ 'WebAssembly.Module(): Argument 0 must be a buffer source',
453
+ ];
454
+ const visited = new Set();
455
+ let current = error;
456
+ while (current && typeof current === 'object' && !visited.has(current)) {
457
+ visited.add(current);
458
+ if (current instanceof Error) {
459
+ const currentMessage = current.message;
460
+ if (targetMessages.some((message) => currentMessage.includes(message))) {
461
+ return true;
462
+ }
463
+ }
464
+ const cause = current.cause;
465
+ if (!cause) {
466
+ break;
467
+ }
468
+ current = cause;
469
+ }
470
+ return false;
471
+ }
317
472
  function decodeBase64(value) {
318
473
  const decoded = atob(value);
319
474
  return Uint8Array.from(decoded, (character) => character.charCodeAt(0));
320
475
  }
476
+ function buildIndexbindSearchOptions(options) {
477
+ const normalized = {};
478
+ if (typeof options.topK === 'number' &&
479
+ Number.isFinite(options.topK) &&
480
+ Number.isInteger(options.topK) &&
481
+ options.topK > 0) {
482
+ normalized.topK = options.topK;
483
+ }
484
+ if (typeof options.relativePathPrefix === 'string' && options.relativePathPrefix !== '') {
485
+ normalized.relativePathPrefix = options.relativePathPrefix;
486
+ }
487
+ if (options.metadata && Object.keys(options.metadata).length > 0) {
488
+ normalized.metadata = options.metadata;
489
+ }
490
+ return Object.keys(normalized).length > 0 ? normalized : undefined;
491
+ }
492
+ function hydrateSearchHits(hits, documentsById) {
493
+ return hits.map((hit) => {
494
+ const document = documentsById.get(hit.docId);
495
+ if (!document) {
496
+ return hit;
497
+ }
498
+ return {
499
+ ...hit,
500
+ canonicalUrl: hit.canonicalUrl ?? document.canonicalUrl ?? undefined,
501
+ title: hit.title ?? document.title ?? undefined,
502
+ summary: hit.summary ?? document.summary ?? undefined,
503
+ metadata: Object.keys(hit.metadata).length > 0
504
+ ? hit.metadata
505
+ : document.metadata ?? hit.metadata,
506
+ };
507
+ });
508
+ }
509
+ async function readSearchBundleDocumentsFromDirectory(indexDir) {
510
+ const manifest = JSON.parse(await readFile(path.join(indexDir, 'manifest.json'), 'utf8'));
511
+ const documentsFile = manifest.files?.documents;
512
+ if (!documentsFile) {
513
+ return new Map();
514
+ }
515
+ const documents = JSON.parse(await readFile(path.join(indexDir, documentsFile), 'utf8'));
516
+ return new Map(documents.map((document) => [document.docId, document]));
517
+ }
518
+ async function readSearchBundleDocumentsFromResponses(bundleEntries, loadResponse) {
519
+ const manifestEntry = bundleEntries.find((entry) => entry.path === 'manifest.json');
520
+ if (!manifestEntry) {
521
+ return new Map();
522
+ }
523
+ const manifest = JSON.parse(await loadBundleResponseText(manifestEntry, loadResponse));
524
+ const documentsFile = manifest.files?.documents;
525
+ if (!documentsFile) {
526
+ return new Map();
527
+ }
528
+ const documentsEntry = bundleEntries.find((entry) => entry.path === documentsFile);
529
+ if (!documentsEntry) {
530
+ return new Map();
531
+ }
532
+ const documents = JSON.parse(await loadBundleResponseText(documentsEntry, loadResponse));
533
+ return new Map(documents.map((document) => [document.docId, document]));
534
+ }
535
+ async function loadBundleResponseText(entry, loadResponse) {
536
+ const response = await loadResponse(entry);
537
+ if (!response.ok) {
538
+ throw new Error(`Failed to read search bundle file ${entry.path}: ${response.status} ${response.statusText}`);
539
+ }
540
+ return response.text();
541
+ }
542
+ function isNodeRuntime() {
543
+ return (typeof process !== 'undefined' &&
544
+ typeof process.versions === 'object' &&
545
+ typeof process.versions.node === 'string');
546
+ }
547
+ function resolveMaterializedSearchBundlePath(baseDir, entryPath) {
548
+ if (path.isAbsolute(entryPath)) {
549
+ throw new Error(`Search bundle path must be relative: ${entryPath}`);
550
+ }
551
+ const normalizedEntryPath = entryPath.replaceAll('/', path.sep);
552
+ const outputPath = path.resolve(baseDir, normalizedEntryPath);
553
+ const relativeOutputPath = path.relative(baseDir, outputPath);
554
+ if (relativeOutputPath === '' ||
555
+ relativeOutputPath.startsWith(`..${path.sep}`) ||
556
+ relativeOutputPath === '..' ||
557
+ path.isAbsolute(relativeOutputPath)) {
558
+ throw new Error(`Search bundle path escapes materialized bundle directory: ${entryPath}`);
559
+ }
560
+ return outputPath;
561
+ }
562
+ function registerMaterializedSearchBundleDirectory(directoryPath) {
563
+ materializedSearchBundleDirectories.add(directoryPath);
564
+ if (searchBundleDirectoryCleanupRegistered || !isNodeRuntime()) {
565
+ return;
566
+ }
567
+ const cleanup = () => {
568
+ for (const stagedDirectory of materializedSearchBundleDirectories) {
569
+ rmSync(stagedDirectory, { recursive: true, force: true });
570
+ }
571
+ materializedSearchBundleDirectories.clear();
572
+ };
573
+ process.once('exit', cleanup);
574
+ process.once('SIGINT', () => {
575
+ cleanup();
576
+ process.exit(130);
577
+ });
578
+ process.once('SIGTERM', () => {
579
+ cleanup();
580
+ process.exit(143);
581
+ });
582
+ searchBundleDirectoryCleanupRegistered = true;
583
+ }
321
584
  function rerankSearchHits(hits) {
322
585
  const remaining = [...hits];
323
586
  const ordered = [];
@@ -360,8 +623,7 @@ function compareHits(left, right) {
360
623
  return left.relativePath.localeCompare(right.relativePath);
361
624
  }
362
625
  function isOverviewSearchHit(hit) {
363
- const baseName = path.posix.basename(hit.relativePath).toLowerCase();
364
- return baseName === 'readme.md' || baseName === 'index.md';
626
+ return OVERVIEW_CONTENT_FILENAMES.has(path.posix.basename(hit.relativePath).toLowerCase());
365
627
  }
366
628
  function getTopLevelSection(relativePath) {
367
629
  const normalized = relativePath.replaceAll('\\', '/');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdorigin",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Markdown-first publishing for humans and agents.",
6
6
  "repository": {
@@ -78,6 +78,6 @@
78
78
  "typescript": "^5.9.2"
79
79
  },
80
80
  "optionalDependencies": {
81
- "indexbind": "^0.2.1"
81
+ "indexbind": "^0.3.0"
82
82
  }
83
83
  }