mdorigin 0.1.7 → 0.2.0

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,12 +1,23 @@
1
- import { mkdir, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { createReadStream } from 'node:fs';
4
+ import { copyFile, mkdir, readFile, readdir, realpath, rm, stat, writeFile, } from 'node:fs/promises';
2
5
  import path from 'node:path';
3
6
  import { createCloudflareWorker } from './adapters/cloudflare.js';
4
- import { getMediaTypeForPath, isLikelyTextPath, normalizeContentPath, } from './core/content-store.js';
7
+ import { getMediaTypeForPath, isIgnoredContentName, isLikelyTextPath, normalizeContentPath, } from './core/content-store.js';
5
8
  export { createCloudflareWorker };
9
+ const DEFAULT_ASSETS_MAX_BYTES = 25 * 1024 * 1024;
10
+ const DEFAULT_ASSETS_BINDING = 'ASSETS';
11
+ const DEFAULT_R2_BINDING = 'MDORIGIN_R2';
12
+ const BUNDLE_FILE_NAME = 'bundle.json';
13
+ const R2_STATE_FILE_NAME = 'r2-sync-state.json';
6
14
  export async function buildCloudflareManifest(options) {
7
15
  const rootDir = path.resolve(options.rootDir);
8
16
  const files = await listFiles(rootDir);
9
17
  const entries = [];
18
+ const binaryMode = options.binaryMode ?? 'inline';
19
+ const assetsMaxBytes = options.assetsMaxBytes ?? DEFAULT_ASSETS_MAX_BYTES;
20
+ const r2Binding = options.r2Binding ?? DEFAULT_R2_BINDING;
10
21
  for (const filePath of files) {
11
22
  const relativePath = path.relative(rootDir, filePath).replaceAll(path.sep, '/');
12
23
  const normalizedPath = normalizeContentPath(relativePath);
@@ -23,12 +34,21 @@ export async function buildCloudflareManifest(options) {
23
34
  });
24
35
  continue;
25
36
  }
26
- entries.push({
27
- path: normalizedPath,
28
- kind: 'binary',
29
- mediaType,
30
- base64: (await readFile(filePath)).toString('base64'),
31
- });
37
+ if (binaryMode === 'inline') {
38
+ const bytes = await readFile(filePath);
39
+ entries.push({
40
+ path: normalizedPath,
41
+ kind: 'binary',
42
+ mediaType,
43
+ base64: bytes.toString('base64'),
44
+ });
45
+ continue;
46
+ }
47
+ const fileStats = await stat(filePath);
48
+ entries.push(await buildExternalBinaryEntry(filePath, normalizedPath, mediaType, fileStats.size, {
49
+ assetsMaxBytes,
50
+ r2Binding,
51
+ }));
32
52
  }
33
53
  const searchEntries = options.searchDir
34
54
  ? await readBundleEntries(path.resolve(options.searchDir))
@@ -38,17 +58,32 @@ export async function buildCloudflareManifest(options) {
38
58
  entries,
39
59
  siteConfig: options.siteConfig,
40
60
  searchEntries,
61
+ runtime: binaryMode === 'external'
62
+ ? {
63
+ binaryMode,
64
+ r2Binding,
65
+ }
66
+ : {
67
+ binaryMode,
68
+ },
41
69
  };
42
70
  }
43
71
  export async function writeCloudflareBundle(options) {
44
72
  const outDir = path.resolve(options.outDir);
73
+ const binaryMode = options.binaryMode ?? 'inline';
74
+ const assetsMaxBytes = options.assetsMaxBytes ?? DEFAULT_ASSETS_MAX_BYTES;
75
+ const r2Binding = options.r2Binding ?? DEFAULT_R2_BINDING;
45
76
  const manifest = await buildCloudflareManifest({
46
77
  rootDir: options.rootDir,
47
78
  siteConfig: options.siteConfig,
48
79
  searchDir: options.searchDir,
80
+ binaryMode,
81
+ assetsMaxBytes,
82
+ r2Binding,
49
83
  });
50
84
  const packageImport = options.packageImport ?? 'mdorigin/cloudflare-runtime';
51
85
  const workerFile = path.join(outDir, 'worker.mjs');
86
+ const bundleFile = path.join(outDir, BUNDLE_FILE_NAME);
52
87
  const configImportPath = options.configModulePath
53
88
  ? toPosixPath(path.relative(outDir, options.configModulePath))
54
89
  : null;
@@ -83,10 +118,18 @@ export async function writeCloudflareBundle(options) {
83
118
  '',
84
119
  ].join('\n');
85
120
  await mkdir(outDir, { recursive: true });
121
+ const metadata = await writeExternalBinaryStaging(path.resolve(options.rootDir), outDir, manifest, {
122
+ binaryMode,
123
+ assetsMaxBytes,
124
+ r2Binding,
125
+ siteTitle: options.siteConfig.siteTitle,
126
+ });
86
127
  await writeFile(workerFile, workerSource, 'utf8');
128
+ await writeFile(bundleFile, JSON.stringify(metadata, null, 2), 'utf8');
87
129
  return {
88
130
  manifest,
89
131
  workerFile,
132
+ bundleFile,
90
133
  };
91
134
  }
92
135
  export async function initCloudflareProject(options) {
@@ -96,8 +139,14 @@ export async function initCloudflareProject(options) {
96
139
  if (existing && !options.force) {
97
140
  throw new Error(`Refusing to overwrite ${configFile}. Re-run with --force to replace it.`);
98
141
  }
142
+ const bundleMetadata = await readCloudflareBundleMetadata(options.workerEntry);
143
+ if (bundleMetadata?.binaryMode === 'external' &&
144
+ bundleMetadata.r2Objects.length > 0 &&
145
+ !options.r2Bucket) {
146
+ throw new Error('Cloudflare bundle contains R2-backed binaries. Re-run init cloudflare with --r2-bucket <bucket-name>.');
147
+ }
99
148
  const workerName = options.workerName ??
100
- slugifyWorkerName(options.siteTitle) ??
149
+ slugifyWorkerName(bundleMetadata?.siteTitle ?? options.siteTitle) ??
101
150
  'mdorigin-site';
102
151
  const compatibilityDate = options.compatibilityDate ?? '2026-03-20';
103
152
  const wranglerConfig = [
@@ -107,13 +156,197 @@ export async function initCloudflareProject(options) {
107
156
  ` "main": ${JSON.stringify(toPosixPath(path.relative(projectDir, options.workerEntry)))},`,
108
157
  ` "compatibility_date": ${JSON.stringify(compatibilityDate)},`,
109
158
  ' "compatibility_flags": ["nodejs_compat"]',
159
+ bundleMetadata?.binaryMode === 'external' && bundleMetadata.assetsDir
160
+ ? [
161
+ ',',
162
+ ' "assets": {',
163
+ ` "directory": ${JSON.stringify(toPosixPath(path.relative(projectDir, path.join(path.dirname(options.workerEntry), bundleMetadata.assetsDir))))},`,
164
+ ` "binding": ${JSON.stringify(DEFAULT_ASSETS_BINDING)},`,
165
+ ' "run_worker_first": true',
166
+ ' }',
167
+ ].join('\n')
168
+ : '',
169
+ bundleMetadata?.binaryMode === 'external' &&
170
+ bundleMetadata.r2Objects.length > 0 &&
171
+ bundleMetadata.r2Binding &&
172
+ options.r2Bucket
173
+ ? [
174
+ ',',
175
+ ' "r2_buckets": [',
176
+ ' {',
177
+ ` "binding": ${JSON.stringify(bundleMetadata.r2Binding)},`,
178
+ ` "bucket_name": ${JSON.stringify(options.r2Bucket)}`,
179
+ ' }',
180
+ ' ]',
181
+ ].join('\n')
182
+ : '',
110
183
  '}',
111
184
  '',
112
- ].join('\n');
185
+ ]
186
+ .filter(Boolean)
187
+ .join('\n');
113
188
  await mkdir(projectDir, { recursive: true });
114
189
  await writeFile(configFile, wranglerConfig, 'utf8');
115
190
  return { configFile };
116
191
  }
192
+ export async function syncCloudflareR2(options) {
193
+ const outDir = path.resolve(options.dir);
194
+ const bundleFile = path.join(outDir, BUNDLE_FILE_NAME);
195
+ 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}.`);
198
+ }
199
+ const stateFile = path.join(outDir, R2_STATE_FILE_NAME);
200
+ const state = await readR2SyncState(stateFile);
201
+ const runCommand = options.runCommand ?? runWranglerCommand;
202
+ let uploadedCount = 0;
203
+ let skippedCount = 0;
204
+ for (const object of metadata.r2Objects) {
205
+ const stateKey = `${options.bucketName}:${object.storageKey}`;
206
+ if (!options.force && state.uploaded[stateKey]) {
207
+ skippedCount += 1;
208
+ continue;
209
+ }
210
+ const filePath = path.join(outDir, object.file);
211
+ const result = runCommand('wrangler', [
212
+ 'r2',
213
+ 'object',
214
+ 'put',
215
+ `${options.bucketName}/${object.storageKey}`,
216
+ '--file',
217
+ filePath,
218
+ '--content-type',
219
+ object.mediaType,
220
+ '--remote',
221
+ ]);
222
+ if (result.status !== 0) {
223
+ throw new Error(result.stderr || `Failed to upload R2 object ${object.storageKey}.`);
224
+ }
225
+ state.uploaded[stateKey] = {
226
+ syncedAt: new Date().toISOString(),
227
+ };
228
+ uploadedCount += 1;
229
+ }
230
+ await writeFile(stateFile, JSON.stringify(state, null, 2), 'utf8');
231
+ return {
232
+ uploadedCount,
233
+ skippedCount,
234
+ stateFile,
235
+ };
236
+ }
237
+ async function buildExternalBinaryEntry(filePath, normalizedPath, mediaType, byteSize, options) {
238
+ if (byteSize <= options.assetsMaxBytes) {
239
+ return {
240
+ path: normalizedPath,
241
+ kind: 'binary',
242
+ mediaType,
243
+ storageKind: 'assets',
244
+ storageKey: normalizedPath,
245
+ byteSize,
246
+ };
247
+ }
248
+ return {
249
+ path: normalizedPath,
250
+ kind: 'binary',
251
+ mediaType,
252
+ storageKind: 'r2',
253
+ storageKey: await buildR2StorageKey(filePath, normalizedPath),
254
+ byteSize,
255
+ };
256
+ }
257
+ async function writeExternalBinaryStaging(rootDir, outDir, manifest, options) {
258
+ const assetsDir = path.join(outDir, 'assets');
259
+ const r2Dir = path.join(outDir, 'r2');
260
+ await rm(assetsDir, { recursive: true, force: true });
261
+ await rm(r2Dir, { recursive: true, force: true });
262
+ const r2Objects = new Map();
263
+ if (options.binaryMode === 'external') {
264
+ for (const entry of manifest.entries) {
265
+ if (entry.kind !== 'binary' || !('storageKind' in entry)) {
266
+ continue;
267
+ }
268
+ const sourceFile = path.resolve(rootDir, entry.path);
269
+ if (entry.storageKind === 'assets') {
270
+ const targetFile = path.join(assetsDir, entry.storageKey);
271
+ await mkdir(path.dirname(targetFile), { recursive: true });
272
+ await copyFile(sourceFile, targetFile);
273
+ continue;
274
+ }
275
+ const relativeFile = toPosixPath(path.join('r2', entry.storageKey));
276
+ const targetFile = path.join(outDir, relativeFile);
277
+ if (!r2Objects.has(entry.storageKey)) {
278
+ await mkdir(path.dirname(targetFile), { recursive: true });
279
+ await copyFile(sourceFile, targetFile);
280
+ r2Objects.set(entry.storageKey, {
281
+ path: entry.path,
282
+ mediaType: entry.mediaType,
283
+ storageKey: entry.storageKey,
284
+ file: relativeFile,
285
+ byteSize: entry.byteSize,
286
+ });
287
+ }
288
+ }
289
+ }
290
+ return {
291
+ version: 1,
292
+ workerEntry: 'worker.mjs',
293
+ 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,
300
+ siteTitle: options.siteTitle,
301
+ r2Objects: Array.from(r2Objects.values()).sort((left, right) => left.storageKey.localeCompare(right.storageKey)),
302
+ };
303
+ }
304
+ async function readCloudflareBundleMetadata(workerEntry) {
305
+ const bundleFile = path.join(path.dirname(workerEntry), BUNDLE_FILE_NAME);
306
+ if (!(await pathExists(bundleFile))) {
307
+ return null;
308
+ }
309
+ return readBundleMetadataFile(bundleFile);
310
+ }
311
+ async function readBundleMetadataFile(bundleFile) {
312
+ return JSON.parse(await readFile(bundleFile, 'utf8'));
313
+ }
314
+ async function readR2SyncState(stateFile) {
315
+ if (!(await pathExists(stateFile))) {
316
+ return { version: 1, uploaded: {} };
317
+ }
318
+ return JSON.parse(await readFile(stateFile, 'utf8'));
319
+ }
320
+ async function buildR2StorageKey(filePath, normalizedPath) {
321
+ const extension = path.posix.extname(normalizedPath).toLowerCase();
322
+ const hash = await hashFile(filePath);
323
+ return extension ? `binary/${hash}${extension}` : `binary/${hash}`;
324
+ }
325
+ async function hashFile(filePath) {
326
+ const hash = createHash('sha256');
327
+ await new Promise((resolve, reject) => {
328
+ const stream = createReadStream(filePath);
329
+ stream.on('data', (chunk) => {
330
+ hash.update(chunk);
331
+ });
332
+ stream.on('end', () => resolve());
333
+ stream.on('error', reject);
334
+ });
335
+ return hash.digest('hex');
336
+ }
337
+ function runWranglerCommand(command, args) {
338
+ const result = spawnSync(command, args, {
339
+ encoding: 'utf8',
340
+ stdio: ['ignore', 'pipe', 'pipe'],
341
+ });
342
+ if (result.error) {
343
+ throw result.error;
344
+ }
345
+ return {
346
+ status: result.status,
347
+ stderr: result.stderr ?? '',
348
+ };
349
+ }
117
350
  async function listFiles(directory, visitedRealDirectories = new Set()) {
118
351
  const directoryRealPath = await realpath(directory);
119
352
  if (visitedRealDirectories.has(directoryRealPath)) {
@@ -123,6 +356,9 @@ async function listFiles(directory, visitedRealDirectories = new Set()) {
123
356
  const entries = await readdir(directory, { withFileTypes: true });
124
357
  const files = [];
125
358
  for (const entry of entries) {
359
+ if (isIgnoredContentName(entry.name)) {
360
+ continue;
361
+ }
126
362
  const fullPath = path.join(directory, entry.name);
127
363
  const entryStats = await stat(fullPath);
128
364
  if (entryStats.isDirectory()) {
@@ -15,6 +15,7 @@ export interface ContentStore {
15
15
  get(contentPath: string): Promise<ContentEntry | null>;
16
16
  listDirectory(contentPath: string): Promise<ContentDirectoryEntry[] | null>;
17
17
  }
18
+ export declare function isIgnoredContentName(name: string): boolean;
18
19
  export declare class MemoryContentStore implements ContentStore {
19
20
  private readonly entries;
20
21
  constructor(entries: Iterable<ContentEntry>);
@@ -1,4 +1,7 @@
1
1
  import path from 'node:path';
2
+ export function isIgnoredContentName(name) {
3
+ return name.startsWith('.');
4
+ }
2
5
  export class MemoryContentStore {
3
6
  entries;
4
7
  constructor(entries) {
@@ -23,7 +26,7 @@ export class MemoryContentStore {
23
26
  continue;
24
27
  }
25
28
  const [firstSegment, ...rest] = remainder.split('/');
26
- if (firstSegment.startsWith('.')) {
29
+ if (isIgnoredContentName(firstSegment)) {
27
30
  continue;
28
31
  }
29
32
  if (rest.length === 0) {
@@ -53,6 +56,7 @@ const MEDIA_TYPES = new Map([
53
56
  ['.js', 'text/javascript; charset=utf-8'],
54
57
  ['.json', 'application/json; charset=utf-8'],
55
58
  ['.md', 'text/markdown; charset=utf-8'],
59
+ ['.mp4', 'video/mp4'],
56
60
  ['.pdf', 'application/pdf'],
57
61
  ['.py', 'text/plain; charset=utf-8'],
58
62
  ['.png', 'image/png'],
@@ -84,6 +88,9 @@ export function normalizeContentPath(inputPath) {
84
88
  resolved.includes('/../')) {
85
89
  return null;
86
90
  }
91
+ if (resolved.split('/').some(isIgnoredContentName)) {
92
+ return null;
93
+ }
87
94
  return resolved;
88
95
  }
89
96
  export function normalizeDirectoryPath(inputPath) {
@@ -1,7 +1,5 @@
1
1
  import type { ManagedIndexEntry, ParsedDocumentMeta } from './markdown.js';
2
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
3
  type MaybePromise<T> = T | Promise<T>;
6
4
  export interface IndexTransformContext {
7
5
  mode: 'build' | 'render';
@@ -11,7 +9,7 @@ export interface IndexTransformContext {
11
9
  siteConfig?: ResolvedSiteConfig;
12
10
  }
13
11
  export interface PageRenderModel {
14
- kind: 'document' | 'catalog';
12
+ kind: 'page' | 'listing';
15
13
  requestPath: string;
16
14
  sourcePath: string;
17
15
  siteTitle: string;
@@ -27,8 +25,6 @@ export interface PageRenderModel {
27
25
  date?: string;
28
26
  showSummary: boolean;
29
27
  showDate: boolean;
30
- theme: BuiltInThemeName;
31
- template: TemplateName;
32
28
  topNav: SiteNavItem[];
33
29
  footerNav: SiteNavItem[];
34
30
  footerText?: string;
@@ -38,10 +34,10 @@ export interface PageRenderModel {
38
34
  stylesheetContent?: string;
39
35
  canonicalPath?: string;
40
36
  alternateMarkdownPath?: string;
41
- catalogEntries: ManagedIndexEntry[];
42
- catalogRequestPath: string;
43
- catalogInitialPostCount: number;
44
- catalogLoadMoreStep: number;
37
+ listingEntries: ManagedIndexEntry[];
38
+ listingRequestPath: string;
39
+ listingInitialPostCount: number;
40
+ listingLoadMoreStep: number;
45
41
  searchEnabled: boolean;
46
42
  }
47
43
  export interface RenderHookContext {
@@ -1,7 +1,9 @@
1
1
  import matter from 'gray-matter';
2
2
  import { remark } from 'remark';
3
3
  import remarkGfm from 'remark-gfm';
4
- import remarkHtml from 'remark-html';
4
+ import remarkRehype from 'remark-rehype';
5
+ import rehypeRaw from 'rehype-raw';
6
+ import rehypeStringify from 'rehype-stringify';
5
7
  export function getDocumentTitle(meta, body, fallback) {
6
8
  return (firstNonEmptyString(meta.title, meta.name) ??
7
9
  extractFirstHeading(body) ??
@@ -24,7 +26,9 @@ export async function parseMarkdownDocument(sourcePath, markdown) {
24
26
  export async function renderMarkdown(markdown) {
25
27
  const output = await remark()
26
28
  .use(remarkGfm)
27
- .use(remarkHtml)
29
+ .use(remarkRehype, { allowDangerousHtml: true })
30
+ .use(rehypeRaw)
31
+ .use(rehypeStringify, { allowDangerousHtml: true })
28
32
  .process(markdown);
29
33
  return String(output);
30
34
  }
@@ -78,15 +82,39 @@ export function extractManagedIndexEntries(markdown) {
78
82
  }
79
83
  const rawHref = entryMatch[2];
80
84
  const href = rewriteMarkdownHref(rawHref);
85
+ const explicitKind = extractManagedIndexKind(lines.slice(1));
81
86
  entries.push({
82
- kind: href.endsWith('/') ? 'directory' : 'article',
87
+ kind: explicitKind ?? (href.endsWith('/') ? 'directory' : 'article'),
83
88
  title: entryMatch[1],
84
89
  href,
85
- detail: lines[1]?.trim() || undefined,
90
+ detail: extractManagedIndexDetail(lines.slice(1)),
86
91
  });
87
92
  }
88
93
  return entries;
89
94
  }
95
+ function extractManagedIndexKind(lines) {
96
+ for (const line of lines) {
97
+ const trimmed = line.trim();
98
+ const match = trimmed.match(/^<!--\s*mdorigin:index\s+kind=(article|directory)\s*-->$/);
99
+ if (match) {
100
+ return match[1];
101
+ }
102
+ }
103
+ return undefined;
104
+ }
105
+ function extractManagedIndexDetail(lines) {
106
+ for (const line of lines) {
107
+ const trimmed = line.trim();
108
+ if (trimmed === '') {
109
+ continue;
110
+ }
111
+ if (/^<!--\s*mdorigin:index\s+kind=(article|directory)\s*-->$/.test(trimmed)) {
112
+ continue;
113
+ }
114
+ return trimmed;
115
+ }
116
+ return undefined;
117
+ }
90
118
  function normalizeMeta(data) {
91
119
  const meta = { ...data };
92
120
  if (typeof data.title === 'string') {