mdorigin 0.1.2 → 0.1.4

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
@@ -2,7 +2,28 @@
2
2
 
3
3
  `mdorigin` is a markdown-first publishing engine.
4
4
 
5
- It treats markdown as the only source of truth, serves raw `.md` directly for agents, renders `.html` views for humans from the same directory tree, can return markdown from extensionless routes when clients send `Accept: text/markdown`, supports frontmatter aliases for old URL redirects, and can publish skill-style bundles built around `SKILL.md`.
5
+ It treats markdown as the only source of truth, serves raw `.md` directly for agents, renders HTML for humans from the same directory tree, and can expose the same content to both browsers and tools through stable routes.
6
+
7
+ ## Core Principle
8
+
9
+ `mdorigin` is not meant to become a template system.
10
+
11
+ Its core is:
12
+
13
+ - routing rules
14
+ - markdown tree normalization
15
+ - document, index, asset, search, and site semantics
16
+ - stable page models derived from the same content tree
17
+
18
+ That means `mdorigin` should own content semantics, while page rendering remains extensible. In practice, this is the direction for advanced customization: users should be able to replace page-level rendering with code, without replacing the routing and content kernel itself.
19
+
20
+ ## Why mdorigin
21
+
22
+ - markdown stays directly accessible at `.md` routes
23
+ - extensionless routes render human-friendly HTML from the same files
24
+ - `README.md`, `index.md`, and `SKILL.md` all fit into one routing model
25
+ - the same core works in local preview and Cloudflare Workers
26
+ - optional search is powered by [`indexbind`](https://github.com/jolestar/indexbind)
6
27
 
7
28
  ## Install
8
29
 
@@ -18,9 +39,55 @@ mdorigin dev --root docs/site
18
39
 
19
40
  If you prefer a project-local install instead, use `npm install --save-dev mdorigin` and run it with `npx --no-install mdorigin ...`.
20
41
 
42
+ ## Quick Start
43
+
44
+ ```bash
45
+ mdorigin dev --root docs/site
46
+ mdorigin build index --root docs/site
47
+ mdorigin build cloudflare --root docs/site
48
+ ```
49
+
50
+ That is enough to preview a site locally, keep directory indexes up to date, and generate a Cloudflare Worker bundle.
51
+
52
+ ## Code-Based Extensions
53
+
54
+ `mdorigin` now supports a code config alongside `mdorigin.config.json`.
55
+
56
+ Use `mdorigin.config.ts` when you want to customize rendering or indexing with code instead of asking for a template system:
57
+
58
+ ```ts
59
+ export default {
60
+ siteTitle: "My Site",
61
+ plugins: [
62
+ {
63
+ name: "custom-footer",
64
+ renderFooter() {
65
+ return '<footer class="custom-footer">Built with mdorigin</footer>';
66
+ },
67
+ },
68
+ ],
69
+ };
70
+ ```
71
+
72
+ Current stable hooks are:
73
+
74
+ - `transformIndex(entries, context)`
75
+ - `renderHeader(context)`
76
+ - `renderFooter(context)`
77
+ - `renderPage(page, context, next)`
78
+ - `transformHtml(html, context)`
79
+
80
+ All render hooks that receive a `RenderHookContext` (`renderHeader`, `renderFooter`, `renderPage`, and `transformHtml`) can read the normalized markdown frontmatter for the current document via `context.page.meta`, including custom fields such as `syndication`.
81
+
82
+ The intended boundary is:
83
+
84
+ - `mdorigin` owns routing and normalized content semantics
85
+ - plugins may replace page rendering
86
+ - plugins do not replace the request kernel itself
87
+
21
88
  ## Optional Search
22
89
 
23
- `mdorigin` can build a local retrieval bundle through the optional [`indexbind`](https://github.com/jolestar/indexbind) package:
90
+ `mdorigin` can build a local retrieval bundle through the optional [`indexbind`](https://github.com/jolestar/indexbind) package. For the retrieval engine itself, see the `indexbind` docs: <https://indexbind.jolestar.workers.dev>.
24
91
 
25
92
  ```bash
26
93
  npm install indexbind
@@ -46,33 +113,13 @@ Runtime endpoints:
46
113
  - `/api/search?q=cloudflare+deploy`
47
114
  - `/api/openapi.json`
48
115
 
49
- ## Repo Development
50
-
51
- ```bash
52
- npm install
53
- npm run check
54
- npm run dev -- --root docs/site
55
- npm run build:index -- --root docs/site
56
- ```
57
-
58
- ## Release
59
-
60
- Publishing is handled by GitHub Actions through npm trusted publishing.
61
-
62
- - workflow: `.github/workflows/release.yml`
63
- - trigger: push a tag like `v0.1.2`, or run the workflow manually
64
-
65
- The npm package settings still need a one-time trusted publisher entry for:
66
-
67
- - owner: `jolestar`
68
- - repository: `mdorigin`
69
- - workflow file: `release.yml`
70
-
71
116
  ## Docs
72
117
 
118
+ - Docs site: <https://mdorigin.jolestar.workers.dev>
73
119
  - Getting started: [`docs/site/guides/getting-started.md`](docs/site/guides/getting-started.md)
74
120
  - Routing model: [`docs/site/concepts/routing.md`](docs/site/concepts/routing.md)
75
121
  - Directory indexes: [`docs/site/concepts/directory-indexes.md`](docs/site/concepts/directory-indexes.md)
76
122
  - Configuration: [`docs/site/reference/configuration.md`](docs/site/reference/configuration.md)
77
123
  - CLI: [`docs/site/reference/cli.md`](docs/site/reference/cli.md)
124
+ - Search setup: [`docs/site/guides/getting-started.md`](docs/site/guides/getting-started.md#quick-start)
78
125
  - Cloudflare deployment: [`docs/site/guides/cloudflare.md`](docs/site/guides/cloudflare.md)
@@ -1,4 +1,5 @@
1
1
  import { type ContentEntryKind } from '../core/content-store.js';
2
+ import type { MdoPlugin } from '../core/extensions.js';
2
3
  import type { ResolvedSiteConfig } from '../core/site-config.js';
3
4
  import { type SearchBundleEntry } from '../search.js';
4
5
  export interface CloudflareManifestEntry {
@@ -16,4 +17,7 @@ export interface CloudflareManifest {
16
17
  export interface ExportedHandlerLike {
17
18
  fetch(request: Request): Promise<Response>;
18
19
  }
19
- export declare function createCloudflareWorker(manifest: CloudflareManifest): ExportedHandlerLike;
20
+ export interface CreateCloudflareWorkerOptions {
21
+ plugins?: MdoPlugin[];
22
+ }
23
+ export declare function createCloudflareWorker(manifest: CloudflareManifest, options?: CreateCloudflareWorkerOptions): ExportedHandlerLike;
@@ -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 { createSearchApiFromBundle } from '../search.js';
4
- export function createCloudflareWorker(manifest) {
4
+ export function createCloudflareWorker(manifest, options = {}) {
5
5
  const store = new MemoryContentStore(manifest.entries.map((entry) => {
6
6
  if (entry.kind === 'text') {
7
7
  return {
@@ -50,6 +50,7 @@ export function createCloudflareWorker(manifest) {
50
50
  searchParams: url.searchParams,
51
51
  requestUrl: request.url,
52
52
  searchApi,
53
+ plugins: options.plugins,
53
54
  });
54
55
  const headers = new Headers(siteResponse.headers);
55
56
  const body = siteResponse.body instanceof Uint8Array
@@ -1,5 +1,6 @@
1
1
  import { type IncomingMessage, type ServerResponse } from 'node:http';
2
2
  import type { ContentStore } from '../core/content-store.js';
3
+ import type { MdoPlugin } from '../core/extensions.js';
3
4
  import type { ResolvedSiteConfig } from '../core/site-config.js';
4
5
  import type { SearchApi } from '../search.js';
5
6
  export interface NodeAdapterOptions {
@@ -7,6 +8,7 @@ export interface NodeAdapterOptions {
7
8
  draftMode: 'include' | 'exclude';
8
9
  siteConfig: ResolvedSiteConfig;
9
10
  searchApi?: SearchApi;
11
+ plugins?: MdoPlugin[];
10
12
  }
11
13
  export declare function createFileSystemContentStore(rootDir: string): ContentStore;
12
14
  export declare function createNodeRequestListener(options: NodeAdapterOptions): (request: IncomingMessage, response: ServerResponse) => Promise<void>;
@@ -126,6 +126,7 @@ export function createNodeRequestListener(options) {
126
126
  searchParams: url.searchParams,
127
127
  requestUrl: url.toString(),
128
128
  searchApi: options.searchApi,
129
+ plugins: options.plugins,
129
130
  });
130
131
  response.statusCode = siteResponse.status;
131
132
  for (const [headerName, headerValue] of Object.entries(siteResponse.headers)) {
@@ -1,27 +1,28 @@
1
1
  import path from 'node:path';
2
2
  import { createFileSystemContentStore } from '../adapters/node.js';
3
3
  import { writeCloudflareBundle } from '../cloudflare.js';
4
- import { applySiteConfigFrontmatterDefaults, loadSiteConfig, } from '../core/site-config.js';
4
+ import { applySiteConfigFrontmatterDefaults, loadUserSiteConfig, } from '../core/site-config.js';
5
5
  export async function runBuildCloudflareCommand(argv) {
6
6
  const args = parseArgs(argv);
7
7
  if (!args.root) {
8
- console.error('Usage: mdorigin build cloudflare --root <content-dir> [--out ./dist/cloudflare] [--config mdorigin.config.json] [--search ./dist/search]');
8
+ console.error('Usage: mdorigin build cloudflare --root <content-dir> [--out ./dist/cloudflare] [--config <config-file>] [--search ./dist/search]');
9
9
  process.exitCode = 1;
10
10
  return;
11
11
  }
12
12
  const rootDir = path.resolve(args.root);
13
- const loadedSiteConfig = await loadSiteConfig({
13
+ const loadedConfig = await loadUserSiteConfig({
14
14
  cwd: process.cwd(),
15
15
  rootDir,
16
16
  configPath: args.config,
17
17
  });
18
18
  const store = createFileSystemContentStore(rootDir);
19
- const siteConfig = await applySiteConfigFrontmatterDefaults(store, loadedSiteConfig);
19
+ const siteConfig = await applySiteConfigFrontmatterDefaults(store, loadedConfig.siteConfig);
20
20
  const result = await writeCloudflareBundle({
21
21
  rootDir,
22
22
  outDir: path.resolve(args.out ?? 'dist/cloudflare'),
23
23
  siteConfig,
24
24
  searchDir: args.search ? path.resolve(args.search) : undefined,
25
+ configModulePath: loadedConfig.configModulePath,
25
26
  });
26
27
  console.log(`cloudflare worker written to ${result.workerFile}`);
27
28
  }
@@ -1,15 +1,24 @@
1
1
  import path from 'node:path';
2
2
  import { buildDirectoryIndexes } from '../index-builder.js';
3
+ import { loadUserSiteConfig } from '../core/site-config.js';
3
4
  export async function runBuildIndexCommand(argv) {
4
5
  const args = parseArgs(argv);
5
6
  if (!args.root && !args.dir) {
6
- console.error('Usage: mdorigin build index (--root <content-dir> | --dir <content-dir>)');
7
+ console.error('Usage: mdorigin build index (--root <content-dir> | --dir <content-dir>) [--config <config-file>]');
7
8
  process.exitCode = 1;
8
9
  return;
9
10
  }
11
+ const rootDir = args.root ? path.resolve(args.root) : undefined;
12
+ const dir = args.dir ? path.resolve(args.dir) : undefined;
13
+ const loadedConfig = await loadUserSiteConfig({
14
+ cwd: process.cwd(),
15
+ rootDir: rootDir ?? dir,
16
+ configPath: args.config,
17
+ });
10
18
  const result = await buildDirectoryIndexes({
11
- rootDir: args.root ? path.resolve(args.root) : undefined,
12
- dir: args.dir ? path.resolve(args.dir) : undefined,
19
+ rootDir,
20
+ dir,
21
+ plugins: loadedConfig.plugins,
13
22
  });
14
23
  console.log(`updated ${result.updatedFiles.length} index file(s)`);
15
24
  if (result.skippedDirectories.length > 0) {
@@ -29,6 +38,11 @@ function parseArgs(argv) {
29
38
  if (argument === '--dir' && nextValue) {
30
39
  result.dir = nextValue;
31
40
  index += 1;
41
+ continue;
42
+ }
43
+ if (argument === '--config' && nextValue) {
44
+ result.config = nextValue;
45
+ index += 1;
32
46
  }
33
47
  }
34
48
  return result;
@@ -1,18 +1,19 @@
1
1
  import path from 'node:path';
2
2
  import { buildSearchBundle } from '../search.js';
3
- import { applySiteConfigFrontmatterDefaults, loadSiteConfig } from '../core/site-config.js';
3
+ import { applySiteConfigFrontmatterDefaults, loadUserSiteConfig, } from '../core/site-config.js';
4
4
  import { createFileSystemContentStore } from '../adapters/node.js';
5
5
  export async function runBuildSearchCommand(rawArgs) {
6
6
  const args = parseArgs(rawArgs);
7
7
  if (!args.root) {
8
- 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 mdorigin.config.json]');
8
+ 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>]');
9
9
  }
10
10
  const rootDir = path.resolve(args.root);
11
11
  const store = createFileSystemContentStore(rootDir);
12
- const siteConfig = await applySiteConfigFrontmatterDefaults(store, await loadSiteConfig({
12
+ const loadedConfig = await loadUserSiteConfig({
13
13
  rootDir,
14
14
  configPath: args.config,
15
- }));
15
+ });
16
+ const siteConfig = await applySiteConfigFrontmatterDefaults(store, loadedConfig.siteConfig);
16
17
  const result = await buildSearchBundle({
17
18
  rootDir,
18
19
  outDir: path.resolve(args.out ?? 'dist/search'),
package/dist/cli/dev.js CHANGED
@@ -1,23 +1,23 @@
1
1
  import path from 'node:path';
2
2
  import { createFileSystemContentStore, createNodeServer } from '../adapters/node.js';
3
- import { applySiteConfigFrontmatterDefaults, loadSiteConfig, } from '../core/site-config.js';
3
+ import { applySiteConfigFrontmatterDefaults, loadUserSiteConfig, } from '../core/site-config.js';
4
4
  import { createSearchApiFromDirectory } from '../search.js';
5
5
  export async function runDevCommand(argv) {
6
6
  const args = parseArgs(argv);
7
7
  if (!args.root) {
8
- console.error('Usage: mdorigin dev --root <content-dir> [--port 3000] [--config mdorigin.config.json] [--search ./dist/search]');
8
+ console.error('Usage: mdorigin dev --root <content-dir> [--port 3000] [--config <config-file>] [--search ./dist/search]');
9
9
  process.exitCode = 1;
10
10
  return;
11
11
  }
12
12
  const rootDir = path.resolve(args.root);
13
13
  const port = args.port ?? 3000;
14
- const loadedSiteConfig = await loadSiteConfig({
14
+ const loadedConfig = await loadUserSiteConfig({
15
15
  cwd: process.cwd(),
16
16
  rootDir,
17
17
  configPath: args.config,
18
18
  });
19
19
  const store = createFileSystemContentStore(rootDir);
20
- const siteConfig = await applySiteConfigFrontmatterDefaults(store, loadedSiteConfig);
20
+ const siteConfig = await applySiteConfigFrontmatterDefaults(store, loadedConfig.siteConfig);
21
21
  const server = createNodeServer({
22
22
  rootDir,
23
23
  draftMode: 'include',
@@ -25,6 +25,7 @@ export async function runDevCommand(argv) {
25
25
  searchApi: args.search
26
26
  ? await createSearchApiFromDirectory(path.resolve(args.search))
27
27
  : undefined,
28
+ plugins: loadedConfig.plugins,
28
29
  });
29
30
  await new Promise((resolve, reject) => {
30
31
  server.once('error', reject);
package/dist/cli/main.js CHANGED
@@ -33,10 +33,10 @@ async function main() {
33
33
  }
34
34
  console.error([
35
35
  'Usage:',
36
- ' mdorigin dev --root <content-dir> [--port 3000] [--config mdorigin.config.json] [--search ./dist/search]',
37
- ' mdorigin build index (--root <content-dir> | --dir <content-dir>)',
38
- ' mdorigin build search --root <content-dir> [--out ./dist/search] [--embedding-backend model2vec|hashing] [--model sentence-transformers/all-MiniLM-L6-v2] [--config mdorigin.config.json]',
39
- ' mdorigin build cloudflare --root <content-dir> [--out ./dist/cloudflare] [--config mdorigin.config.json] [--search ./dist/search]',
36
+ ' mdorigin dev --root <content-dir> [--port 3000] [--config <config-file>] [--search ./dist/search]',
37
+ ' mdorigin build index (--root <content-dir> | --dir <content-dir>) [--config <config-file>]',
38
+ ' mdorigin build search --root <content-dir> [--out ./dist/search] [--embedding-backend model2vec|hashing] [--model sentence-transformers/all-MiniLM-L6-v2] [--config <config-file>]',
39
+ ' mdorigin build cloudflare --root <content-dir> [--out ./dist/cloudflare] [--config <config-file>] [--search ./dist/search]',
40
40
  ' mdorigin init cloudflare [--dir .] [--entry ./dist/cloudflare/worker.mjs] [--name <worker-name>] [--compatibility-date 2026-03-20] [--force]',
41
41
  ' mdorigin search --index <search-dir> [--top-k 10] <query>',
42
42
  ].join('\n'));
@@ -14,6 +14,7 @@ export interface WriteCloudflareBundleOptions {
14
14
  siteConfig: ResolvedSiteConfig;
15
15
  packageImport?: string;
16
16
  searchDir?: string;
17
+ configModulePath?: string;
17
18
  }
18
19
  export interface InitCloudflareProjectOptions {
19
20
  projectDir: string;
@@ -49,12 +49,37 @@ export async function writeCloudflareBundle(options) {
49
49
  });
50
50
  const packageImport = options.packageImport ?? 'mdorigin/cloudflare-runtime';
51
51
  const workerFile = path.join(outDir, 'worker.mjs');
52
+ const configImportPath = options.configModulePath
53
+ ? toPosixPath(path.relative(outDir, options.configModulePath))
54
+ : null;
52
55
  const workerSource = [
53
56
  `import { createCloudflareWorker } from '${packageImport}';`,
57
+ configImportPath
58
+ ? `import * as userConfigModule from '${configImportPath.startsWith('.') ? configImportPath : `./${configImportPath}`}';`
59
+ : '',
54
60
  '',
55
61
  `const manifest = ${JSON.stringify(manifest, null, 2)};`,
56
62
  '',
57
- 'export default createCloudflareWorker(manifest);',
63
+ configImportPath
64
+ ? [
65
+ 'function unwrapUserConfigModule(moduleValue) {',
66
+ ' let current = moduleValue;',
67
+ " while (current && typeof current === 'object' && 'default' in current && current.default !== undefined) {",
68
+ ' current = current.default;',
69
+ ' }',
70
+ " if (current && typeof current === 'object' && 'config' in current && current.config !== undefined) {",
71
+ ' return current.config;',
72
+ ' }',
73
+ ' return current;',
74
+ '}',
75
+ '',
76
+ 'const userConfig = unwrapUserConfigModule(userConfigModule);',
77
+ '',
78
+ ].join('\n')
79
+ : '',
80
+ configImportPath
81
+ ? 'export default createCloudflareWorker(manifest, { plugins: Array.isArray(userConfig?.plugins) ? userConfig.plugins : [] });'
82
+ : 'export default createCloudflareWorker(manifest);',
58
83
  '',
59
84
  ].join('\n');
60
85
  await mkdir(outDir, { recursive: true });
@@ -0,0 +1,67 @@
1
+ import type { ManagedIndexEntry, ParsedDocumentMeta } from './markdown.js';
2
+ import type { EditLinkConfig, ResolvedSiteConfig, SiteLogo, SiteNavItem, SiteSocialLink } from './site-config.js';
3
+ import type { TemplateName } from '../html/template-kind.js';
4
+ import type { BuiltInThemeName } from '../html/theme.js';
5
+ type MaybePromise<T> = T | Promise<T>;
6
+ export interface IndexTransformContext {
7
+ mode: 'build' | 'render';
8
+ directoryPath?: string;
9
+ requestPath?: string;
10
+ sourcePath?: string;
11
+ siteConfig?: ResolvedSiteConfig;
12
+ }
13
+ export interface PageRenderModel {
14
+ kind: 'document' | 'catalog';
15
+ requestPath: string;
16
+ sourcePath: string;
17
+ siteTitle: string;
18
+ siteDescription?: string;
19
+ siteUrl?: string;
20
+ favicon?: string;
21
+ socialImage?: string;
22
+ logo?: SiteLogo;
23
+ title: string;
24
+ meta: ParsedDocumentMeta;
25
+ bodyHtml: string;
26
+ summary?: string;
27
+ date?: string;
28
+ showSummary: boolean;
29
+ showDate: boolean;
30
+ theme: BuiltInThemeName;
31
+ template: TemplateName;
32
+ topNav: SiteNavItem[];
33
+ footerNav: SiteNavItem[];
34
+ footerText?: string;
35
+ socialLinks: SiteSocialLink[];
36
+ editLink?: EditLinkConfig;
37
+ editLinkHref?: string;
38
+ stylesheetContent?: string;
39
+ canonicalPath?: string;
40
+ alternateMarkdownPath?: string;
41
+ catalogEntries: ManagedIndexEntry[];
42
+ catalogRequestPath: string;
43
+ catalogInitialPostCount: number;
44
+ catalogLoadMoreStep: number;
45
+ searchEnabled: boolean;
46
+ }
47
+ export interface RenderHookContext {
48
+ page: PageRenderModel;
49
+ siteConfig: ResolvedSiteConfig;
50
+ }
51
+ export interface MdoPlugin {
52
+ name?: string;
53
+ transformIndex?(entries: ManagedIndexEntry[], context: IndexTransformContext): MaybePromise<ManagedIndexEntry[]>;
54
+ renderHeader?(context: RenderHookContext): MaybePromise<string | undefined | null>;
55
+ renderFooter?(context: RenderHookContext): MaybePromise<string | undefined | null>;
56
+ renderPage?(page: PageRenderModel, context: RenderHookContext, next: (page: PageRenderModel) => MaybePromise<string>): MaybePromise<string | undefined | null>;
57
+ transformHtml?(html: string, context: RenderHookContext): MaybePromise<string>;
58
+ }
59
+ export declare function applyIndexTransforms(entries: ManagedIndexEntry[], plugins: MdoPlugin[], context: IndexTransformContext): Promise<ManagedIndexEntry[]>;
60
+ export declare function renderHeaderOverride(plugins: MdoPlugin[], context: RenderHookContext): Promise<string | undefined>;
61
+ export declare function renderFooterOverride(plugins: MdoPlugin[], context: RenderHookContext): Promise<string | undefined>;
62
+ export declare function renderPageWithPlugins(page: PageRenderModel, plugins: MdoPlugin[], context: RenderHookContext, renderDefault: (page: PageRenderModel) => MaybePromise<string>): Promise<{
63
+ html: string;
64
+ page: PageRenderModel;
65
+ }>;
66
+ export declare function transformHtmlWithPlugins(html: string, plugins: MdoPlugin[], context: RenderHookContext): Promise<string>;
67
+ export {};
@@ -0,0 +1,86 @@
1
+ export async function applyIndexTransforms(entries, plugins, context) {
2
+ let current = [...entries];
3
+ for (const plugin of plugins) {
4
+ if (!plugin.transformIndex) {
5
+ continue;
6
+ }
7
+ current = [...(await plugin.transformIndex(current, context))];
8
+ }
9
+ return current;
10
+ }
11
+ export async function renderHeaderOverride(plugins, context) {
12
+ let rendered;
13
+ for (const plugin of plugins) {
14
+ if (!plugin.renderHeader) {
15
+ continue;
16
+ }
17
+ const value = await plugin.renderHeader(context);
18
+ if (typeof value === 'string') {
19
+ rendered = value;
20
+ }
21
+ }
22
+ return rendered;
23
+ }
24
+ export async function renderFooterOverride(plugins, context) {
25
+ let rendered;
26
+ for (const plugin of plugins) {
27
+ if (!plugin.renderFooter) {
28
+ continue;
29
+ }
30
+ const value = await plugin.renderFooter(context);
31
+ if (typeof value === 'string') {
32
+ rendered = value;
33
+ }
34
+ }
35
+ return rendered;
36
+ }
37
+ export async function renderPageWithPlugins(page, plugins, context, renderDefault) {
38
+ const renderers = plugins
39
+ .map((plugin) => plugin.renderPage)
40
+ .filter((renderPage) => typeof renderPage === 'function');
41
+ let finalPage = page;
42
+ const dispatch = async (index, currentPage) => {
43
+ const renderPage = renderers[index];
44
+ if (!renderPage) {
45
+ finalPage = currentPage;
46
+ return renderDefault(currentPage);
47
+ }
48
+ const currentContext = {
49
+ ...context,
50
+ page: currentPage,
51
+ };
52
+ let nextInvoked = false;
53
+ const next = async (nextPage) => {
54
+ nextInvoked = true;
55
+ finalPage = nextPage;
56
+ return dispatch(index + 1, nextPage);
57
+ };
58
+ const rendered = await renderPage(currentPage, currentContext, next);
59
+ if (typeof rendered === 'string') {
60
+ if (!nextInvoked) {
61
+ finalPage = currentPage;
62
+ }
63
+ return rendered;
64
+ }
65
+ return next(currentPage);
66
+ };
67
+ return {
68
+ html: await dispatch(0, page),
69
+ page: finalPage,
70
+ };
71
+ }
72
+ export async function transformHtmlWithPlugins(html, plugins, context) {
73
+ let current = html;
74
+ for (const plugin of plugins) {
75
+ if (!plugin.transformHtml) {
76
+ continue;
77
+ }
78
+ const result = await plugin.transformHtml(current, context);
79
+ if (typeof result !== 'string') {
80
+ const pluginName = plugin.name ?? 'unknown plugin';
81
+ throw new Error(`transformHtmlWithPlugins expected plugin "${pluginName}" to return a string, but got ${typeof result}`);
82
+ }
83
+ current = result;
84
+ }
85
+ return current;
86
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ContentStore } from './content-store.js';
2
+ import type { MdoPlugin } from './extensions.js';
2
3
  import type { ResolvedSiteConfig } from './site-config.js';
3
4
  import type { SearchApi } from '../search.js';
4
5
  export interface HandleSiteRequestOptions {
@@ -8,6 +9,7 @@ export interface HandleSiteRequestOptions {
8
9
  searchParams?: URLSearchParams;
9
10
  requestUrl?: string;
10
11
  searchApi?: SearchApi;
12
+ plugins?: MdoPlugin[];
11
13
  }
12
14
  export interface SiteResponse {
13
15
  status: number;
@@ -2,10 +2,12 @@ import path from 'node:path';
2
2
  import { inferDirectoryContentType } from './content-type.js';
3
3
  import { getDirectoryIndexCandidates } from './directory-index.js';
4
4
  import { extractManagedIndexEntries, getDocumentSummary, getDocumentTitle as getParsedDocumentTitle, parseMarkdownDocument, stripManagedIndexBlock, stripManagedIndexLinks, } from './markdown.js';
5
+ import { applyIndexTransforms, renderFooterOverride, renderHeaderOverride, renderPageWithPlugins, transformHtmlWithPlugins, } from './extensions.js';
5
6
  import { handleApiRoute } from './api.js';
6
7
  import { normalizeRequestPath, resolveRequest } from './router.js';
7
8
  import { escapeHtml, renderCatalogArticleItems, renderDocument, } from '../html/template.js';
8
9
  export async function handleSiteRequest(store, pathname, options) {
10
+ const plugins = options.plugins ?? [];
9
11
  const searchEnabled = options.searchApi !== undefined;
10
12
  const apiRoute = await handleApiRoute(pathname, options.searchParams, {
11
13
  searchApi: options.searchApi,
@@ -81,7 +83,12 @@ export async function handleSiteRequest(store, pathname, options) {
81
83
  ? stripManagedIndexLinks(entry.text, new Set(navigation.items.map((item) => item.href)))
82
84
  : entry.text;
83
85
  const catalogEntries = options.siteConfig.template === 'catalog'
84
- ? extractManagedIndexEntries(renderedBody)
86
+ ? await applyIndexTransforms(extractManagedIndexEntries(renderedBody), plugins, {
87
+ mode: 'render',
88
+ requestPath: resolved.requestPath,
89
+ sourcePath: resolved.sourcePath,
90
+ siteConfig: options.siteConfig,
91
+ })
85
92
  : [];
86
93
  if (catalogFragmentRequest !== null &&
87
94
  options.siteConfig.template === 'catalog') {
@@ -93,42 +100,18 @@ export async function handleSiteRequest(store, pathname, options) {
93
100
  const renderedParsed = documentBody === entry.text
94
101
  ? parsed
95
102
  : await parseMarkdownDocument(resolved.sourcePath, documentBody);
96
- return {
97
- status: 200,
98
- headers: withVaryAcceptIfNeeded({
99
- 'content-type': 'text/html; charset=utf-8',
100
- }, shouldVaryOnAccept(resolved)),
101
- body: renderDocument({
102
- siteTitle: options.siteConfig.siteTitle,
103
- siteDescription: options.siteConfig.siteDescription,
104
- siteUrl: options.siteConfig.siteUrl,
105
- favicon: options.siteConfig.favicon,
106
- logo: options.siteConfig.logo,
107
- title: getDocumentTitle(parsed),
108
- body: renderedParsed.html,
109
- summary: options.siteConfig.showSummary === false
110
- ? undefined
111
- : getDocumentSummary(parsed.meta, parsed.body),
112
- date: options.siteConfig.showDate === false ? undefined : parsed.meta.date,
113
- showSummary: options.siteConfig.showSummary,
114
- showDate: options.siteConfig.showDate,
115
- theme: options.siteConfig.theme,
116
- template: options.siteConfig.template,
117
- topNav: navigation.items,
118
- footerNav: options.siteConfig.footerNav,
119
- footerText: options.siteConfig.footerText,
120
- socialLinks: options.siteConfig.socialLinks,
121
- editLinkHref: getEditLinkHref(options.siteConfig, resolved.sourcePath),
122
- stylesheetContent: options.siteConfig.stylesheetContent,
123
- canonicalPath: getCanonicalHtmlPathForContentPath(resolved.sourcePath),
124
- alternateMarkdownPath: getMarkdownRequestPathForContentPath(resolved.sourcePath),
125
- catalogEntries,
126
- catalogRequestPath: resolved.requestPath,
127
- catalogInitialPostCount: options.siteConfig.catalogInitialPostCount,
128
- catalogLoadMoreStep: options.siteConfig.catalogLoadMoreStep,
129
- searchEnabled,
130
- }),
131
- };
103
+ return renderStructuredPage({
104
+ requestPath: resolved.requestPath,
105
+ sourcePath: resolved.sourcePath,
106
+ parsed,
107
+ renderedParsed,
108
+ siteConfig: options.siteConfig,
109
+ topNav: navigation.items,
110
+ catalogEntries,
111
+ searchEnabled,
112
+ plugins,
113
+ varyOnAccept: shouldVaryOnAccept(resolved),
114
+ });
132
115
  }
133
116
  function getCatalogFragmentRequest(searchParams) {
134
117
  if (searchParams?.get('catalog-format') !== 'posts') {
@@ -155,6 +138,110 @@ function normalizePositiveInteger(value) {
155
138
  const parsed = Number.parseInt(value, 10);
156
139
  return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
157
140
  }
141
+ function buildPageRenderModel(options) {
142
+ return {
143
+ kind: options.siteConfig.template === 'catalog' ? 'catalog' : 'document',
144
+ requestPath: options.resolvedRequestPath,
145
+ sourcePath: options.sourcePath,
146
+ siteTitle: options.siteConfig.siteTitle,
147
+ siteDescription: options.siteConfig.siteDescription,
148
+ siteUrl: options.siteConfig.siteUrl,
149
+ favicon: options.siteConfig.favicon,
150
+ socialImage: options.siteConfig.socialImage,
151
+ logo: options.siteConfig.logo,
152
+ title: getDocumentTitle(options.parsed),
153
+ meta: options.parsed.meta,
154
+ bodyHtml: options.renderedBodyHtml,
155
+ summary: options.siteConfig.showSummary === false
156
+ ? undefined
157
+ : getDocumentSummary(options.parsed.meta, options.parsed.body),
158
+ date: options.siteConfig.showDate === false ? undefined : options.parsed.meta.date,
159
+ showSummary: options.siteConfig.showSummary,
160
+ showDate: options.siteConfig.showDate,
161
+ theme: options.siteConfig.theme,
162
+ template: options.siteConfig.template,
163
+ topNav: options.topNav,
164
+ footerNav: options.siteConfig.footerNav,
165
+ footerText: options.siteConfig.footerText,
166
+ socialLinks: options.siteConfig.socialLinks,
167
+ editLink: options.siteConfig.editLink,
168
+ editLinkHref: getEditLinkHref(options.siteConfig, options.sourcePath),
169
+ stylesheetContent: options.siteConfig.stylesheetContent,
170
+ canonicalPath: getCanonicalHtmlPathForContentPath(options.sourcePath),
171
+ alternateMarkdownPath: getMarkdownRequestPathForContentPath(options.sourcePath),
172
+ catalogEntries: options.catalogEntries,
173
+ catalogRequestPath: options.resolvedRequestPath,
174
+ catalogInitialPostCount: options.siteConfig.catalogInitialPostCount,
175
+ catalogLoadMoreStep: options.siteConfig.catalogLoadMoreStep,
176
+ searchEnabled: options.searchEnabled,
177
+ };
178
+ }
179
+ async function renderStructuredPage(options) {
180
+ const page = buildPageRenderModel({
181
+ resolvedRequestPath: options.requestPath,
182
+ sourcePath: options.sourcePath,
183
+ renderedBodyHtml: options.renderedParsed.html,
184
+ parsed: options.parsed,
185
+ siteConfig: options.siteConfig,
186
+ topNav: options.topNav,
187
+ catalogEntries: options.catalogEntries,
188
+ searchEnabled: options.searchEnabled,
189
+ });
190
+ const renderContext = {
191
+ page,
192
+ siteConfig: options.siteConfig,
193
+ };
194
+ const renderedPage = await renderPageWithPlugins(page, options.plugins, renderContext, async (currentPage) => {
195
+ const currentContext = {
196
+ page: currentPage,
197
+ siteConfig: options.siteConfig,
198
+ };
199
+ const headerHtml = await renderHeaderOverride(options.plugins, currentContext);
200
+ const footerHtml = await renderFooterOverride(options.plugins, currentContext);
201
+ return (renderDocument({
202
+ siteTitle: currentPage.siteTitle,
203
+ siteDescription: currentPage.siteDescription,
204
+ siteUrl: currentPage.siteUrl,
205
+ favicon: currentPage.favicon,
206
+ socialImage: currentPage.socialImage,
207
+ logo: currentPage.logo,
208
+ title: currentPage.title,
209
+ body: currentPage.bodyHtml,
210
+ summary: currentPage.summary,
211
+ date: currentPage.date,
212
+ showSummary: currentPage.showSummary,
213
+ showDate: currentPage.showDate,
214
+ theme: currentPage.theme,
215
+ template: currentPage.template,
216
+ topNav: currentPage.topNav,
217
+ footerNav: currentPage.footerNav,
218
+ footerText: currentPage.footerText,
219
+ socialLinks: currentPage.socialLinks,
220
+ editLinkHref: currentPage.editLinkHref,
221
+ stylesheetContent: currentPage.stylesheetContent,
222
+ canonicalPath: currentPage.canonicalPath,
223
+ alternateMarkdownPath: currentPage.alternateMarkdownPath,
224
+ catalogEntries: currentPage.catalogEntries,
225
+ catalogRequestPath: currentPage.catalogRequestPath,
226
+ catalogInitialPostCount: currentPage.catalogInitialPostCount,
227
+ catalogLoadMoreStep: currentPage.catalogLoadMoreStep,
228
+ searchEnabled: currentPage.searchEnabled,
229
+ headerHtml,
230
+ footerHtml,
231
+ }));
232
+ });
233
+ const finalHtml = await transformHtmlWithPlugins(renderedPage.html, options.plugins, {
234
+ page: renderedPage.page,
235
+ siteConfig: options.siteConfig,
236
+ });
237
+ return {
238
+ status: 200,
239
+ headers: withVaryAcceptIfNeeded({
240
+ 'content-type': 'text/html; charset=utf-8',
241
+ }, options.varyOnAccept ?? false),
242
+ body: finalHtml,
243
+ };
244
+ }
158
245
  function renderCatalogPostsFragment(entries, request) {
159
246
  const articles = entries.filter((entry) => entry.kind === 'article');
160
247
  const visibleArticles = articles.slice(request.offset, request.offset + request.limit);
@@ -377,6 +464,7 @@ function getDirectoryIndexContentPathForRequestPath(requestPath) {
377
464
  : `${requestPath.slice(1).replace(/\/$/, '')}/index.md`;
378
465
  }
379
466
  async function tryRenderAlternateDirectoryIndex(store, requestPath, options) {
467
+ const plugins = options.plugins ?? [];
380
468
  const directoryPath = requestPath === '/' ? '' : requestPath.slice(1).replace(/\/$/, '');
381
469
  for (const candidatePath of getDirectoryIndexCandidates(directoryPath)) {
382
470
  if (candidatePath === (directoryPath === '' ? 'index.md' : `${directoryPath}/index.md`)) {
@@ -397,7 +485,12 @@ async function tryRenderAlternateDirectoryIndex(store, requestPath, options) {
397
485
  ? stripManagedIndexLinks(entry.text, new Set(navigation.items.map((item) => item.href)))
398
486
  : entry.text;
399
487
  const catalogEntries = options.siteConfig.template === 'catalog'
400
- ? extractManagedIndexEntries(renderedBody)
488
+ ? await applyIndexTransforms(extractManagedIndexEntries(renderedBody), plugins, {
489
+ mode: 'render',
490
+ requestPath,
491
+ sourcePath: candidatePath,
492
+ siteConfig: options.siteConfig,
493
+ })
401
494
  : [];
402
495
  const catalogFragmentRequest = getCatalogFragmentRequest(options.searchParams);
403
496
  if (catalogFragmentRequest !== null &&
@@ -410,42 +503,17 @@ async function tryRenderAlternateDirectoryIndex(store, requestPath, options) {
410
503
  const renderedParsed = documentBody === entry.text
411
504
  ? parsed
412
505
  : await parseMarkdownDocument(candidatePath, documentBody);
413
- return {
414
- status: 200,
415
- headers: {
416
- 'content-type': 'text/html; charset=utf-8',
417
- },
418
- body: renderDocument({
419
- siteTitle: options.siteConfig.siteTitle,
420
- siteDescription: options.siteConfig.siteDescription,
421
- siteUrl: options.siteConfig.siteUrl,
422
- favicon: options.siteConfig.favicon,
423
- logo: options.siteConfig.logo,
424
- title: getDocumentTitle(parsed),
425
- body: renderedParsed.html,
426
- summary: options.siteConfig.showSummary === false
427
- ? undefined
428
- : getDocumentSummary(parsed.meta, parsed.body),
429
- date: options.siteConfig.showDate === false ? undefined : parsed.meta.date,
430
- showSummary: options.siteConfig.showSummary,
431
- showDate: options.siteConfig.showDate,
432
- theme: options.siteConfig.theme,
433
- template: options.siteConfig.template,
434
- topNav: navigation.items,
435
- footerNav: options.siteConfig.footerNav,
436
- footerText: options.siteConfig.footerText,
437
- socialLinks: options.siteConfig.socialLinks,
438
- editLinkHref: getEditLinkHref(options.siteConfig, candidatePath),
439
- stylesheetContent: options.siteConfig.stylesheetContent,
440
- canonicalPath: requestPath,
441
- alternateMarkdownPath: getMarkdownRequestPathForContentPath(candidatePath),
442
- catalogEntries,
443
- catalogRequestPath: requestPath,
444
- catalogInitialPostCount: options.siteConfig.catalogInitialPostCount,
445
- catalogLoadMoreStep: options.siteConfig.catalogLoadMoreStep,
446
- searchEnabled: options.searchApi !== undefined,
447
- }),
448
- };
506
+ return renderStructuredPage({
507
+ requestPath,
508
+ sourcePath: candidatePath,
509
+ parsed,
510
+ renderedParsed,
511
+ siteConfig: options.siteConfig,
512
+ topNav: navigation.items,
513
+ catalogEntries,
514
+ searchEnabled: options.searchApi !== undefined,
515
+ plugins,
516
+ });
449
517
  }
450
518
  return null;
451
519
  }
@@ -1,4 +1,5 @@
1
1
  import type { ContentStore } from './content-store.js';
2
+ import type { MdoPlugin } from './extensions.js';
2
3
  import type { TemplateName } from '../html/template-kind.js';
3
4
  import type { BuiltInThemeName } from '../html/theme.js';
4
5
  export interface SiteNavItem {
@@ -23,6 +24,7 @@ export interface SiteConfig {
23
24
  siteDescription?: string;
24
25
  siteUrl?: string;
25
26
  favicon?: string;
27
+ socialImage?: string;
26
28
  logo?: SiteLogo;
27
29
  showDate?: boolean;
28
30
  showSummary?: boolean;
@@ -38,11 +40,15 @@ export interface SiteConfig {
38
40
  catalogInitialPostCount?: number;
39
41
  catalogLoadMoreStep?: number;
40
42
  }
43
+ export interface UserSiteConfig extends SiteConfig {
44
+ plugins?: MdoPlugin[];
45
+ }
41
46
  export interface ResolvedSiteConfig {
42
47
  siteTitle: string;
43
48
  siteDescription?: string;
44
49
  siteUrl?: string;
45
50
  favicon?: string;
51
+ socialImage?: string;
46
52
  logo?: SiteLogo;
47
53
  showDate: boolean;
48
54
  showSummary: boolean;
@@ -65,5 +71,13 @@ export interface LoadSiteConfigOptions {
65
71
  rootDir?: string;
66
72
  configPath?: string;
67
73
  }
74
+ export interface LoadedSiteConfig {
75
+ siteConfig: ResolvedSiteConfig;
76
+ plugins: MdoPlugin[];
77
+ configFilePath: string;
78
+ configModulePath?: string;
79
+ }
68
80
  export declare function loadSiteConfig(options?: LoadSiteConfigOptions): Promise<ResolvedSiteConfig>;
81
+ export declare function loadUserSiteConfig(options?: LoadSiteConfigOptions): Promise<LoadedSiteConfig>;
69
82
  export declare function applySiteConfigFrontmatterDefaults(store: ContentStore, siteConfig: ResolvedSiteConfig): Promise<ResolvedSiteConfig>;
83
+ export declare function defineConfig(config: UserSiteConfig): UserSiteConfig;
@@ -1,30 +1,26 @@
1
- import { readFile } from 'node:fs/promises';
1
+ import { readFile, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { tsImport } from 'tsx/esm/api';
3
5
  import { getDirectoryIndexCandidates } from './directory-index.js';
4
6
  import { parseMarkdownDocument } from './markdown.js';
5
7
  export async function loadSiteConfig(options = {}) {
8
+ return (await loadUserSiteConfig(options)).siteConfig;
9
+ }
10
+ export async function loadUserSiteConfig(options = {}) {
6
11
  const cwd = path.resolve(options.cwd ?? process.cwd());
7
12
  const rootDir = options.rootDir ? path.resolve(options.rootDir) : null;
8
13
  const configFilePath = options.configPath
9
14
  ? path.resolve(cwd, options.configPath)
10
15
  : await resolveDefaultConfigPath(cwd, rootDir);
11
- let parsedConfig = {};
12
- try {
13
- const configSource = await readFile(configFilePath, 'utf8');
14
- parsedConfig = JSON.parse(configSource);
15
- }
16
- catch (error) {
17
- if (!isNodeNotFound(error)) {
18
- throw error;
19
- }
20
- }
16
+ const parsedConfig = await loadConfigSource(configFilePath);
21
17
  const stylesheetPath = parsedConfig.stylesheet
22
18
  ? path.resolve(path.dirname(configFilePath), parsedConfig.stylesheet)
23
19
  : null;
24
20
  const stylesheetContent = stylesheetPath
25
21
  ? await readFile(stylesheetPath, 'utf8')
26
22
  : undefined;
27
- return {
23
+ const siteConfig = {
28
24
  siteTitle: typeof parsedConfig.siteTitle === 'string' && parsedConfig.siteTitle !== ''
29
25
  ? parsedConfig.siteTitle
30
26
  : 'mdorigin',
@@ -34,6 +30,7 @@ export async function loadSiteConfig(options = {}) {
34
30
  : undefined,
35
31
  siteUrl: normalizeSiteUrl(parsedConfig.siteUrl),
36
32
  favicon: normalizeSiteHref(parsedConfig.favicon),
33
+ socialImage: normalizeSiteHref(parsedConfig.socialImage),
37
34
  logo: normalizeLogo(parsedConfig.logo),
38
35
  showDate: parsedConfig.showDate ?? true,
39
36
  showSummary: parsedConfig.showSummary ?? true,
@@ -56,6 +53,12 @@ export async function loadSiteConfig(options = {}) {
56
53
  siteDescriptionConfigured: typeof parsedConfig.siteDescription === 'string' &&
57
54
  parsedConfig.siteDescription !== '',
58
55
  };
56
+ return {
57
+ siteConfig,
58
+ plugins: Array.isArray(parsedConfig.plugins) ? parsedConfig.plugins : [],
59
+ configFilePath,
60
+ configModulePath: isCodeConfigPath(configFilePath) ? configFilePath : undefined,
61
+ };
59
62
  }
60
63
  export async function applySiteConfigFrontmatterDefaults(store, siteConfig) {
61
64
  if (siteConfig.siteTitleConfigured && siteConfig.siteDescriptionConfigured) {
@@ -84,11 +87,11 @@ export async function applySiteConfigFrontmatterDefaults(store, siteConfig) {
84
87
  return siteConfig;
85
88
  }
86
89
  async function resolveDefaultConfigPath(cwd, rootDir) {
87
- const rootConfigPath = rootDir ? path.join(rootDir, 'mdorigin.config.json') : null;
88
- if (rootConfigPath && (await pathExists(rootConfigPath))) {
90
+ const rootConfigPath = rootDir ? await findConfigPath(rootDir) : null;
91
+ if (rootConfigPath) {
89
92
  return rootConfigPath;
90
93
  }
91
- return path.join(cwd, 'mdorigin.config.json');
94
+ return (await findConfigPath(cwd)) ?? path.join(cwd, 'mdorigin.config.json');
92
95
  }
93
96
  function isBuiltInThemeName(value) {
94
97
  return value === 'paper' || value === 'atlas' || value === 'gazette';
@@ -104,7 +107,7 @@ function isNodeNotFound(error) {
104
107
  }
105
108
  async function pathExists(filePath) {
106
109
  try {
107
- await readFile(filePath, 'utf8');
110
+ await stat(filePath);
108
111
  return true;
109
112
  }
110
113
  catch (error) {
@@ -114,6 +117,61 @@ async function pathExists(filePath) {
114
117
  throw error;
115
118
  }
116
119
  }
120
+ async function findConfigPath(directory) {
121
+ for (const candidate of getConfigCandidates(directory)) {
122
+ if (await pathExists(candidate)) {
123
+ return candidate;
124
+ }
125
+ }
126
+ return null;
127
+ }
128
+ function getConfigCandidates(directory) {
129
+ return [
130
+ path.join(directory, 'mdorigin.config.ts'),
131
+ path.join(directory, 'mdorigin.config.mjs'),
132
+ path.join(directory, 'mdorigin.config.js'),
133
+ path.join(directory, 'mdorigin.config.json'),
134
+ ];
135
+ }
136
+ async function loadConfigSource(configFilePath) {
137
+ if (!(await pathExists(configFilePath))) {
138
+ return {};
139
+ }
140
+ if (configFilePath.endsWith('.json')) {
141
+ const configSource = await readFile(configFilePath, 'utf8');
142
+ return JSON.parse(configSource);
143
+ }
144
+ const imported = configFilePath.endsWith('.ts')
145
+ ? await tsImport(configFilePath, import.meta.url)
146
+ : await import(pathToFileURL(configFilePath).href);
147
+ const config = unwrapConfigModule(imported);
148
+ if (typeof config !== 'object' || config === null) {
149
+ throw new Error(`${configFilePath} must export a config object`);
150
+ }
151
+ return config;
152
+ }
153
+ function isCodeConfigPath(filePath) {
154
+ return /\.(mjs|js|ts)$/.test(filePath);
155
+ }
156
+ export function defineConfig(config) {
157
+ return config;
158
+ }
159
+ function unwrapConfigModule(moduleValue) {
160
+ let current = moduleValue;
161
+ while (typeof current === 'object' &&
162
+ current !== null &&
163
+ 'default' in current &&
164
+ current.default !== undefined) {
165
+ current = current.default;
166
+ }
167
+ if (typeof current === 'object' &&
168
+ current !== null &&
169
+ 'config' in current &&
170
+ current.config !== undefined) {
171
+ return current.config;
172
+ }
173
+ return current;
174
+ }
117
175
  function normalizeTopNav(value) {
118
176
  if (!Array.isArray(value)) {
119
177
  return [];
@@ -7,6 +7,7 @@ export interface RenderDocumentOptions {
7
7
  siteDescription?: string;
8
8
  siteUrl?: string;
9
9
  favicon?: string;
10
+ socialImage?: string;
10
11
  logo?: SiteLogo;
11
12
  title: string;
12
13
  body: string;
@@ -29,6 +30,8 @@ export interface RenderDocumentOptions {
29
30
  catalogInitialPostCount?: number;
30
31
  catalogLoadMoreStep?: number;
31
32
  searchEnabled?: boolean;
33
+ headerHtml?: string;
34
+ footerHtml?: string;
32
35
  }
33
36
  export declare function renderDocument(options: RenderDocumentOptions): string;
34
37
  export declare function escapeHtml(value: string): string;
@@ -14,6 +14,14 @@ export function renderDocument(options) {
14
14
  const faviconMeta = options.favicon
15
15
  ? `<link rel="icon" href="${escapeHtml(options.favicon)}">`
16
16
  : '';
17
+ const absoluteSocialImageUrl = getAbsoluteSiteAssetUrl(options.siteUrl, options.socialImage);
18
+ const socialImageMeta = absoluteSocialImageUrl
19
+ ? [
20
+ `<meta property="og:image" content="${escapeHtml(absoluteSocialImageUrl)}">`,
21
+ '<meta name="twitter:card" content="summary_large_image">',
22
+ `<meta name="twitter:image" content="${escapeHtml(absoluteSocialImageUrl)}">`,
23
+ ].join('')
24
+ : '';
17
25
  const alternateMarkdownMeta = options.alternateMarkdownPath
18
26
  ? `<link rel="alternate" type="text/markdown" href="${escapeHtml(options.alternateMarkdownPath)}">`
19
27
  : '';
@@ -78,6 +86,9 @@ export function renderDocument(options) {
78
86
  })
79
87
  : options.body;
80
88
  const searchScript = options.searchEnabled ? renderSearchScript() : '';
89
+ const headerBlock = options.headerHtml ??
90
+ `<header class="site-header"><div class="site-header__inner"><div class="site-header__brand"><p class="site-header__title"><a href="${brandHref}">${logoBlock}<span>${siteTitle}</span></a></p>${siteDescriptionBlock}</div><div class="site-header__actions">${navBlock}${searchToggleBlock}</div></div></header>`;
91
+ const renderedFooterBlock = options.footerHtml ?? footerBlock;
81
92
  return [
82
93
  '<!doctype html>',
83
94
  '<html lang="en">',
@@ -88,20 +99,33 @@ export function renderDocument(options) {
88
99
  summaryMeta,
89
100
  canonicalMeta,
90
101
  faviconMeta,
102
+ socialImageMeta,
91
103
  alternateMarkdownMeta,
92
104
  stylesheetBlock,
93
105
  '</head>',
94
106
  `<body data-theme="${options.theme}" data-template="${options.template}">`,
95
- `<header class="site-header"><div class="site-header__inner"><div class="site-header__brand"><p class="site-header__title"><a href="${brandHref}">${logoBlock}<span>${siteTitle}</span></a></p>${siteDescriptionBlock}</div><div class="site-header__actions">${navBlock}${searchToggleBlock}</div></div></header>`,
107
+ headerBlock,
96
108
  '<main>',
97
109
  `<article>${articleBody}</article>`,
98
110
  '</main>',
99
- footerBlock,
111
+ renderedFooterBlock,
100
112
  searchScript,
101
113
  '</body>',
102
114
  '</html>',
103
115
  ].join('');
104
116
  }
117
+ function getAbsoluteSiteAssetUrl(siteUrl, href) {
118
+ if (!href) {
119
+ return undefined;
120
+ }
121
+ if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href) || href.startsWith('//')) {
122
+ return href;
123
+ }
124
+ if (!siteUrl) {
125
+ return undefined;
126
+ }
127
+ return new URL(href.replace(/^\//, ''), `${siteUrl}/`).toString();
128
+ }
105
129
  export function escapeHtml(value) {
106
130
  return value
107
131
  .replaceAll('&', '&amp;')
@@ -1,13 +1,15 @@
1
+ import { type MdoPlugin } from './core/extensions.js';
1
2
  export interface BuildIndexOptions {
2
3
  rootDir?: string;
3
4
  dir?: string;
5
+ plugins?: MdoPlugin[];
4
6
  }
5
7
  export interface BuildIndexResult {
6
8
  updatedFiles: string[];
7
9
  skippedDirectories: string[];
8
10
  }
9
11
  export declare function buildDirectoryIndexes(options: BuildIndexOptions): Promise<BuildIndexResult>;
10
- export declare function buildManagedIndexBlock(directoryPath: string): Promise<string>;
12
+ export declare function buildManagedIndexBlock(directoryPath: string, plugins?: MdoPlugin[]): Promise<string>;
11
13
  export declare function upsertManagedIndexBlock(source: string, block: string, options?: {
12
14
  directoryPath?: string;
13
15
  }): string;
@@ -1,5 +1,6 @@
1
1
  import { readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { applyIndexTransforms } from './core/extensions.js';
3
4
  import { inferDirectoryContentType } from './core/content-type.js';
4
5
  import { getDirectoryIndexCandidates } from './core/directory-index.js';
5
6
  import { getDocumentSummary, getDocumentTitle, parseMarkdownDocument, } from './core/markdown.js';
@@ -16,6 +17,7 @@ export async function buildDirectoryIndexes(options) {
16
17
  const directoryPath = path.resolve(options.dir);
17
18
  const updatedFile = await updateSingleDirectoryIndex(directoryPath, {
18
19
  createIfMissing: false,
20
+ plugins: options.plugins ?? [],
19
21
  });
20
22
  return {
21
23
  updatedFiles: updatedFile ? [updatedFile] : [],
@@ -29,6 +31,7 @@ export async function buildDirectoryIndexes(options) {
29
31
  for (const directoryPath of directories) {
30
32
  const updatedFile = await updateSingleDirectoryIndex(directoryPath, {
31
33
  createIfMissing: false,
34
+ plugins: options.plugins ?? [],
32
35
  });
33
36
  if (updatedFile) {
34
37
  updatedFiles.push(updatedFile);
@@ -55,7 +58,7 @@ async function updateSingleDirectoryIndex(directoryPath, options) {
55
58
  const existingContent = indexFilePath
56
59
  ? await readFile(indexFilePath, 'utf8')
57
60
  : '';
58
- const block = await buildManagedIndexBlock(directoryPath);
61
+ const block = await buildManagedIndexBlock(directoryPath, options.plugins);
59
62
  const nextContent = upsertManagedIndexBlock(existingContent, block, {
60
63
  directoryPath,
61
64
  });
@@ -64,7 +67,7 @@ async function updateSingleDirectoryIndex(directoryPath, options) {
64
67
  }
65
68
  return targetFilePath;
66
69
  }
67
- export async function buildManagedIndexBlock(directoryPath) {
70
+ export async function buildManagedIndexBlock(directoryPath, plugins = []) {
68
71
  const entries = await readdir(directoryPath, { withFileTypes: true });
69
72
  const directories = [];
70
73
  const articles = [];
@@ -118,7 +121,23 @@ export async function buildManagedIndexBlock(directoryPath) {
118
121
  }
119
122
  directories.sort(compareDirectories);
120
123
  articles.sort(compareArticles);
121
- return renderManagedIndexBlock(directories, articles);
124
+ const transformedEntries = await applyIndexTransforms([
125
+ ...directories.map((entry) => ({
126
+ kind: 'directory',
127
+ title: entry.title,
128
+ href: entry.link,
129
+ })),
130
+ ...articles.map((entry) => ({
131
+ kind: 'article',
132
+ title: entry.title,
133
+ href: entry.link,
134
+ detail: [entry.date, entry.summary].filter(Boolean).join(' · ') || undefined,
135
+ })),
136
+ ], plugins, {
137
+ mode: 'build',
138
+ directoryPath,
139
+ });
140
+ return renderManagedIndexBlock(transformedEntries);
122
141
  }
123
142
  export function upsertManagedIndexBlock(source, block, options = {}) {
124
143
  const hasStart = source.includes(INDEX_START_MARKER);
@@ -137,20 +156,13 @@ export function upsertManagedIndexBlock(source, block, options = {}) {
137
156
  }
138
157
  return `${trimmed}\n\n${block}\n`;
139
158
  }
140
- function renderManagedIndexBlock(directories, articles) {
159
+ function renderManagedIndexBlock(entries) {
141
160
  const lines = [INDEX_START_MARKER, ''];
142
- if (directories.length > 0) {
143
- for (const entry of directories) {
144
- lines.push(`- [${entry.title}](${entry.link})`);
145
- }
146
- lines.push('');
147
- }
148
- if (articles.length > 0) {
149
- for (const article of articles) {
150
- lines.push(`- [${article.title}](${article.link})`);
151
- const detail = [article.date, article.summary].filter(Boolean).join(' · ');
152
- if (detail !== '') {
153
- lines.push(` ${detail}`);
161
+ if (entries.length > 0) {
162
+ for (const entry of entries) {
163
+ lines.push(`- [${entry.title}](${entry.href})`);
164
+ if (entry.detail) {
165
+ lines.push(` ${entry.detail}`);
154
166
  }
155
167
  lines.push('');
156
168
  }
@@ -0,0 +1,3 @@
1
+ export { defineConfig, type SiteConfig, type UserSiteConfig, type SiteNavItem, type SiteLogo, type SiteSocialLink, type EditLinkConfig, } from './core/site-config.js';
2
+ export type { MdoPlugin, PageRenderModel, RenderHookContext, IndexTransformContext, } from './core/extensions.js';
3
+ export type { ParsedDocumentMeta } from './core/markdown.js';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { defineConfig, } from './core/site-config.js';
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "mdorigin",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
- "description": "Markdown-first publishing engine with raw Markdown and HTML views.",
5
+ "description": "Markdown-first publishing for humans and agents.",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/jolestar/mdorigin.git"
9
9
  },
10
- "homepage": "https://github.com/jolestar/mdorigin#readme",
10
+ "homepage": "https://mdorigin.jolestar.workers.dev",
11
11
  "bugs": {
12
12
  "url": "https://github.com/jolestar/mdorigin/issues"
13
13
  },
@@ -16,12 +16,18 @@
16
16
  "publishing",
17
17
  "static-site",
18
18
  "cloudflare-workers",
19
+ "agents",
20
+ "skills",
19
21
  "cli"
20
22
  ],
21
23
  "bin": {
22
24
  "mdorigin": "dist/cli/main.js"
23
25
  },
24
26
  "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ },
25
31
  "./search": {
26
32
  "types": "./dist/search.d.ts",
27
33
  "default": "./dist/search.js"
@@ -62,11 +68,11 @@
62
68
  "gray-matter": "^4.0.3",
63
69
  "remark": "^15.0.1",
64
70
  "remark-gfm": "^4.0.1",
65
- "remark-html": "^16.0.1"
71
+ "remark-html": "^16.0.1",
72
+ "tsx": "^4.20.5"
66
73
  },
67
74
  "devDependencies": {
68
75
  "@types/node": "^24.5.2",
69
- "tsx": "^4.20.5",
70
76
  "typescript": "^5.9.2"
71
77
  },
72
78
  "optionalDependencies": {