Ecopages0.2.0-rc.4

eco Namespace

A unified API for defining components, pages, and page data in EcoPages.

Overview

The eco namespace provides a consistent, type-safe interface for:

  1. eco.component() - Factory for defining reusable components with dependencies and optional lazy-loading
  2. eco.html() - Semantic alias for the document shell component (owns <html>, <head>, <body>)
  3. eco.layout() - Semantic alias for route layout components (page-level wrappers)
  4. eco.page() - Factory for defining page components with optional inline staticPaths, staticProps, and metadata
  5. eco.embed() - Explicit render helper for foreign or same-integration components during mixed rendering
  6. eco.metadata() - Type-safe wrapper for page metadata (legacy pattern)
  7. eco.staticPaths() - Type-safe wrapper for dynamic route generation (legacy pattern)
  8. eco.staticProps() - Type-safe wrapper for static data fetching (legacy pattern)

Dependency discovery

Discovery is enabled by default for modules declaring eco.component(), eco.layout(), eco.html(), or eco.page(), as well as MDX documents compiled through @ecopages/mdx/core.

import { eco } from '@ecopages/core';
import { Counter } from './counter';
import './page.css';
 
export default eco.page({
    render: () => <Counter />,
});

Direct static named/default imports of local Eco Components contribute their Dependencies transitively. Configured TypeScript path aliases and relative imports are supported for both Components and CSS stylesheets (e.g., import '@/components/button' or import '@/styles/main.css'). Side-effect CSS imports become stylesheet Dependencies; the shared transform removes the imports so the existing asset pipeline owns delivery in server and browser builds. In MDX documents, top-level component and CSS imports are discovered while markdown code blocks and dynamic imports within functions are safely ignored.

Discovery is conservative and module-scoped: every Eco declaration in a file shares its imported Components and styles, even when a render condition omits a Component. It does not infer which Components actually render. Best practice: author one eco.component() per file. Co-locating multiple components in a single file will cause all declared components in that file to share discovered dependencies.

Imported Layouts contribute assets; only the explicit layout option controls Layout composition.

Explicit dependencies remain supported. Explicit Components come first, followed by discovered Components in import order, with duplicate Components removed. Explicit stylesheet declarations take precedence over discovered references to the same resolved file, preserving their attributes and order. Discovered stylesheets are not written into config.dependencies.stylesheets; they stay on Component identity until collection. Inferred styles follow explicit styles and are emitted once per collection. Circular dependency traversal is guarded by Component config identity, so two Components in one file remain distinct.

When a Page combines its own relative assets with a content entry, use mergePageDependencies(). Do not object-spread the two bags: spread copies ownerFile onto the other side's relative paths.

import { eco, mergePageDependencies } from '@ecopages/core';
import { getEntryDependencies } from 'ecopages:content/posts/server';
 
export default eco.page({
	dependencies: async ({ props }) =>
		mergePageDependencies({ stylesheets: ['./post.css'] }, await getEntryDependencies(props.slug)),
	render: () => <article />,
});

Browser scripts remain explicit:

scripts: [{
    src: './counter.script.ts',
    ssr: true,
    lazy: { 'on:visible': true },
}]

For Lit and Ecopages JSX (Radiant hosts), ssr: true imports the script on the server before rendering so customElements.define runs without a separate value import in the component file. Only those integrations run SSR preload today; other renderers ignore ssr: true until they adopt the same preloader. lazy controls browser delivery only; it does not suppress server import. Lazy entries with ssr: true do not emit a duplicate eager browser script—browser delivery follows lazy, or loads eagerly when lazy is omitted. String-form scripts: ['./file.ts'] stays browser-only unless you opt in with { src, ssr: true }.

Named barrel re-exports (export { Counter } from './counter') are followed for the imported binding only. Discovery does not follow export * from './components', dynamic imports, namespace imports (import * as), package Components/CSS, CSS Modules, or custom import attributes. export * would pull an entire kit into the page asset graph; keep that explicit with dependencies.components. Plain-function modules retain their existing behavior. Ordinary utility imports do not become browser script entries. Missing supported imports report the owner file and import specifier.

Component Patterns

EcoPages provides a unified API for creating components and pages. While there are a few ways to define components, eco.component() is the standard and recommended approach for most use cases, as it ensures proper dependency management, lazy loading, and type safety.

Component Creation

eco.component() (Standard)

This is the primary way to create components in EcoPages. It handles:

  • Dependency Management: Automatically injects stylesheets and scripts.
  • Lazy Loading: Supports interaction, visibility, and idle triggers.
  • Type Safety: Infers props for both internal usage and custom elements.

For components that need dependencies, scripts, or lazy loading:

import { eco } from '@ecopages/core';
 
export const Counter = eco.component({
	dependencies: {
		stylesheets: ['./counter.css'],
		scripts: [{ src: './counter.script.ts', ssr: true, lazy: { 'on:interaction': 'mouseenter,focusin' } }],
	},
	render: ({ count }) => <my-counter count={count}></my-counter>,
});

When to use:

  • Components with client-side interactivity
  • Components requiring scripts or stylesheets
  • Lazy-loaded components with hydration strategies
  • Web components or custom elements

Optimizations & Specific Integrations

While eco.component() is the default, there are specific scenarios where lighter-weight alternatives are preferred for optimization or specific framework integrations.

Simple JSX Functions (Optimization)

For purely presentational components (static UI) that do not require client-side interactivity, scripts, or dedicated stylesheets, you can use plain JSX functions. This is a performance optimization that avoids the overhead of a custom element wrapper and works especially well with utility-first CSS where styles are expressed inline through class names.

import type { PropsWithChildren } from '@/types';
import { cn } from 'your-library';
 
export function Card({ children, className }: PropsWithChildren<{ className?: string }>) {
	return <div class={cn('p-6 rounded-2xl border border-white/10 bg-zinc-900/30', className)}>{children}</div>;
}

When to use:

  • Static/presentational UI (cards, alerts, badges, layout primitives)
  • Projects using utility-first CSS (Tailwind, UnoCSS, etc.)
  • Components that only render HTML without scripts or dedicated stylesheets
  • Zero runtime cost - just functions returning markup

Note: If your component requires a dedicated CSS file, use eco.component() instead to manage the stylesheet dependency.

Plain React Components (Integration)

You can use standard React components directly if they only rely on hooks (useState, useEffect) and local styling patterns such as Tailwind CSS. This is useful for migrating existing React code or when you need complex state management that doesn't require the full eco lifecycle.

Powered by @ecopages/react.

Warning: These components cannot declare external dependencies (scripts/stylesheets) or use EcoPages' lazy loading strategies directly. For those features, wrap them in eco.component().

Important: Plain React functions are not valid as eco.page({ layout }) values or as dependencies.components entries. The renderer validates that dependency graph nodes are declared components (eco.component(), eco.layout(), or eco.html()) with bound config.identity. Use eco.layout() for route wrappers instead of ad-hoc functions.

import { useState } from 'react';
 
export function Counter() {
	const [count, setCount] = useState(0);
 
	return (
		<button
			onClick={() => setCount(count + 1)}
			className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
		>
			Count: {count}
		</button>
	);
}

When to use:

  • Migrating existing React codebases
  • Complex state logic within a single component
  • When you are already using the React integration

Note: If you need to attach external assets or lazy-load the component, switch to eco.component().

Comparison

AspectSimple JSXPlain Reacteco.component()
React hooksNoYesYes
Scripts/StylesheetsNoNoYes
Lazy loadingNoNoYes
Hydration strategiesNoNoYes
Runtime costZeroMinimalMinimal
Use caseStatic UIInteractive UIAdvanced UI

All patterns can coexist in the same project. Use the right tool for the job.

React Support

EcoPages fully supports React components. You can use eco.component() with React to managing dependencies and hydration while maintaining type safety.

Since React components return ReactNode or JSX.Element (not EcoPagesElement), you need to specify the return type generic:

import { eco } from '@ecopages/core';
import type { ReactNode } from 'react';
 
type ButtonProps = {
	label: string;
	onClick?: () => void;
};
 
// Specify ReactNode as the second generic argument
export const Button = eco.component<ButtonProps, ReactNode>({
	dependencies: {
		stylesheets: ['./button.css'],
	},
	render: ({ label, onClick }) => (
		<button className="eco-button" onClick={onClick}>
			{label}
		</button>
	),
});

You can also use it for pages:

import { eco } from '@ecopages/core';
import type { JSX } from 'react';
 
export default eco.page<Props, JSX.Element>({
	layout: BaseLayout,
	render: () => <h1>Hello React</h1>,
});

Two Patterns for Pages

Consolidated API (Recommended)

Define everything in one place:

import { eco } from '@ecopages/core';
 
export default eco.page<BlogPostProps>({
	layout: BaseLayout,
	staticPaths: async () => ({ paths: getAllSlugs() }),
	staticProps: async ({ pathname }) => ({
		props: { post: await getPost(pathname.params.slug) },
	}),
	metadata: ({ props: { post } }) => ({
		title: post.title,
		description: post.excerpt,
	}),
	render: ({ post }) => <article>{post.content}</article>,
});

Separate Exports (Legacy)

The traditional pattern with named exports:

import { eco } from '@ecopages/core';
 
export const getStaticPaths = eco.staticPaths(async () => ({
	paths: getAllSlugs(),
}));
 
export const getStaticProps = eco.staticProps(async ({ pathname }) => ({
	props: { post: await getPost(pathname.params.slug) },
}));
 
export const getMetadata = eco.metadata<typeof getStaticProps>(({ props: { post } }) => ({
	title: post.title,
	description: post.excerpt,
}));
 
export default eco.page<typeof getStaticProps>({
	render: ({ post }) => <article>{post.content}</article>,
});

Both patterns work and can be mixed - the renderer checks for attached properties first, then falls back to named exports.

API Reference

eco.html()

Creates the document shell component — the outermost HTML wrapper rendered once per page. Semantically equivalent to eco.component() but signals to tooling and readers that this component owns the full document structure (<html>, <head>, <body>).

import { eco } from '@ecopages/core';
 
export const Document = eco.html({
	dependencies: {
		stylesheets: ['./document.css'],
	},
	render: ({ children, metadata }) => (
		<html lang="en">
			<head>
				<title>{metadata?.title ?? 'EcoPages'}</title>
			</head>
			<body>{children}</body>
		</html>
	),
});

eco.layout()

Creates a route layout component — a wrapper rendered around page content. Semantically equivalent to eco.component() but clearly communicates that the component is intended to be used as a layout in eco.page().

import { eco } from '@ecopages/core';
 
export const BaseLayout = eco.layout({
	dependencies: {
		stylesheets: ['./base-layout.css'],
		scripts: ['./base-layout.script.ts'],
	},
	render: ({ children }) => (
		<main>{children}</main>
	),
});

Use eco.layout() components as the layout option in eco.page(). EcoPages merges declared layouts into the page dependency graph automatically — you do not need to list the layout again in dependencies.components unless you also render it manually inside render (unusual).

import { eco } from '@ecopages/core';
import { BaseLayout } from '@/layouts/base-layout';
 
export default eco.page({
	layout: BaseLayout,
	render: () => <h1>Hello</h1>,
});

Nested layouts (outer → inner)

Pass a single layout or an array of layouts. Array order is outermost first, innermost last.

import { eco } from '@ecopages/core';
import { AppShell } from '@/layouts/app-shell';
import { DocsSection } from '@/layouts/docs-section';
 
export default eco.page({
	layout: [AppShell, DocsSection],
	render: () => <h1>Documentation</h1>,
});

At factory time, EcoPages normalizes this stack onto config.layouts (components) and config.layoutEntries (components plus optional per-tier props factories).

Per-tier prop factories are supported when a tier needs route-scoped props:

layout: [
	AppShell,
	{
		component: DocsSection,
		props: ({ params }) => ({ activeSection: params.slug }),
	},
],

On React sites with @ecopages/react-router, each tier is cached independently during SPA navigation. Routes that share an outer layout — for example [AppShell, Docs] and [AppShell, Settings] — reuse the same mounted outer instance while inner tiers swap.

eco.component()

Define a reusable component with dependencies. Entries in dependencies.components must themselves be declared eco components (eco.component(), eco.layout(), or eco.html()).

import { eco } from '@ecopages/core';
 
export const BaseLayout = eco.component({
	dependencies: {
		stylesheets: ['./base-layout.css'],
		scripts: ['./base-layout.script.ts'],
	},
	render: ({ children, class: className }) => (
		<html>
			<body class={className}>{children}</body>
		</html>
	),
});

With lazy-loaded scripts:

export const Counter = eco.component({
	dependencies: {
		stylesheets: ['./counter.css'], // loaded immediately
		scripts: [
			{ src: './counter.script.ts', ssr: true, lazy: { 'on:interaction': 'mouseenter,focusin' } },
		],
	},
	render: ({ count }) => <my-counter count={count}></my-counter>,
});

Lazy Loading Options

Lazy loading is configured per dependency entry in dependencies.scripts:

// Custom element (Lit or Radiant): server registration + lazy browser load
scripts: [{ src: './counter.script.ts', ssr: true, lazy: { 'on:interaction': 'mouseenter,focusin' } }]
 
// Browser-only: no server import (string form or object without ssr: true)
scripts: ['./analytics.ts']
scripts: [{ src: './analytics.ts', lazy: { 'on:idle': true } }]

Custom-element entries can mix triggers; each object entry can define its own lazy rule:

scripts: [{ src: './component.script.ts', ssr: true, lazy: { 'on:visible': true } }]

Each entry can define its own trigger, so mixed strategies in one component are supported.

These map directly to scripts-injector attributes.

eco.embed()

Renders a declared eco component explicitly and optionally injects children into the props bag before invocation. During mixed rendering, eco.embed() routes through the active foreign-child runtime so the owning integration can queue and resolve cross-integration subtrees.

Integration-owned JSX wrappers expose the same contract:

IntegrationImport
Ecopages JSX@ecopages/ecopages-jsx/eco-embed
React@ecopages/react/eco-embed
KitaJS@ecopages/kitajs/eco-embed
import { eco } from '@ecopages/core';
import { KitaShell } from '@/components/kita-shell';
 
// Imperative handoff
const html = eco.embed(KitaShell, { id: 'shell' }, '<span>Leaf</span>');
/** @jsxImportSource @ecopages/jsx */
import { EcoEmbed } from '@ecopages/ecopages-jsx/eco-embed';
import { KitaShell } from '@/components/kita-shell';
 
// JSX wrapper (preferred in .tsx / .kita.tsx / React files)
<EcoEmbed component={KitaShell} props={{ id: 'shell' }}>
	<span>Leaf</span>
</EcoEmbed>;

Children at foreign-subtree queue boundaries

Core rejects plain opaque objects before they enter the foreign-subtree queue. Values that would stringify to [object Object] throw a TypeError with guidance to use EcoEmbed or pass already-serialized HTML.

Accepted without throwing: HTML strings, template results (strings / values), markup nodes with outerHTML, arrays, and framework element markers ($$typeof).

Use EcoEmbed (or pre-serialized HTML) for cross-integration shell stacks. See Ecopages JSX — Mixed Rendering for the full boundary contract.

eco.page()

Define a page component with optional inline static functions.

Consolidated API (everything in one place):

type BlogPostProps = { slug: string; title: string; text: string };
 
export default eco.page<BlogPostProps>({
	layout: BaseLayout,
 
	// Generate paths for dynamic routes
	staticPaths: async () => ({
		paths: posts.map((p) => ({ params: { slug: p.slug } })),
	}),
 
	// Fetch data at build time
	staticProps: async ({ pathname }) => ({
		props: {
			slug: pathname.params.slug as string,
			title: post.title,
			text: post.text,
		},
	}),
 
	// Generate page metadata
	metadata: ({ props: { title, slug } }) => ({
		title: `${title} | My Blog`,
		description: `Read about ${slug}`,
	}),
 
	// Render the page
	render: ({ title, text }) => (
		<article>
			<h1>{title}</h1>
			<p>{text}</p>
		</article>
	),
});

Simple page without data fetching:

export default eco.page({
	layout: BaseLayout,
	metadata: () => ({
		title: 'Home',
		description: 'Welcome to EcoPages',
	}),
	render: () => <h1>Welcome</h1>,
});

With layout (single or nested):

export default eco.page({
	layout: BaseLayout, // or layout: [AppShell, SectionLayout]
	render: () => <h1>Content</h1>,
});

With nested layouts:

export default eco.page({
	layout: [AppShell, DocsSection],
	render: () => <h1>Docs home</h1>,
});

Complete Examples

Dynamic Blog Post (Consolidated API)

// pages/blog/[slug].tsx
import { eco } from '@ecopages/core';
import { BaseLayout } from '@/layouts/base-layout';
import { getBlogPost, getAllBlogPostSlugs, getAuthor } from '@/mocks/data';
 
type BlogPostProps = {
	slug: string;
	title: string;
	text: string;
	authorId: string;
	authorName: string;
};
 
export default eco.page<BlogPostProps>({
	layout: BaseLayout,
 
	staticPaths: async () => {
		return { paths: getAllBlogPostSlugs() };
	},
 
	staticProps: async ({ pathname }) => {
		const slug = pathname.params.slug as string;
		const post = getBlogPost(slug);
		if (!post) throw new Error(`Post not found: ${slug}`);
		const author = getAuthor(post.authorId);
		return {
			props: {
				slug,
				title: post.title,
				text: post.text,
				authorId: post.authorId,
				authorName: author?.name ?? 'Unknown',
			},
		};
	},
 
	metadata: ({ props: { title, slug } }) => ({
		title: `${title} | My Blog`,
		description: `Read the blog post: ${slug}`,
	}),
 
	render: ({ title, text, authorId, authorName }) => (
		<article>
			<h1>{title}</h1>
			<p>
				By <a href={`/blog/author/${authorId}`}>{authorName}</a>
			</p>
			<div>{text}</div>
		</article>
	),
});

Lazy-Loaded Component

// components/counter/counter.tsx
import { eco } from '@ecopages/core';
 
export const Counter = eco.component({
	dependencies: {
		stylesheets: ['./counter.css'],
		scripts: [{ src: './counter.script.ts', ssr: true, lazy: { 'on:interaction': 'mouseenter,focusin' } }],
	},
	render: ({ count }) => <my-counter count={count}></my-counter>,
});

HTML Output:

<scripts-injector>
	<script type="ecopages/injector-map">
		{
			"on:interaction": {
				"value": "mouseenter,focusin",
				"scripts": ["/_assets/components/counter/counter.script.js"]
			}
		}
	</script>
	<my-counter count="5">
		<!-- SSR content -->
	</my-counter>
</scripts-injector>

Page with Lazy Component

// pages/index.tsx
import { eco } from '@ecopages/core';
import { BaseLayout } from '@/layouts/base-layout';
import { Counter } from '@/components/counter';
 
export default eco.page({
	layout: BaseLayout,
	metadata: () => ({
		title: 'Home',
		description: 'Welcome to EcoPages',
	}),
	render: () => (
		<>
			<h1>Welcome</h1>
			<Counter count={5} />
		</>
	),
});

The static Counter import is discovered automatically. You do not list it again in dependencies.components. Lazy scripts stay on the Component.

Type Definitions

type LazyTrigger = { 'on:idle': true } | { 'on:interaction': string } | { 'on:visible': true | string };
 
type DependencyEntry = {
	src?: string;
	content?: string;
	lazy?: LazyTrigger;
	ssr?: boolean;
	attributes?: Record<string, string>;
};
 
interface EcoComponentDependencies {
	scripts?: Array<string | DependencyEntry>;
	stylesheets?: Array<string | DependencyEntry>;
	modules?: string[];
	/** Declared eco components only — see `EcoDeclaredComponent`. */
	components?: EcoDeclaredComponent[];
}
 
type FileOwnedDependencyContribution = EcoComponentDependencies & {
	ownerFile?: string;
};
 
type PageDependenciesResult = FileOwnedDependencyContribution & {
	contributions?: FileOwnedDependencyContribution[];
};
 
type GetPageDependencies<T> = (context: {
	props: PagePropsFor<T>;
	params?: Record<string, string>;
	query?: Record<string, string>;
}) => PageDependenciesResult | undefined | Promise<PageDependenciesResult | undefined>;
 
// Shared base option shape used by component(), html(), and layout()
interface ComponentOptions<P, E = EcoPagesElement> {
	componentDir?: string;
	dependencies?: EcoComponentDependencies;
	render: (props: P) => E;
}
 
// html() and layout() accept the same options as component() but return
// narrower types to signal intent (EcoHtmlComponent / EcoLayoutComponent).
type HtmlOptions<E = EcoPagesElement> = ComponentOptions<Record<string, unknown>, E>;
type LayoutOptions<E = EcoPagesElement> = ComponentOptions<{ children: E }, E>;
 
type EcoPageLayoutSpec<E = EcoPagesElement> =
	| EcoDeclaredComponent<any, E>
	| { component: EcoDeclaredComponent<any, E>; props?: (context: LayoutPropsContext) => Record<string, unknown> };
 
/** Outer → inner. Normalized to `config.layouts` / `config.layoutEntries`. */
type EcoPageLayouts<E = EcoPagesElement> = EcoPageLayoutSpec<E> | EcoPageLayoutSpec<E>[];
 
interface PageOptions<T, E = EcoPagesElement> {
	componentDir?: string;
	dependencies?: EcoComponentDependencies | GetPageDependencies<T>;
	layout?: EcoPageLayouts<E>;
	staticPaths?: GetStaticPaths;
	staticProps?: GetStaticProps<T>;
	metadata?: GetMetadata<T>;
	render: (props: PagePropsFor<T>) => E;
}
 
type EcoPageComponent<T> = EcoComponent<PagePropsFor<T>> & {
	staticPaths?: GetStaticPaths;
	staticProps?: GetStaticProps<T>;
	metadata?: GetMetadata<T>;
};
 
type PagePropsFor<T> =
	T extends GetStaticProps<infer P>
		? P & { params?: Record<string, string>; query?: Record<string, string> }
		: T & { params?: Record<string, string>; query?: Record<string, string> };

Benefits

1. Discoverability

import { eco } from '@ecopages/core';
 
eco. // IDE shows: component, html, layout, page, metadata, staticPaths, staticProps

2. Type Safety

Props flow through the system automatically:

export default eco.page<{ title: string }>({
	staticProps: async () => ({ props: { title: 'Hello' } }),
	render: ({ title }) => <h1>{title}</h1>, // ✓ TypeScript knows `title` is a string
});

3. Single Source of Truth (Consolidated API)

All page configuration in one place - no hunting for separate exports:

export default eco.page<Props>({
  layout: BaseLayout,
  staticPaths: async () => ...,
  staticProps: async () => ...,
  metadata: () => ...,
  render: () => ...,
});

4. Backwards Compatible

Both patterns work side by side - choose what works best for your use case.

Implementation Notes

The renderer checks for attached properties first:

// Check for attached static functions (consolidated API) or named exports (legacy)
const getStaticProps = Page.staticProps ?? module.getStaticProps;
const getMetadata = Page.metadata ?? module.getMetadata;

This ensures both patterns work seamlessly.