diff --git a/docs/site/concepts/routing.md b/docs/site/concepts/routing.md index c9cfb25..ee55efd 100644 --- a/docs/site/concepts/routing.md +++ b/docs/site/concepts/routing.md @@ -24,6 +24,7 @@ docs/site/ - skill homepage -> `/skill-name/` using `SKILL.md` when neither `index.md` nor `README.md` exists - `guides/getting-started.md` -> `/guides/getting-started.md`, `/guides/getting-started.html`, `/guides/getting-started` - sitemap -> `/sitemap.xml` +- RSS feed -> `/feed.xml` when `siteUrl` is configured and RSS is not disabled If a directory has no `index.md`, the current runtime can still render a minimal fallback listing for browsing. @@ -31,6 +32,8 @@ The content tree may also include directory symlinks. `mdorigin` follows them in `/sitemap.xml` emits canonical HTML URLs, not `.md` source URLs. It requires `siteUrl` so the sitemap can use absolute locations. +`/feed.xml` also requires `siteUrl`, because feed items use absolute canonical URLs. + Rendered HTML also exposes the source markdown path with: ```html @@ -39,6 +42,12 @@ Rendered HTML also exposes the source markdown path with: This is a lightweight interoperability hint for agents and tools that want to discover the raw markdown source from the human HTML page. +When RSS is enabled, rendered HTML also exposes feed autodiscovery: + +```html + +``` + ## Canonical markdown paths Directory homepages support `index.md`, `README.md`, and `SKILL.md`, but only one can act as the effective source file for a given directory. diff --git a/docs/site/guides/getting-started.md b/docs/site/guides/getting-started.md index 4cc2d51..4d943a4 100644 --- a/docs/site/guides/getting-started.md +++ b/docs/site/guides/getting-started.md @@ -185,6 +185,31 @@ The built-in presentation is now fixed to the default atlas baseline. Configure } ``` +Once `siteUrl` is set, `mdorigin` also enables: + +- `/sitemap.xml` +- `/feed.xml` + +If you want to turn RSS off or override feed metadata, add: + +```json +{ + "rss": { + "title": "Example Feed", + "description": "Latest updates from Example", + "maxItems": 20 + } +} +``` + +Or disable it entirely: + +```json +{ + "rss": false +} +``` + If a page contains a managed index block, the default renderer automatically presents it as a structured listing. `mdorigin` no longer exposes built-in theme/template variants as product configuration. If you want to start using code-based extensions now, switch from JSON config to `mdorigin.config.ts` and export a config object with `plugins`. diff --git a/docs/site/reference/configuration.md b/docs/site/reference/configuration.md index c7942e5..7648980 100644 --- a/docs/site/reference/configuration.md +++ b/docs/site/reference/configuration.md @@ -118,6 +118,7 @@ The design boundary is: - `siteUrl` sets the canonical site origin and is used for canonical links in rendered HTML. - `siteUrl` also enables `/sitemap.xml`, which emits absolute canonical URLs. +- `siteUrl` also enables `/feed.xml` by default unless RSS is explicitly disabled. - `favicon` adds a standard favicon link tag. - `socialImage` emits absolute `og:image` and `twitter:image` metadata when `siteUrl` is set. - `logo` renders a small site logo in the header. @@ -136,6 +137,37 @@ Example: } ``` +## RSS + +`mdorigin` can emit a built-in RSS feed at `/feed.xml`. + +Rules: + +- if `siteUrl` is set, RSS is enabled by default +- set `"rss": false` to disable the feed +- the feed emits dated post content, not every page in the tree +- rendered HTML adds an RSS autodiscovery `` when the feed is enabled + +Optional overrides: + +```json +{ + "rss": { + "title": "Example Feed", + "description": "Latest updates from Example", + "author": "editor@example.com", + "maxItems": 20 + } +} +``` + +Supported fields: + +- `rss.title` +- `rss.description` +- `rss.author` +- `rss.maxItems` + ## Footer `mdorigin` supports a small set of explicit footer settings: diff --git a/skills/mdorigin/SKILL.md b/skills/mdorigin/SKILL.md index 852eeaf..73c2900 100644 --- a/skills/mdorigin/SKILL.md +++ b/skills/mdorigin/SKILL.md @@ -29,6 +29,7 @@ npm install --save-dev mdorigin - Cloudflare Worker bundle output with `mdorigin build cloudflare` - external binary deployment flow for Cloudflare Assets + R2 - markdown and HTML route behavior, including `Accept: text/markdown` +- built-in `/sitemap.xml` and `/feed.xml` when `siteUrl` is configured ## Quick commands diff --git a/src/core/request-handler.test.ts b/src/core/request-handler.test.ts index 1474cd1..306bea1 100644 --- a/src/core/request-handler.test.ts +++ b/src/core/request-handler.test.ts @@ -446,6 +446,113 @@ test('handleSiteRequest returns an error for sitemap.xml when siteUrl is missing assert.match(String(response.body), /siteUrl/); }); +test('handleSiteRequest renders feed.xml for dated posts and adds autodiscovery link', async () => { + const store = new MemoryContentStore([ + { + path: 'README.md', + kind: 'text', + mediaType: 'text/markdown; charset=utf-8', + text: ['---', 'title: Home', 'summary: Site summary', '---', '', '# Home'].join('\n'), + }, + { + path: 'posts/new.md', + kind: 'text', + mediaType: 'text/markdown; charset=utf-8', + text: [ + '---', + 'title: New Post', + 'date: 2026-03-22', + 'summary: Fresh summary', + '---', + '', + '# New Post', + ].join('\n'), + }, + { + path: 'posts/old.md', + kind: 'text', + mediaType: 'text/markdown; charset=utf-8', + text: [ + '---', + 'title: Old Post', + 'date: 2026-03-20', + '---', + '', + 'First paragraph excerpt.', + ].join('\n'), + }, + { + path: 'guides/page.md', + kind: 'text', + mediaType: 'text/markdown; charset=utf-8', + text: ['---', 'title: Guide', 'date: 2026-03-21', 'type: page', '---', '', '# Guide'].join('\n'), + }, + { + path: 'draft.md', + kind: 'text', + mediaType: 'text/markdown; charset=utf-8', + text: ['---', 'title: Draft', 'date: 2026-03-23', 'draft: true', '---', '', '# Draft'].join('\n'), + }, + ]); + + const siteConfig = { + ...TEST_SITE_CONFIG, + siteUrl: 'https://example.com', + siteDescription: 'Configured site description', + }; + + const feedResponse = await handleSiteRequest(store, '/feed.xml', { + draftMode: 'exclude', + siteConfig, + }); + + assert.equal(feedResponse.status, 200); + assert.equal(feedResponse.headers['content-type'], 'application/rss+xml; charset=utf-8'); + const feedBody = String(feedResponse.body); + assert.match(feedBody, /Test Site<\/title>/); + assert.match(feedBody, /<link>https:\/\/example\.com<\/link>/); + assert.match(feedBody, /<atom:link href="https:\/\/example\.com\/feed\.xml"/); + assert.match(feedBody, /<title>New Post<\/title>[\s\S]*<title>Old Post<\/title>/); + assert.match(feedBody, /<guid isPermaLink="true">https:\/\/example\.com\/posts\/new<\/guid>/); + assert.match(feedBody, /<description>Fresh summary<\/description>/); + assert.match(feedBody, /<description>First paragraph excerpt\.<\/description>/); + assert.doesNotMatch(feedBody, /Guide/); + assert.doesNotMatch(feedBody, /Draft/); + + const htmlResponse = await handleSiteRequest(store, '/posts/new.html', { + draftMode: 'exclude', + siteConfig, + }); + assert.equal(htmlResponse.status, 200); + assert.match( + String(htmlResponse.body), + /<link rel="alternate" type="application\/rss\+xml" title="Test Site" href="https:\/\/example\.com\/feed\.xml">/, + ); +}); + +test('handleSiteRequest returns 404 for feed.xml when siteUrl is missing or rss is disabled', async () => { + const store = new MemoryContentStore([]); + + const missingSiteUrl = await handleSiteRequest(store, '/feed.xml', { + draftMode: 'exclude', + siteConfig: TEST_SITE_CONFIG, + }); + assert.equal(missingSiteUrl.status, 404); + + const disabledRss = await handleSiteRequest(store, '/feed.xml', { + draftMode: 'exclude', + siteConfig: { + ...TEST_SITE_CONFIG, + siteUrl: 'https://example.com', + rss: { + enabled: false, + maxItems: 20, + }, + }, + }); + assert.equal(disabledRss.status, 404); +}); + test('handleSiteRequest serves OpenAPI schema for search', async () => { const response = await handleSiteRequest( new MemoryContentStore([]), diff --git a/src/core/request-handler.ts b/src/core/request-handler.ts index 37ede71..5ed99e8 100644 --- a/src/core/request-handler.ts +++ b/src/core/request-handler.ts @@ -6,7 +6,10 @@ import type { ContentStore, } from './content-store.js'; import { isIgnoredContentName } from './content-store.js'; -import { inferDirectoryContentType } from './content-type.js'; +import { + inferDirectoryContentType, + resolveContentType, +} from './content-type.js'; import { getDirectoryIndexCandidates } from './directory-index.js'; import { extractManagedIndexEntries, @@ -15,7 +18,9 @@ import { parseMarkdownDocument, stripManagedIndexBlock, stripManagedIndexLinks, + stripMachineOnlyMarkdownComments, } from './markdown.js'; +import { ensureTrailingSlash, trimLeadingSlash } from './site-url.js'; import type { MdoPlugin, PageRenderModel, RenderHookContext } from './extensions.js'; import { applyIndexTransforms, @@ -70,6 +75,10 @@ export async function handleSiteRequest( return renderSitemap(store, options); } + if (pathname === '/feed.xml') { + return renderRssFeed(store, options); + } + const resolved = resolveRequest(pathname); const listingFragmentRequest = getListingFragmentRequest(options.searchParams); const negotiatedMarkdown = shouldServeMarkdownForRequest( @@ -357,6 +366,7 @@ async function renderStructuredPage(options: { stylesheetContent: currentPage.stylesheetContent, canonicalPath: currentPage.canonicalPath, alternateMarkdownPath: currentPage.alternateMarkdownPath, + rssFeedUrl: getRssFeedUrl(currentPage.siteUrl, options.siteConfig), listingEntries: currentPage.listingEntries, listingRequestPath: currentPage.listingRequestPath, listingInitialPostCount: currentPage.listingInitialPostCount, @@ -491,6 +501,76 @@ async function renderSitemap( }; } +interface FeedItem { + title: string; + canonicalPath: string; + absoluteUrl: string; + summary?: string; + pubDate: Date; +} + +async function renderRssFeed( + store: ContentStore, + options: HandleSiteRequestOptions, +): Promise<SiteResponse> { + if (!isRssEnabled(options.siteConfig) || !options.siteConfig.siteUrl) { + return notFound(); + } + + const items = await collectRssFeedItems(store, '', options); + const limitedItems = items.slice(0, options.siteConfig.rss?.maxItems ?? 20); + const rssFeedUrl = getRssFeedUrl(options.siteConfig.siteUrl, options.siteConfig); + const title = options.siteConfig.rss?.title ?? options.siteConfig.siteTitle; + const description = + options.siteConfig.rss?.description ?? options.siteConfig.siteDescription; + const lastBuildDate = limitedItems[0]?.pubDate.toUTCString(); + const body = [ + '<?xml version="1.0" encoding="UTF-8"?>', + '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">', + '<channel>', + ` <title>${escapeHtml(title)}`, + ` ${escapeHtml(options.siteConfig.siteUrl)}`, + description + ? ` ${escapeHtml(description)}` + : ' ', + ' mdorigin', + rssFeedUrl + ? ` ` + : '', + options.siteConfig.rss?.author + ? ` ${escapeHtml(options.siteConfig.rss.author)}` + : '', + lastBuildDate ? ` ${escapeHtml(lastBuildDate)}` : '', + ...limitedItems.map((item) => + [ + ' ', + ` ${escapeHtml(item.title)}`, + ` ${escapeHtml(item.absoluteUrl)}`, + ` ${escapeHtml(item.absoluteUrl)}`, + ` ${escapeHtml(item.pubDate.toUTCString())}`, + item.summary + ? ` ${escapeHtml(item.summary)}` + : '', + ' ', + ] + .filter((line) => line !== '') + .join('\n'), + ), + '', + '', + ] + .filter((line) => line !== '') + .join('\n'); + + return { + status: 200, + headers: { + 'content-type': 'application/rss+xml; charset=utf-8', + }, + body, + }; +} + function withVaryAcceptIfNeeded( headers: Record, enabled: boolean, @@ -593,6 +673,106 @@ function dedupeSitemapEntries(entries: SitemapEntry[]): SitemapEntry[] { return Array.from(deduped.values()); } +async function collectRssFeedItems( + store: ContentStore, + directoryPath: string, + options: HandleSiteRequestOptions, +): Promise { + const entries = await store.listDirectory(directoryPath); + if (entries === null) { + return []; + } + + const feedItems: FeedItem[] = []; + const directoryShape = inspectDirectoryShapeEntries(entries); + + for (const entry of entries) { + if (entry.kind === 'directory') { + feedItems.push(...(await collectRssFeedItems(store, entry.path, options))); + continue; + } + + if (!isMarkdownEntry(entry)) { + continue; + } + + const document = await store.get(entry.path); + if (document === null || document.kind !== 'text' || document.text === undefined) { + continue; + } + + const parsed = await parseMarkdownDocument(entry.path, document.text); + if (parsed.meta.draft === true && options.draftMode === 'exclude') { + continue; + } + + const pubDate = parseFeedDate(parsed.meta.date); + if (pubDate === null) { + continue; + } + + const contentType = inferFeedContentType(entry.path, parsed.meta, directoryShape); + if (contentType !== 'post') { + continue; + } + + const canonicalPath = getCanonicalHtmlPathForContentPath(entry.path); + feedItems.push({ + title: getDocumentTitle(parsed), + canonicalPath, + absoluteUrl: new URL( + trimLeadingSlash(canonicalPath), + ensureTrailingSlash(options.siteConfig.siteUrl ?? ''), + ).toString(), + summary: getFeedSummary(parsed), + pubDate, + }); + } + + feedItems.sort((left, right) => { + const timeDelta = right.pubDate.getTime() - left.pubDate.getTime(); + return timeDelta !== 0 + ? timeDelta + : left.canonicalPath.localeCompare(right.canonicalPath); + }); + return feedItems; +} + +function inferFeedContentType( + contentPath: string, + meta: Awaited>['meta'], + directoryShape: Awaited>, +): 'page' | 'post' { + const explicitType = resolveContentType(meta); + if (explicitType) { + return explicitType; + } + + if (isDirectoryIndexContentPath(contentPath)) { + return inferDirectoryContentType(meta, directoryShape); + } + + return typeof meta.date === 'string' && meta.date !== '' ? 'post' : 'page'; +} + +function getFeedSummary( + parsed: Awaited>, +): string | undefined { + return getDocumentSummary( + parsed.meta, + stripMachineOnlyMarkdownComments(stripManagedIndexBlock(parsed.body)), + ); +} + +function parseFeedDate(value: unknown): Date | null { + if (typeof value !== 'string' || value.trim() === '') { + return null; + } + + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + async function renderDirectoryListing( store: ContentStore, requestPath: string, @@ -641,6 +821,7 @@ async function renderDirectoryListing( alternateMarkdownPath: getMarkdownRequestPathForContentPath( getDirectoryIndexContentPathForRequestPath(requestPath), ), + rssFeedUrl: getRssFeedUrl(siteConfig.siteUrl, siteConfig), searchEnabled, }), }; @@ -949,6 +1130,11 @@ function isMarkdownEntry(entry: ContentDirectoryEntry): boolean { return path.posix.extname(entry.name).toLowerCase() === '.md'; } +function isDirectoryIndexContentPath(contentPath: string): boolean { + const basename = path.posix.basename(contentPath).toLowerCase(); + return basename === 'index.md' || basename === 'readme.md' || basename === 'skill.md'; +} + function normalizeAliases(aliases: unknown): string[] { if (!Array.isArray(aliases)) { return []; @@ -966,7 +1152,11 @@ function normalizeAliases(aliases: unknown): string[] { function getCanonicalHtmlPathForContentPath(contentPath: string): string { const basename = path.posix.basename(contentPath).toLowerCase(); - if (basename === 'index.md' || basename === 'readme.md') { + if ( + basename === 'index.md' || + basename === 'readme.md' || + basename === 'skill.md' + ) { const directory = path.posix.dirname(contentPath); return directory === '.' ? '/' : `/${directory}/`; } @@ -974,6 +1164,21 @@ function getCanonicalHtmlPathForContentPath(contentPath: string): string { return `/${contentPath.slice(0, -'.md'.length)}`; } +function isRssEnabled(siteConfig: ResolvedSiteConfig): boolean { + return Boolean(siteConfig.siteUrl) && (siteConfig.rss?.enabled ?? true); +} + +function getRssFeedUrl( + siteUrl: string | undefined, + siteConfig: ResolvedSiteConfig, +): string | undefined { + if (!siteUrl || !isRssEnabled(siteConfig)) { + return undefined; + } + + return new URL('feed.xml', ensureTrailingSlash(siteUrl)).toString(); +} + function getEditLinkHref( siteConfig: ResolvedSiteConfig, sourcePath: string | undefined, @@ -1088,6 +1293,17 @@ async function inspectDirectoryShape( }; } + return inspectDirectoryShapeEntries(entries); +} + +function inspectDirectoryShapeEntries( + entries: readonly ContentDirectoryEntry[], +): { + hasSkillIndex: boolean; + hasChildDirectories: boolean; + hasExtraMarkdownFiles: boolean; + hasAssetFiles: boolean; +} { let hasSkillIndex = false; let hasChildDirectories = false; let hasExtraMarkdownFiles = false; diff --git a/src/core/site-config.test.ts b/src/core/site-config.test.ts index 1ce0076..ceecc09 100644 --- a/src/core/site-config.test.ts +++ b/src/core/site-config.test.ts @@ -139,6 +139,64 @@ test('applySiteConfigFrontmatterDefaults does not override explicit config value assert.equal(resolved.siteDescription, 'Configured Description'); }); +test('loadSiteConfig auto-enables rss when siteUrl is present and normalizes overrides', async () => { + const rootDir = await mkdtemp(path.join(tmpdir(), 'mdorigin-config-rss-')); + await writeFile( + path.join(rootDir, 'mdorigin.config.json'), + JSON.stringify( + { + siteUrl: 'https://example.com', + rss: { + title: 'Example Feed', + description: 'Latest posts', + author: 'editor@example.com', + maxItems: '12', + }, + }, + null, + 2, + ), + 'utf8', + ); + + const config = await loadSiteConfig({ rootDir }); + + assert.equal(config.siteUrl, 'https://example.com'); + assert.deepEqual(config.rss, { + enabled: true, + title: 'Example Feed', + description: 'Latest posts', + author: 'editor@example.com', + maxItems: 12, + }); +}); + +test('loadSiteConfig allows rss to be disabled explicitly', async () => { + const rootDir = await mkdtemp(path.join(tmpdir(), 'mdorigin-config-rss-disabled-')); + await writeFile( + path.join(rootDir, 'mdorigin.config.json'), + JSON.stringify( + { + siteUrl: 'https://example.com', + rss: false, + }, + null, + 2, + ), + 'utf8', + ); + + const config = await loadSiteConfig({ rootDir }); + + assert.deepEqual(config.rss, { + enabled: false, + title: undefined, + description: undefined, + author: undefined, + maxItems: 20, + }); +}); + test('loadUserSiteConfig prefers mdorigin.config.ts and exposes plugins', async () => { const rootDir = await mkdtemp(path.join(tmpdir(), 'mdorigin-config-ts-')); const warnings: string[] = []; diff --git a/src/core/site-config.ts b/src/core/site-config.ts index c08f49a..94ad12b 100644 --- a/src/core/site-config.ts +++ b/src/core/site-config.ts @@ -29,6 +29,21 @@ export interface EditLinkConfig { baseUrl: string; } +export interface SiteRssConfigInput { + title?: string; + description?: string; + author?: string; + maxItems?: number; +} + +export interface SiteRssConfig { + enabled: boolean; + title?: string; + description?: string; + author?: string; + maxItems: number; +} + export interface SiteSearchRerankerConfig { kind?: 'embedding-v1' | 'heuristic-v1'; candidatePoolSize?: number; @@ -82,6 +97,7 @@ export interface SiteConfig { footerText?: string; socialLinks?: SiteSocialLink[]; editLink?: EditLinkConfig; + rss?: false | SiteRssConfigInput; showHomeIndex?: boolean; listingInitialPostCount?: number; listingLoadMoreStep?: number; @@ -106,6 +122,7 @@ export interface ResolvedSiteConfig { footerText?: string; socialLinks: SiteSocialLink[]; editLink?: EditLinkConfig; + rss?: SiteRssConfig; showHomeIndex: boolean; listingInitialPostCount: number; listingLoadMoreStep: number; @@ -177,6 +194,7 @@ export async function loadUserSiteConfig( : undefined, socialLinks: normalizeSocialLinks(parsedConfig.socialLinks), editLink: normalizeEditLink(parsedConfig.editLink), + rss: normalizeRssConfig(parsedConfig.rss), showHomeIndex: typeof parsedConfig.showHomeIndex === 'boolean' ? parsedConfig.showHomeIndex @@ -418,6 +436,40 @@ function normalizeOptionalNumber(value: unknown): number | undefined { return undefined; } +function normalizeRssConfig(value: unknown): SiteRssConfig { + if (value === false) { + return { + enabled: false, + title: undefined, + description: undefined, + author: undefined, + maxItems: 20, + }; + } + + if (typeof value !== 'object' || value === null) { + return { + enabled: true, + title: undefined, + description: undefined, + author: undefined, + maxItems: 20, + }; + } + + const rss = value as Record; + return { + enabled: true, + title: typeof rss.title === 'string' && rss.title !== '' ? rss.title : undefined, + description: + typeof rss.description === 'string' && rss.description !== '' + ? rss.description + : undefined, + author: typeof rss.author === 'string' && rss.author !== '' ? rss.author : undefined, + maxItems: normalizePositiveInteger(rss.maxItems, 20), + }; +} + function normalizeSearchConfig( value: unknown, configFilePath: string, diff --git a/src/core/site-url.ts b/src/core/site-url.ts new file mode 100644 index 0000000..43b9cca --- /dev/null +++ b/src/core/site-url.ts @@ -0,0 +1,7 @@ +export function trimLeadingSlash(value: string): string { + return value.startsWith('/') ? value.slice(1) : value; +} + +export function ensureTrailingSlash(value: string): string { + return value.endsWith('/') ? value : `${value}/`; +} diff --git a/src/html/template.ts b/src/html/template.ts index bb2f534..522205e 100644 --- a/src/html/template.ts +++ b/src/html/template.ts @@ -27,6 +27,7 @@ export interface RenderDocumentOptions { stylesheetContent?: string; canonicalPath?: string; alternateMarkdownPath?: string; + rssFeedUrl?: string; listingEntries?: ManagedIndexEntry[]; listingRequestPath?: string; listingInitialPostCount?: number; @@ -67,6 +68,11 @@ export function renderDocument(options: RenderDocumentOptions) { options.alternateMarkdownPath, )}">` : ''; + const rssMeta = options.rssFeedUrl + ? `` + : ''; const stylesheetBlock = ``; @@ -176,6 +182,7 @@ export function renderDocument(options: RenderDocumentOptions) { faviconMeta, socialImageMeta, alternateMarkdownMeta, + rssMeta, stylesheetBlock, '', '', diff --git a/src/search.ts b/src/search.ts index d9b3cc4..bfe3e69 100644 --- a/src/search.ts +++ b/src/search.ts @@ -19,6 +19,7 @@ import { } from './core/markdown.js'; import type { ParsedDocumentMeta } from './core/markdown.js'; import { isIgnoredContentName } from './core/content-store.js'; +import { ensureTrailingSlash, trimLeadingSlash } from './core/site-url.js'; import type { ResolvedSiteConfig, SiteSearchConfig, @@ -674,14 +675,6 @@ function getCanonicalHtmlPathForContentPath(contentPath: string): string { return `/${contentPath.slice(0, -'.md'.length)}`; } -function trimLeadingSlash(value: string): string { - return value.startsWith('/') ? value.slice(1) : value; -} - -function ensureTrailingSlash(value: string): string { - return value.endsWith('/') ? value : `${value}/`; -} - async function pathExists(filePath: string): Promise { try { await stat(filePath);