🍡 mochi

SSR framework for Svelte 5 + Bun with islands-based selective hydration

transformPage

Pass transformPage to resolve(event, { transformPage }) inside a Handle to rewrite the rendered HTML before it ships. It runs once per response, only on text/html bodies, with the full HTML string and done: true.

// file: src/hooks.ts
import type { Handle } from 'mochi-framework/hooks';

const greeting: Handle = async ({ event, resolve }) => {
  return resolve(event, {
    transformPage({ html }) {
      return html.replace('{{app.greeting}}', 'Welcome to Mochi!');
    },
  });
};
<!-- file: src/shell.html -->
<body>
  <header>{{app.greeting}}</header>
  {{mochi.body}}
</body>

The callback receives { html, done } and returns string | undefined | Promise<string | undefined>. Returning undefined replaces the body with an empty string.

const banner: Handle = async ({ event, resolve }) => {
  return resolve(event, {
    async transformPage({ html }) {
      const message = await fetchBannerMessage();
      return html.replace('{{app.banner}}', message);
    },
  });
};

Use it for per-request mutations the shell template can’t express on its own — request-aware <html lang>, nonce injection, A/B placeholder swaps:

const lang: Handle = async ({ event, resolve }) => {
  const locale = event.request.headers.get('accept-language')?.slice(0, 2) ?? 'en';
  return resolve(event, {
    transformPage({ html }) {
      return html.replace('<html', `<html lang="${locale}"`);
    },
  });
};

The callback is invoked once with the full HTML, not streamed chunk-by-chunk; done is always true. Treat it as a whole-document transform.

When sequence() chains multiple handlers, transforms run inner-most first and outer-most last — the handler closest to the route sees the original HTML, the outermost wraps the final result.