Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Ecto is a modern template consolidation engine that enables the best template en
* [FrontMatter Helper Functions](#frontmatter-helper-functions)
* [Caching on Rendering](#caching-on-rendering)
* [Emitting Events](#emitting-events)
* [Hooks](#hooks)
* [Creating Custom Engines](#creating-custom-engines)
* [Direct Engine Usage](#direct-engine-usage)
* [Creating a Custom Engine](#creating-a-custom-engine)
Expand Down Expand Up @@ -712,6 +713,112 @@ const data = { firstName: "John", lastName: "Doe" };
await ecto.render(source, data); // <h1>Hello John Doe!</h1>
```

# Hooks

Ecto supports hooks that allow you to intercept and modify data during the rendering process. Unlike events (which are notifications only), hooks let you transform the source, data, or result as it flows through the render pipeline.

## Available Hooks

| Hook Name | Description |
| --------- | ----------- |
| `beforeRender` | Called before async rendering. Allows modifying source and data. |
| `afterRender` | Called after async rendering. Allows modifying the result. |
| `beforeRenderSync` | Called before sync rendering. Allows modifying source and data. |
| `afterRenderSync` | Called after sync rendering. Allows modifying the result. |

## Hook Context

The `beforeRender` and `beforeRenderSync` hooks receive a `RenderContext` object:

```typescript
type RenderContext = {
source: string; // Template source (modifiable)
data?: Record<string, unknown>; // Template data (modifiable)
engineName: string; // Engine being used
rootTemplatePath?: string; // Root path for partials
filePathOutput?: string; // Output file path
cached: boolean; // True if result came from cache
};
```

The `afterRender` and `afterRenderSync` hooks receive a `RenderResult` object:

```typescript
type RenderResult = {
result: string; // Rendered output (modifiable)
context: RenderContext; // Original render context
};
```

## Using Hooks

Register hooks using the `onHook` method:

```javascript
import { Ecto, EctoEvents } from 'ecto';

const ecto = new Ecto();

// Modify source before rendering
ecto.onHook(EctoEvents.beforeRender, (context) => {
context.source = context.source.replace('{{placeholder}}', '{{replaced}}');
});

// Inject data before rendering
ecto.onHook(EctoEvents.beforeRender, (context) => {
context.data = { ...context.data, injectedValue: 'hello' };
});

// Transform result after rendering
ecto.onHook(EctoEvents.afterRender, (renderResult) => {
renderResult.result = renderResult.result.toUpperCase();
});

// Check if result was cached
ecto.onHook(EctoEvents.afterRender, (renderResult) => {
if (renderResult.context.cached) {
console.log('Result came from cache');
}
});

const output = await ecto.render('<%= name %>', { name: 'World' });
```

## Sync Hooks Example

```javascript
import { Ecto, EctoEvents } from 'ecto';

const ecto = new Ecto();

// Works the same for sync rendering
ecto.onHook(EctoEvents.beforeRenderSync, (context) => {
context.data = { ...context.data, timestamp: Date.now() };
});

ecto.onHook(EctoEvents.afterRenderSync, (renderResult) => {
renderResult.result = `<!-- Rendered at ${Date.now()} -->\n${renderResult.result}`;
});

const output = ecto.renderSync('<%= name %>', { name: 'World' });
```

## Multiple Hooks

Multiple hooks for the same event are executed in the order they are registered:

```javascript
ecto.onHook(EctoEvents.beforeRender, (context) => {
console.log('First hook');
context.source += ' - modified by first';
});

ecto.onHook(EctoEvents.beforeRender, (context) => {
console.log('Second hook');
context.source += ' - modified by second';
});
```

# Creating Custom Engines

Ecto allows you to create your own custom template engines by implementing the `EngineInterface`. This is useful when you want to integrate a template engine that isn't built into Ecto or when you need custom rendering logic.
Expand Down
157 changes: 131 additions & 26 deletions src/ecto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,35 @@ export type EctoOptions = {
cacheSync?: boolean | CacheableMemory;
} & HookifiedOptions;

/**
* Context passed to beforeRender and beforeRenderSync hooks
*/
export type RenderContext = {
source: string;
data?: Record<string, unknown>;
engineName: string;
rootTemplatePath?: string;
filePathOutput?: string;
cached: boolean;
};

/**
* Result passed to afterRender and afterRenderSync hooks
*/
export type RenderResult = {
result: string;
context: RenderContext;
};

export enum EctoEvents {
cacheHit = "cacheHit",
cacheMiss = "cacheMiss",
warn = "warn",
error = "error",
beforeRender = "beforeRender",
afterRender = "afterRender",
beforeRenderSync = "beforeRenderSync",
afterRenderSync = "afterRenderSync",
}

export class Ecto extends Hookified {
Expand Down Expand Up @@ -258,36 +282,69 @@ export class Ecto extends Hookified {
filePathOutput?: string,
): Promise<string> {
try {
const cacheKey = `${engineName ?? this._defaultEngine}-${source}-${JSON.stringify(data)}`;
let renderEngineName = this._defaultEngine;

// Set the render engine
if (this.isValidEngine(engineName) && engineName !== undefined) {
renderEngineName = engineName;
}

const cacheKey = `${renderEngineName}-${source}-${JSON.stringify(data)}`;
if (this._cache) {
const cachedResult = await this._cache.get<string>(cacheKey);
if (cachedResult) {
this.emit(EctoEvents.cacheHit, `Cache hit for key: ${cacheKey}`);

// Create context for hooks with cached flag
const context: RenderContext = {
source,
data,
engineName: renderEngineName,
rootTemplatePath,
filePathOutput,
cached: true,
};
await this.hook(EctoEvents.beforeRender, context);

// Create render result for afterRender hook
const renderResult: RenderResult = { result: cachedResult, context };
await this.hook(EctoEvents.afterRender, renderResult);

// Write out the file
await this.writeFile(filePathOutput, cachedResult);
// Return the cached result
return cachedResult;
await this.writeFile(filePathOutput, renderResult.result);
// Return the result (potentially modified by hooks)
return renderResult.result;
}

this.emit(EctoEvents.cacheMiss, `Cache miss for key: ${cacheKey}`);
}

let result = "";
let renderEngineName = this._defaultEngine;
// Create context for hooks
const context: RenderContext = {
source,
data,
engineName: renderEngineName,
rootTemplatePath,
filePathOutput,
cached: false,
};

// Set the render engine
if (this.isValidEngine(engineName) && engineName !== undefined) {
renderEngineName = engineName;
}
// Call beforeRender hook - allows modifying source and data
await this.hook(EctoEvents.beforeRender, context);

// Get the render engine
const renderEngine = this.getRenderEngine(renderEngineName);

// Set the root template path
renderEngine.rootTemplatePath = rootTemplatePath;
renderEngine.rootTemplatePath = context.rootTemplatePath;

// Get the output
result = await renderEngine.render(source, data);
// Get the output using potentially modified source and data
let result = await renderEngine.render(context.source, context.data);

// Create render result for afterRender hook
const renderResult: RenderResult = { result, context };
await this.hook(EctoEvents.afterRender, renderResult);
result = renderResult.result;

// If caching is enabled, store the result in the cache
if (this._cache) {
Expand Down Expand Up @@ -325,36 +382,69 @@ export class Ecto extends Hookified {
filePathOutput?: string,
): string {
try {
const cacheKey = `${engineName ?? this._defaultEngine}-${source}-${JSON.stringify(data)}`;
let renderEngineName = this._defaultEngine;

// Set the render engine
if (this.isValidEngine(engineName) && engineName !== undefined) {
renderEngineName = engineName;
}

const cacheKey = `${renderEngineName}-${source}-${JSON.stringify(data)}`;
if (this._cacheSync) {
const cachedResult = this._cacheSync.get<string>(cacheKey);
if (cachedResult) {
this.emit(EctoEvents.cacheHit, `Cache hit for key: ${cacheKey}`);

// Create context for hooks with cached flag
const context: RenderContext = {
source,
data,
engineName: renderEngineName,
rootTemplatePath,
filePathOutput,
cached: true,
};
this.runHooksSync(EctoEvents.beforeRenderSync, context);

// Create render result for afterRenderSync hook
const renderResult: RenderResult = { result: cachedResult, context };
this.runHooksSync(EctoEvents.afterRenderSync, renderResult);

// Write out the file
this.writeFileSync(filePathOutput, cachedResult);
// Return the cached result
return cachedResult;
this.writeFileSync(filePathOutput, renderResult.result);
// Return the result (potentially modified by hooks)
return renderResult.result;
}

this.emit(EctoEvents.cacheMiss, `Cache miss for key: ${cacheKey}`);
}

let result = "";
let renderEngineName = this._defaultEngine;
// Create context for hooks
const context: RenderContext = {
source,
data,
engineName: renderEngineName,
rootTemplatePath,
filePathOutput,
cached: false,
};

// Set the render engine
if (this.isValidEngine(engineName) && engineName !== undefined) {
renderEngineName = engineName;
}
// Call beforeRenderSync hook - allows modifying source and data
this.runHooksSync(EctoEvents.beforeRenderSync, context);

// Get the render engine
const renderEngine = this.getRenderEngine(renderEngineName);

// Set the root template path
renderEngine.rootTemplatePath = rootTemplatePath;
renderEngine.rootTemplatePath = context.rootTemplatePath;

// Get the output using potentially modified source and data
let result = renderEngine.renderSync(context.source, context.data);

// Get the output
result = renderEngine.renderSync(source, data);
// Create render result for afterRenderSync hook
const renderResult: RenderResult = { result, context };
this.runHooksSync(EctoEvents.afterRenderSync, renderResult);
result = renderResult.result;

// If caching is enabled, store the result in the cache
if (this._cacheSync) {
Expand All @@ -372,6 +462,21 @@ export class Ecto extends Hookified {
}
}

/**
* Run hooks synchronously for a specific event
* @param {string} event - The event name
* @param {unknown[]} args - Arguments to pass to the hooks
* @private
*/
private runHooksSync(event: string, ...args: unknown[]): void {
const eventHooks = this.hooks.get(event);
if (eventHooks) {
for (const hook of eventHooks) {
hook(...args);
}
}
}

/**
* Asynchronously render a template from a file path
* @param {string} filePath - Path to the template file to render
Expand Down
Loading
Loading