Skip to main content

Renderer Architecture

The rendering pipeline:
Template → Detector → Renderer → HTML String

     Determines type:
     - HTML function
     - React component
     - MDX string
     - Custom...

Built-in Renderers

HTML Renderer

Handles template functions returning strings:
// Detection: typeof template === 'function' && returns string
const htmlTemplate = (ctx) => `<div>${ctx.output.name}</div>`;

React Renderer

Handles React components:
// Detection: typeof template === 'function' && JSX return type
import { MyWidget } from './widget';
const reactTemplate = MyWidget;

MDX Renderer

Handles MDX strings:
// Detection: string with JSX-like tags or {expressions}
const mdxTemplate = `# {output.title}\n<Card>{output.body}</Card>`;

Creating a Custom Renderer

1. Define the Renderer Interface

src/renderers/my-renderer.ts
import { TemplateContext } from '@frontmcp/ui';

export interface CustomRendererOptions {
  // Renderer-specific options
  customOption?: boolean;
}

export interface CustomRenderer {
  /**
   * Check if this renderer can handle the template
   */
  canHandle(template: unknown): boolean;

  /**
   * Render the template to HTML
   */
  render<In, Out>(
    template: unknown,
    context: TemplateContext<In, Out>,
    options?: CustomRendererOptions
  ): string;
}

2. Implement the Renderer

src/renderers/my-renderer.ts
import { escapeHtml } from '@frontmcp/ui';

export const myCustomRenderer: CustomRenderer = {
  canHandle(template: unknown): boolean {
    // Check if template matches your format
    if (typeof template !== 'object' || template === null) {
      return false;
    }

    // Example: check for specific property
    return 'myCustomFormat' in template;
  },

  render<In, Out>(
    template: unknown,
    context: TemplateContext<In, Out>,
    options?: CustomRendererOptions
  ): string {
    const config = template as MyCustomTemplate;

    // Build HTML from your custom format
    let html = '';

    for (const section of config.sections) {
      html += `<div class="section">${escapeHtml(section.content)}</div>`;
    }

    return html;
  },
};

interface MyCustomTemplate {
  myCustomFormat: true;
  sections: Array<{ content: string }>;
}

3. Register the Renderer

src/renderers/index.ts
import { htmlRenderer, reactRenderer, mdxRenderer } from '@frontmcp/ui';
import { myCustomRenderer } from './my-renderer';

// Order matters - first matching renderer wins
export const renderers = [
  myCustomRenderer,  // Check custom format first
  reactRenderer,
  mdxRenderer,
  htmlRenderer,      // HTML is the fallback
];

export function detectAndRender<In, Out>(
  template: unknown,
  context: TemplateContext<In, Out>
): string {
  for (const renderer of renderers) {
    if (renderer.canHandle(template)) {
      return renderer.render(template, context);
    }
  }

  throw new Error('No renderer found for template');
}

Example: YAML Template Renderer

A renderer for YAML-based templates:
src/renderers/yaml-renderer.ts
import yaml from 'js-yaml';
import { escapeHtml, TemplateContext } from '@frontmcp/ui';

interface YamlTemplate {
  title?: string;
  sections: Array<{
    heading?: string;
    content: string;
    type?: 'text' | 'list' | 'code';
  }>;
}

export const yamlRenderer = {
  canHandle(template: unknown): boolean {
    if (typeof template !== 'string') return false;
    try {
      const parsed = yaml.load(template);
      return typeof parsed === 'object' && parsed !== null && 'sections' in parsed;
    } catch {
      return false;
    }
  },

  render<In, Out>(
    template: string,
    context: TemplateContext<In, Out>
  ): string {
    // Parse YAML
    const config = yaml.load(template) as YamlTemplate;

    // Interpolate variables
    const interpolate = (str: string): string => {
      return str.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (_, path) => {
        const value = getNestedValue(context, path);
        return escapeHtml(String(value ?? ''));
      });
    };

    // Build HTML
    let html = '<div class="yaml-widget">';

    if (config.title) {
      html += `<h1>${interpolate(config.title)}</h1>`;
    }

    for (const section of config.sections) {
      html += '<div class="section">';

      if (section.heading) {
        html += `<h2>${interpolate(section.heading)}</h2>`;
      }

      switch (section.type) {
        case 'list':
          html += `<ul>${interpolate(section.content)
            .split('\n')
            .map(li => `<li>${li}</li>`)
            .join('')}</ul>`;
          break;
        case 'code':
          html += `<pre><code>${interpolate(section.content)}</code></pre>`;
          break;
        default:
          html += `<p>${interpolate(section.content)}</p>`;
      }

      html += '</div>';
    }

    html += '</div>';
    return html;
  },
};

function getNestedValue(obj: any, path: string): any {
  return path.split('.').reduce((o, k) => o?.[k], obj);
}
Usage:
@Tool({
  name: 'my_tool',
  ui: {
    template: `
title: "{{output.title}}"
sections:
  - heading: Overview
    content: "{{output.description}}"
  - heading: Items
    type: list
    content: |
      {{output.item1}}
      {{output.item2}}
      {{output.item3}}
`,
  },
})

Example: Markdown-Only Renderer

A simple Markdown renderer without JSX:
This example uses new Function() for expression evaluation, which is equivalent to eval(). Never use this pattern with untrusted template content as it enables arbitrary code execution. For production use, implement a safe expression parser or restrict to simple property access patterns (e.g., output.name only).
src/renderers/markdown-renderer.ts
import { marked } from 'marked';
import { escapeHtml, TemplateContext } from '@frontmcp/ui';

export const markdownRenderer = {
  canHandle(template: unknown): boolean {
    if (typeof template !== 'string') return false;
    // Pure markdown (no JSX tags)
    const hasJsx = /<[A-Z][a-zA-Z]*/.test(template);
    const hasMarkdown = /^#|^\*|\*\*|`/.test(template);
    return !hasJsx && hasMarkdown;
  },

  render<In, Out>(
    template: string,
    context: TemplateContext<In, Out>
  ): string {
    // Interpolate variables
    // WARNING: new Function() is equivalent to eval() - only use with trusted templates
    const interpolated = template.replace(
      /\{([^}]+)\}/g,
      (_, expr) => {
        try {
          const fn = new Function('ctx', `with(ctx) { return ${expr}; }`);
          return escapeHtml(String(fn(context)));
        } catch {
          return `{${expr}}`;
        }
      }
    );

    // Convert Markdown to HTML
    return marked(interpolated);
  },
};

Template Type Detection

The detection order is important:
// Check in order of specificity
const detectTemplateType = (template: unknown): string => {
  // 1. Check custom formats first
  if (isYamlTemplate(template)) return 'yaml';
  if (isPugTemplate(template)) return 'pug';

  // 2. Check function types
  if (typeof template === 'function') {
    if (isReactComponent(template)) return 'react';
    return 'html-function';
  }

  // 3. Check string formats
  if (typeof template === 'string') {
    if (hasMdxSyntax(template)) return 'mdx';
    if (hasMarkdown(template)) return 'markdown';
    return 'html-string';
  }

  return 'unknown';
};

Caching Compiled Templates

For performance, cache compiled templates:
const templateCache = new Map<string, CompiledTemplate>();

export function renderWithCache<In, Out>(
  template: string,
  context: TemplateContext<In, Out>
): string {
  // Generate cache key
  const key = hashTemplate(template);

  // Check cache
  let compiled = templateCache.get(key);

  if (!compiled) {
    compiled = compileTemplate(template);
    templateCache.set(key, compiled);
  }

  // Render with context
  return compiled(context);
}

Best Practices

  1. Fail gracefully - Return error HTML, don’t throw
  2. Cache compiled templates - Compilation is expensive
  3. Escape output - Always escape user content
  4. Document your format - Make it easy for others to use
  5. Test thoroughly - Cover edge cases
  6. Consider performance - Rendering happens on every request