@ecopages/content-processor

中文文档
0.7.1发布于 2026/11/13MITTypeScriptES Module构建来源已验证近 30 天下载 3319

Scan MDX collections at build time and expose typed ecopages:content/* virtual modules for pages and scripts

原始使用文档
文档目录19 节
  1. Mental model
  2. What this package owns vs. what your app owns
  3. Features
  4. Installation
  5. Configuration
  6. Collection options
  7. Frontmatter schema (app-owned)
  8. TypeScript setup
  9. Virtual module API
  10. Page usage
  11. Hydratable React Pages
  12. Navigation (app-side)
  13. Build scripts
  14. Package exports
  15. Generated artifacts
  16. Validation errors
  17. Dev / HMR
  18. MDX integration
  19. Content-entry ownership

@ecopages/content-processor

Build-time content collections for Ecopages. Scans MDX (or other configured extensions) at build time, validates frontmatter, and exposes each collection as a typed virtual module: ecopages:content/<collection>.

Mental model

Build contributions use one plugins(app) method. Collection modules and types are generated in setup(), and a build that resolves a collection first waits for the same generation. ecopages build emits those generated collection manifests and server modules into dist/server/ and registers them in server-modules.json. Production ecopages start restores from that map and does not call setup(). Development still regenerates collections when an MDX file is edited. The server resolver and browser resolver declare separate environments, so browser compilation cannot select the server implementation by registration order.

  1. Declare collections in eco.config.ts — content directory, frontmatter schema, and sort order.
  2. Scan at build time — the processor validates frontmatter and writes generated modules under .eco/cache/. Invalid frontmatter fails ecopages build, not the first request. Production emit copies the compiled collection modules into dist/server/.
  3. Import the virtual module — pages and layouts use ecopages:content/<collection> for entries, metadata, and MDX components.
  4. Own your frontmatter — define fields with any Standard Schema-compatible library (Zod, Valibot, ArkType, etc.). The processor adds slug and segments from the file path.

What this package owns vs. what your app owns

LayerOwned by
File discovery, frontmatter validation, manifest sort@ecopages/content-processor
Generated ecopages:content/* modules (entries, getComponent, …)@ecopages/content-processor
ContentScanner for app scripts that emit llms.txt@ecopages/content-processor
Routes (eco.page), layouts, metadata, URL shapeYour app
Sidebar, breadcrumbs, paginationYour app — derive from entries and frontmatter fields such as title, group, and order

The processor gives you a typed manifest and MDX components at build time. Wire them into Ecopages routing and UI the same way you would any other data source.

Features

  • Build-time scanning — content metadata is resolved before page bundles ship; MDX entry modules load on demand per slug on the server.
  • Standard Schema validation — frontmatter schemas stay in your app; the library validates through the Standard Schema interface.
  • Typed virtual modules — ecopages:content/<collection> with generated Entry types.
  • Multiple collections — docs, blog, changelog, or any keyed collection you configure.
  • ContentScanner — reuse the same scan/validate path in one-off build scripts. The scanner does not write llms.txt; the app script does.

During development, edits to an entry's MDX body invalidate its compiled server collection bundle even when its frontmatter and generated manifest are unchanged. The next request rebuilds that bundle and renders the new body.

The content processor owns the compiled collection artifacts and their invalidation generation. Core provides the shared server-module build path; collection identity, source tracking, and cache lifetime stay with the content processor.

Co-located non-entry files inside a collection directory (for example survey-form.config.ts, helper modules, or JSON data) also invalidate the compiled server collection without rewriting the generated entries manifest.

Installation

bun add @ecopages/content-processor

Peer dependency: @ecopages/core.

Configuration

Register the processor in eco.config.ts:

import { defineConfig } from '@ecopages/core/config';
import { contentProcessorPlugin } from '@ecopages/content-processor/plugin';
import { compareEntriesByField } from '@ecopages/content-processor';
import { blogFrontmatterSchema } from './src/content/blog-schema';
import { docsFrontmatterSchema } from './src/content/docs-schema';

export default defineConfig({
    processors: [
        contentProcessorPlugin({
            options: {
                collections: {
                    docs: {
                        contentDir: 'content/docs',
                        orderBy: compareEntriesByField('order'),
                        schema: docsFrontmatterSchema,
                        entryType: './src/content/docs-schema#DocsFrontmatter',
                    },
                    blog: {
                        contentDir: 'content/blog',
                        orderBy: compareEntriesByField('title'),
                        schema: blogFrontmatterSchema,
                        entryType: './src/content/blog-schema#BlogFrontmatter',
                        extensions: ['.mdx', '.md'],
                    },
                },
            },
        }),
    ],
});

Collection options

OptionRequiredDescription
contentDiryesDirectory relative to app srcDir, e.g. content/docs.
schemayesStandard Schema validator for frontmatter.
entryTypenoFrontmatter type for generated virtual-module types. Format: ./path/to/schema#TypeName. The processor wraps it as ContentEntry<YourFrontmatter>.
orderBynoComparator function for manifest sort. Default: {@link compareEntriesBySlug}. Use {@link compareEntriesByField} for frontmatter fields.
extensionsnoFile extensions to scan. Default: ['.mdx']. Multi-dot extensions (.radiant.mdx, .react.mdx) are matched longest-first; declaration order does not matter.
routePrefixnoPublic URL prefix for entries, e.g. /docs. Used with devPrewarm.
devPrewarmnoDev prewarm: 'first', 'all', { slugs }, or { limit }. Core SSR-prewarms after the HMR-ready pipeline exists; prewarm schedules renders only and does not control HTML cache admission.

Collection keys must be kebab-case (docs, api-reference). Each key becomes ecopages:content/<key>.

Frontmatter schema (app-owned)

Define schema and frontmatter types in your app. Zod 4 is a common choice:

// src/content/docs-schema.ts
import { z } from 'zod';

export const docsFrontmatterSchema = z.object({
    title: z.string(),
    description: z.string(),
    group: z.string().optional(),
    order: z.coerce.number().optional(),
});

export type DocsFrontmatter = z.infer<typeof docsFrontmatterSchema>;

Important: Do not add slug or segments to your schema. The processor derives them from the file path and exposes them through ContentEntry<T>:

import type { ContentEntry } from '@ecopages/content-processor';

type DocsEntry = ContentEntry<DocsFrontmatter>;
// DocsEntry includes: title, description, group?, order?, slug, segments

Point entryType at the frontmatter type (DocsFrontmatter), not a hand-rolled entry type.

TypeScript setup

Add one import to the app modules.d.ts:

import '@ecopages/content-processor/types';

That makes ecopages:content/*, /server, and /component imports resolvable immediately. Collection-specific declarations are generated at dev/build time into node_modules/@types/ecopages-content-processor, which TypeScript loads automatically.

Run ecopages dev or ecopages build before expecting precise collection entry types. Restart the TypeScript server if generated types look stale after changing schema or collection config.

Virtual module API

Each collection exposes ecopages:content/<collection> for metadata, ecopages:content/<collection>/server for server-only dependency discovery, and ecopages:content/<collection>/component for a lazy MDX component loader that works on the server and in the browser:

import { entries, getEntry, getEntryBySegments } from 'ecopages:content/docs';
import { getComponent, getEntryDependencies } from 'ecopages:content/docs/server';
import { loadComponent } from 'ecopages:content/docs/component';
import type { Entry } from 'ecopages:content/docs';
ExportModuleDescription
entriesentriesReadonly manifest of all entries, sorted by orderBy.
getEntry(slug)entriesLookup by joined slug, e.g. 'getting-started/intro'. Throws HttpError.NotFound when missing.
getEntryBySegments(segments)entriesLookup by segment array, e.g. ['getting-started', 'intro']. Throws HttpError.NotFound when missing.
getComponent(slug)serverPromise of the MDX component for the entry (lazy-loaded per slug). Rejects with HttpError.NotFound when missing.
getEntryDependencies(slug)serverReturns { components: [entry] } so collection walks MDX identity. Does not copy the dependency bag. Throws HttpError.NotFound when missing.
loadComponent(slug)componentPromise of the MDX component for the entry. Server graph: the entry wrapped for cross-Integration rendering. Page Browser Graph: the browser-compiled entry. Rejects with HttpError.NotFound when missing. loadComponent.collection is the collection name, which stays the same when HMR evaluates the Page again. Pair with createLazyCollectionComponents() from @ecopages/react/lazy-collection-components when a hydratable React Page renders the component.
EntryentriesType only. ContentEntry<YourFrontmatter> — frontmatter fields plus slug and segments.

/component is one specifier with two resolutions. The server graph backs it with the same server module as /server, so a Page that imports it shares one component per slug with getComponent(). Use it in Pages that also load the entry in the browser, such as a hydratable React Page. /component does no asset discovery: a Page that needs the entry's own assets still returns getEntryDependencies() from /server. The Page Browser Graph backs it with the browser loader and still rejects /server, so importing /component never makes server code browser-reachable.

Important: Entry exists only in generated .d.ts files, not in the runtime cache module. Keep type imports on a separate import type line in page files that get bundled. Mixed imports like import { entries, type Entry } can cause the bundler to treat Entry as a runtime export and fail with MISSING_EXPORT.

Page usage

Define a catch-all or per-entry route with eco.page. Import the collection manifest and MDX components from the virtual module:

import { eco } from '@ecopages/core';
import { entries, getEntryBySegments } from 'ecopages:content/docs';
import { getComponent, getEntryDependencies } from 'ecopages:content/docs/server';
import type { Entry } from 'ecopages:content/docs';

export default eco.page<{ entry: Entry }>({
    staticPaths: async () => ({
        paths: entries.map((post) => ({
            params: { slug: post.segments },
        })),
    }),
    staticProps: async ({ pathname }) => {
        const segments = Array.isArray(pathname.params.slug)
            ? pathname.params.slug
            : [pathname.params.slug];

        return {
            props: { entry: getEntryBySegments(segments) },
        };
    },
    dependencies: async ({ props }) => getEntryDependencies(props.entry.slug),
    metadata: ({ props: { entry } }) => ({
        title: entry.title,
        description: entry.description,
    }),
    render: async ({ entry }) => {
        const Content = await getComponent(entry.slug);
        return <Content />;
    },
});

Return getEntryDependencies alone when the Page has no relative assets of its own. Combine Page-local scripts, stylesheets, or modules with mergePageDependencies() so each file keeps ownership.

Hydratable React Pages

When a React Page must keep the MDX component in its hydrated tree, create one lazy component per slug from the /component loader and render it inside a <Suspense> boundary. The server waits for the entry and sends it in the HTML; hydration keeps that HTML until the browser has loaded the entry. getEntryDependencies() remains server-only and is stripped from the Page Browser Graph. In development, HMR of the Page module renders the already-loaded entry again without the Suspense fallback, and an edited MDX file re-renders that entry without a document reload.

import { Suspense } from 'react';
import { createLazyCollectionComponents } from '@ecopages/react/lazy-collection-components';
import { getEntry } from 'ecopages:content/docs';
import { loadComponent } from 'ecopages:content/docs/component';
import { getEntryDependencies } from 'ecopages:content/docs/server';

const docs = createLazyCollectionComponents(loadComponent);

export default eco.page({
    staticProps: async ({ pathname }) => ({ props: { entry: getEntry(pathname.params.slug) } }),
    dependencies: ({ props }) => getEntryDependencies(props.entry.slug),
    render: ({ entry }) => {
        const Content = docs.get(entry.slug);
        return (
            <Suspense fallback={null}>
                <Content />
            </Suspense>
        );
    },
});

Keep the boundary at a fixed place in the Page. During client navigation between entries, React keeps the current entry on screen until the next one has loaded, as long as the boundary is not remounted for each slug.

Unknown slugs throw HttpError.NotFound, so a catch-all that matches /docs/[...slug] still serves the app 404 page instead of a 500.

Content MDX entries may import interactive demos and styles without a handwritten config.dependencies bag. The MDX loader discovers them:

import { WeatherApp } from '@/components/weather-app/weather-app';
import './weather-app.css';

# Weather app

<WeatherApp />

getComponent() lazy-loads one MDX module per slug and returns the default component with loader-attributed config attached. Pair that with getEntryDependencies() on the catch-all page so only the active entry contributes to the Page Browser Graph.

getEntryDependencies(slug) returns { components: [entry] }. It does not copy config.dependencies and does not set ownerFile. Relative scripts, stylesheets, and modules declared on the MDX module resolve from that Component's identity rather than the catch-all route.

Combine Page-local relative assets with mergePageDependencies(). Object-spread of the two bags copies ownerFile onto the other side's relative paths:

import { eco, mergePageDependencies } from '@ecopages/core';
import { getComponent, getEntryDependencies } from 'ecopages:content/posts/server';

export default eco.page({
    dependencies: async ({ props }) =>
        mergePageDependencies({ stylesheets: ['./post.css'] }, await getEntryDependencies(props.slug)),
    render: async ({ slug }) => {
        const Content = await getComponent(slug);
        return <Content />;
    },
});

Navigation (app-side)

Navigation is app code. Map entries to links using the frontmatter and path fields the processor already validated:

import { entries } from 'ecopages:content/docs';

export const docsNav = entries.map((entry) => ({
    title: entry.title,
    href: `/docs/${entry.segments.join('/')}`,
}));

Group or sort in your app — for example by a group frontmatter field and an order number. See apps/docs (src/lib/content-nav.ts) and templates/docs-starter (src/content-nav.ts) for full layouts with sections and sidebars.

Build scripts

Use ContentScanner when a script needs the manifest outside page bundles:

import { join } from 'node:path';
import { ContentScanner, compareEntriesByField } from '@ecopages/content-processor';
import { docsFrontmatterSchema } from '../src/content/docs-schema';

const scanner = new ContentScanner({
    contentRoot: join(srcDir, 'content/docs'),
    orderBy: compareEntriesByField('order'),
    schema: docsFrontmatterSchema,
});

const manifest = await scanner.getManifest();
const raw = await scanner.getRawContent('getting-started/intro');

Package exports

ImportPurpose
@ecopages/content-processorContentScanner, sort helpers, shared types.
@ecopages/content-processor/plugincontentProcessorPlugin() for eco.config.ts.
@ecopages/content-processor/typesContentEntry, ContentCollectionModule, EntryComparator.
@ecopages/content-processor/mdxremarkFrontmatter, withContentMdxPlugins().

Generated artifacts

PathPurpose
.eco/cache/ecopages-content-processor/<collection>.tsGenerated collection module (manifest + MDX imports).
.eco/cache/ecopages-content-processor/<collection>.browser.tsBrowser-safe MDX loaders behind /component in the Page Browser Graph. Relative imports resolve from this file on disk.
.eco/.server-collections/<collection>/<collection>-<identity>.mjsLazy-built server artifact for one collection.
node_modules/@types/ecopages-content-processor/virtual-module.d.tsGenerated TypeScript declarations for virtual modules.

Do not edit generated files manually.

Validation errors

Invalid frontmatter throws SchemaError from @standard-schema/utils during scan/build. Fix the MDX frontmatter or relax the schema in your app. In development the error appears in the browser overlay, and saving the fixed file recovers the collection without restarting the server.

Dev / HMR

In development, the processor watches collection files to regenerate ecopages:content/* manifests when MDX changes. The generated server artifact is built lazily on the first server import and reused by its content-derived identity; MDX edits invalidate that artifact before the next Page build. Collection MDX is still server source: Ecopages invalidates server modules and runs HMR so page imports pick up the updated MDX component.

Watch config drives manifest regeneration only. Asset ownership (which would skip server invalidation) requires declared processor capabilities. Content-processor does not claim MDX as an asset, so no capabilities workaround is needed in eco.config.ts.

MDX integration

Content files are MDX components. Ensure your JSX or React integration is configured in eco.config.ts with an mdx option so collection entries compile as components.

Content collections with YAML frontmatter need remark-frontmatter in the MDX compile pipeline. The processor validates frontmatter at scan time (vfile-matter); MDX render time needs the remark plugin so --- blocks are not emitted as content.

Use @ecopages/content-processor/mdx on whichever integration compiles the MDX:

import { withContentMdxPlugins } from '@ecopages/content-processor/mdx';
import remarkGfm from 'remark-gfm';

ecopagesJsxPlugin({
    mdx: {
        ...withContentMdxPlugins({
            remarkPlugins: [remarkGfm],
            rehypePlugins: [/* app-specific rehype plugins */],
        }),
    },
});

reactPlugin({
    mdx: {
        ...withContentMdxPlugins(),
    },
});

withContentMdxPlugins() prepends remarkFrontmatter. Import it directly when you need finer control:

import { remarkFrontmatter } from '@ecopages/content-processor/mdx';

App-specific presentation plugins (syntax highlighting, GFM, table wrappers) stay in your app — for example src/mdx/plugins.ts.

Content-entry ownership

The integration that compiles an MDX entry also owns its rendering: an entry compiled with mdx.extensions: ['.react.mdx'] on the React integration is a React component and must be rendered through a React-owned route (for example a [...slug].react.tsx catch-all). Calling it from a JSX (Radiant) or Kita render tree cannot serialize its output.

The generated getComponent() preserves this ownership at render time: invoking an entry in its owning lane renders directly, while a supported foreign-child render lane queues the entry through its owning integration. If no handoff runtime is available, it throws with the entry path, owning integration, and active integration. When both integrations compile MDX for the same collection, declare disjoint mdx.extensions per integration — since the React MDX loader derives its filter from the declared extensions, only .react.mdx files compile with React. mdx.extensions replaces compilerOptions.mdxExtensions rather than merging with it.

版本与发布记录

@ecopages/content-processor 共发布 46 个版本,首次发布于 2026/07/14;过去 12 个月发布了 46 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.7.1latest2026/11/13今天
0.7.02026/11/094 天前
0.6.12026/10/3014 天前
0.6.02026/10/2915 天前
0.5.02026/10/2420 天前
0.4.02026/10/2222 天前

其他发布通道:beta → 0.2.0-beta.43,rc → 0.2.0-rc.14。

关于 @ecopages/content-processor 的常见问题

@ecopages/content-processor 是什么?

Scan MDX collections at build time and expose typed ecopages:content/* virtual modules for pages and scripts

如何安装 @ecopages/content-processor?

在项目目录运行 npm install @ecopages/content-processor,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@ecopages/core(0.7.1)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@ecopages/content-processor 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@ecopages/content-processor 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@ecopages/content-processor 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js ^24.22.0 || >=26.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@ecopages/content-processor 可以商用吗?

@ecopages/content-processor 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@ecopages/content-processor 的下载量有多少?

根据 npm 官方下载统计,@ecopages/content-processor 在 2026/10/13 – 2026/11/11 这 30 天内被下载 3,319 次,日均约 111 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@ecopages/content-processor 还在维护吗?

最新版本 0.7.1 发布于 2026/11/13(今天);至今共发布 46 个版本,其中过去 12 个月发布了 46 个;首次发布于 2026/07/14。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。