mdorigin 0.1.8 → 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
  }
@@ -1,11 +1,12 @@
1
1
  import path from 'node:path';
2
+ import { isIgnoredContentName } from './content-store.js';
2
3
  import { inferDirectoryContentType } from './content-type.js';
3
4
  import { getDirectoryIndexCandidates } from './directory-index.js';
4
5
  import { extractManagedIndexEntries, getDocumentSummary, getDocumentTitle as getParsedDocumentTitle, parseMarkdownDocument, stripManagedIndexBlock, stripManagedIndexLinks, } from './markdown.js';
5
6
  import { applyIndexTransforms, renderFooterOverride, renderHeaderOverride, renderPageWithPlugins, transformHtmlWithPlugins, } from './extensions.js';
6
7
  import { handleApiRoute } from './api.js';
7
8
  import { normalizeRequestPath, resolveRequest } from './router.js';
8
- import { escapeHtml, renderCatalogArticleItems, renderDocument, } from '../html/template.js';
9
+ import { escapeHtml, renderListingArticleItems, renderDocument, } from '../html/template.js';
9
10
  export async function handleSiteRequest(store, pathname, options) {
10
11
  const plugins = options.plugins ?? [];
11
12
  const searchEnabled = options.searchApi !== undefined;
@@ -21,7 +22,7 @@ export async function handleSiteRequest(store, pathname, options) {
21
22
  return renderSitemap(store, options);
22
23
  }
23
24
  const resolved = resolveRequest(pathname);
24
- const catalogFragmentRequest = getCatalogFragmentRequest(options.searchParams);
25
+ const listingFragmentRequest = getListingFragmentRequest(options.searchParams);
25
26
  const negotiatedMarkdown = shouldServeMarkdownForRequest(resolved, options.acceptHeader);
26
27
  if (resolved.kind === 'not-found' || !resolved.sourcePath) {
27
28
  const aliasRedirect = await tryRedirectAlias(store, pathname, options);
@@ -82,21 +83,16 @@ export async function handleSiteRequest(store, pathname, options) {
82
83
  : isRootHomeRequest(resolved.requestPath) && navigation.items.length > 0
83
84
  ? stripManagedIndexLinks(entry.text, new Set(navigation.items.map((item) => item.href)))
84
85
  : entry.text;
85
- const catalogEntries = options.siteConfig.template === 'catalog'
86
- ? await applyIndexTransforms(extractManagedIndexEntries(renderedBody), plugins, {
87
- mode: 'render',
88
- requestPath: resolved.requestPath,
89
- sourcePath: resolved.sourcePath,
90
- siteConfig: options.siteConfig,
91
- })
92
- : [];
93
- if (catalogFragmentRequest !== null &&
94
- options.siteConfig.template === 'catalog') {
95
- return renderCatalogPostsFragment(catalogEntries, catalogFragmentRequest);
96
- }
97
- const documentBody = options.siteConfig.template === 'catalog'
98
- ? stripManagedIndexBlock(renderedBody)
99
- : renderedBody;
86
+ const listingEntries = await applyIndexTransforms(extractManagedIndexEntries(renderedBody), plugins, {
87
+ mode: 'render',
88
+ requestPath: resolved.requestPath,
89
+ sourcePath: resolved.sourcePath,
90
+ siteConfig: options.siteConfig,
91
+ });
92
+ if (listingFragmentRequest !== null && listingEntries.length > 0) {
93
+ return renderListingPostsFragment(listingEntries, listingFragmentRequest);
94
+ }
95
+ const documentBody = listingEntries.length > 0 ? stripManagedIndexBlock(renderedBody) : renderedBody;
100
96
  const renderedParsed = documentBody === entry.text
101
97
  ? parsed
102
98
  : await parseMarkdownDocument(resolved.sourcePath, documentBody);
@@ -107,18 +103,19 @@ export async function handleSiteRequest(store, pathname, options) {
107
103
  renderedParsed,
108
104
  siteConfig: options.siteConfig,
109
105
  topNav: navigation.items,
110
- catalogEntries,
106
+ listingEntries,
111
107
  searchEnabled,
112
108
  plugins,
113
109
  varyOnAccept: shouldVaryOnAccept(resolved),
114
110
  });
115
111
  }
116
- function getCatalogFragmentRequest(searchParams) {
117
- if (searchParams?.get('catalog-format') !== 'posts') {
112
+ function getListingFragmentRequest(searchParams) {
113
+ const format = searchParams?.get('listing-format') ?? searchParams?.get('catalog-format');
114
+ if (format !== 'posts') {
118
115
  return null;
119
116
  }
120
- const offset = normalizeNonNegativeInteger(searchParams.get('catalog-offset'));
121
- const limit = normalizePositiveInteger(searchParams.get('catalog-limit'));
117
+ const offset = normalizeNonNegativeInteger(searchParams?.get('listing-offset') ?? searchParams?.get('catalog-offset') ?? null);
118
+ const limit = normalizePositiveInteger(searchParams?.get('listing-limit') ?? searchParams?.get('catalog-limit') ?? null);
122
119
  if (offset === null || limit === null) {
123
120
  return null;
124
121
  }
@@ -140,7 +137,7 @@ function normalizePositiveInteger(value) {
140
137
  }
141
138
  function buildPageRenderModel(options) {
142
139
  return {
143
- kind: options.siteConfig.template === 'catalog' ? 'catalog' : 'document',
140
+ kind: options.listingEntries.length > 0 ? 'listing' : 'page',
144
141
  requestPath: options.resolvedRequestPath,
145
142
  sourcePath: options.sourcePath,
146
143
  siteTitle: options.siteConfig.siteTitle,
@@ -158,8 +155,6 @@ function buildPageRenderModel(options) {
158
155
  date: options.siteConfig.showDate === false ? undefined : options.parsed.meta.date,
159
156
  showSummary: options.siteConfig.showSummary,
160
157
  showDate: options.siteConfig.showDate,
161
- theme: options.siteConfig.theme,
162
- template: options.siteConfig.template,
163
158
  topNav: options.topNav,
164
159
  footerNav: options.siteConfig.footerNav,
165
160
  footerText: options.siteConfig.footerText,
@@ -169,10 +164,10 @@ function buildPageRenderModel(options) {
169
164
  stylesheetContent: options.siteConfig.stylesheetContent,
170
165
  canonicalPath: getCanonicalHtmlPathForContentPath(options.sourcePath),
171
166
  alternateMarkdownPath: getMarkdownRequestPathForContentPath(options.sourcePath),
172
- catalogEntries: options.catalogEntries,
173
- catalogRequestPath: options.resolvedRequestPath,
174
- catalogInitialPostCount: options.siteConfig.catalogInitialPostCount,
175
- catalogLoadMoreStep: options.siteConfig.catalogLoadMoreStep,
167
+ listingEntries: options.listingEntries,
168
+ listingRequestPath: options.resolvedRequestPath,
169
+ listingInitialPostCount: options.siteConfig.listingInitialPostCount,
170
+ listingLoadMoreStep: options.siteConfig.listingLoadMoreStep,
176
171
  searchEnabled: options.searchEnabled,
177
172
  };
178
173
  }
@@ -184,7 +179,7 @@ async function renderStructuredPage(options) {
184
179
  parsed: options.parsed,
185
180
  siteConfig: options.siteConfig,
186
181
  topNav: options.topNav,
187
- catalogEntries: options.catalogEntries,
182
+ listingEntries: options.listingEntries,
188
183
  searchEnabled: options.searchEnabled,
189
184
  });
190
185
  const renderContext = {
@@ -198,7 +193,7 @@ async function renderStructuredPage(options) {
198
193
  };
199
194
  const headerHtml = await renderHeaderOverride(options.plugins, currentContext);
200
195
  const footerHtml = await renderFooterOverride(options.plugins, currentContext);
201
- return (renderDocument({
196
+ return renderDocument({
202
197
  siteTitle: currentPage.siteTitle,
203
198
  siteDescription: currentPage.siteDescription,
204
199
  siteUrl: currentPage.siteUrl,
@@ -211,8 +206,6 @@ async function renderStructuredPage(options) {
211
206
  date: currentPage.date,
212
207
  showSummary: currentPage.showSummary,
213
208
  showDate: currentPage.showDate,
214
- theme: currentPage.theme,
215
- template: currentPage.template,
216
209
  topNav: currentPage.topNav,
217
210
  footerNav: currentPage.footerNav,
218
211
  footerText: currentPage.footerText,
@@ -221,14 +214,14 @@ async function renderStructuredPage(options) {
221
214
  stylesheetContent: currentPage.stylesheetContent,
222
215
  canonicalPath: currentPage.canonicalPath,
223
216
  alternateMarkdownPath: currentPage.alternateMarkdownPath,
224
- catalogEntries: currentPage.catalogEntries,
225
- catalogRequestPath: currentPage.catalogRequestPath,
226
- catalogInitialPostCount: currentPage.catalogInitialPostCount,
227
- catalogLoadMoreStep: currentPage.catalogLoadMoreStep,
217
+ listingEntries: currentPage.listingEntries,
218
+ listingRequestPath: currentPage.listingRequestPath,
219
+ listingInitialPostCount: currentPage.listingInitialPostCount,
220
+ listingLoadMoreStep: currentPage.listingLoadMoreStep,
228
221
  searchEnabled: currentPage.searchEnabled,
229
222
  headerHtml,
230
223
  footerHtml,
231
- }));
224
+ });
232
225
  });
233
226
  const finalHtml = await transformHtmlWithPlugins(renderedPage.html, options.plugins, {
234
227
  page: renderedPage.page,
@@ -242,7 +235,7 @@ async function renderStructuredPage(options) {
242
235
  body: finalHtml,
243
236
  };
244
237
  }
245
- function renderCatalogPostsFragment(entries, request) {
238
+ function renderListingPostsFragment(entries, request) {
246
239
  const articles = entries.filter((entry) => entry.kind === 'article');
247
240
  const visibleArticles = articles.slice(request.offset, request.offset + request.limit);
248
241
  const nextOffset = request.offset + visibleArticles.length;
@@ -252,7 +245,7 @@ function renderCatalogPostsFragment(entries, request) {
252
245
  'content-type': 'application/json; charset=utf-8',
253
246
  },
254
247
  body: JSON.stringify({
255
- itemsHtml: renderCatalogArticleItems(visibleArticles),
248
+ itemsHtml: renderListingArticleItems(visibleArticles),
256
249
  hasMore: nextOffset < articles.length,
257
250
  nextOffset,
258
251
  }),
@@ -424,8 +417,6 @@ async function renderDirectoryListing(store, requestPath, siteConfig, searchEnab
424
417
  body,
425
418
  showSummary: false,
426
419
  showDate: false,
427
- theme: siteConfig.theme,
428
- template: siteConfig.template,
429
420
  topNav: navigation.items,
430
421
  footerNav: siteConfig.footerNav,
431
422
  footerText: siteConfig.footerText,
@@ -484,22 +475,17 @@ async function tryRenderAlternateDirectoryIndex(store, requestPath, options) {
484
475
  : isRootHomeRequest(requestPath) && navigation.items.length > 0
485
476
  ? stripManagedIndexLinks(entry.text, new Set(navigation.items.map((item) => item.href)))
486
477
  : entry.text;
487
- const catalogEntries = options.siteConfig.template === 'catalog'
488
- ? await applyIndexTransforms(extractManagedIndexEntries(renderedBody), plugins, {
489
- mode: 'render',
490
- requestPath,
491
- sourcePath: candidatePath,
492
- siteConfig: options.siteConfig,
493
- })
494
- : [];
495
- const catalogFragmentRequest = getCatalogFragmentRequest(options.searchParams);
496
- if (catalogFragmentRequest !== null &&
497
- options.siteConfig.template === 'catalog') {
498
- return renderCatalogPostsFragment(catalogEntries, catalogFragmentRequest);
499
- }
500
- const documentBody = options.siteConfig.template === 'catalog'
501
- ? stripManagedIndexBlock(renderedBody)
502
- : renderedBody;
478
+ const listingEntries = await applyIndexTransforms(extractManagedIndexEntries(renderedBody), plugins, {
479
+ mode: 'render',
480
+ requestPath,
481
+ sourcePath: candidatePath,
482
+ siteConfig: options.siteConfig,
483
+ });
484
+ const listingFragmentRequest = getListingFragmentRequest(options.searchParams);
485
+ if (listingFragmentRequest !== null && listingEntries.length > 0) {
486
+ return renderListingPostsFragment(listingEntries, listingFragmentRequest);
487
+ }
488
+ const documentBody = listingEntries.length > 0 ? stripManagedIndexBlock(renderedBody) : renderedBody;
503
489
  const renderedParsed = documentBody === entry.text
504
490
  ? parsed
505
491
  : await parseMarkdownDocument(candidatePath, documentBody);
@@ -510,7 +496,7 @@ async function tryRenderAlternateDirectoryIndex(store, requestPath, options) {
510
496
  renderedParsed,
511
497
  siteConfig: options.siteConfig,
512
498
  topNav: navigation.items,
513
- catalogEntries,
499
+ listingEntries,
514
500
  searchEnabled: options.searchApi !== undefined,
515
501
  plugins,
516
502
  });
@@ -750,7 +736,7 @@ async function inspectDirectoryShape(store, directoryPath) {
750
736
  let hasExtraMarkdownFiles = false;
751
737
  let hasAssetFiles = false;
752
738
  for (const entry of entries) {
753
- if (entry.name.startsWith('.')) {
739
+ if (isIgnoredContentName(entry.name)) {
754
740
  continue;
755
741
  }
756
742
  if (entry.kind === 'directory') {