# One blueprint: a page, an email, a PDF, in any language

> One Texuli blueprint, many targets: email-safe HTML with a plain-text part, print and PDF, and every component label from one i18n message catalog.

Language: en
Canonical: https://texuli.com/output-page/
Markdown: https://texuli.com/output-page.md (this page). Every texuli.com page has a Markdown twin at its URL plus `.md` (the home page is https://texuli.com/index.md), and any page URL answers `Accept: text/markdown` with its twin instead of HTML.
Last Updated: 2026-11-13

---

@texuli/tools/email · @texuli/tools/print · @texuli/core/i18n · new in 0.8.0

The same validated blueprint that paints in the player becomes an email that mail clients render, a print-ready page and a PDF, and every label the components show comes from one message catalog. These targets are on npm from `0.8.0`, a pre-1.0 release; `0.7.0` and earlier are without them.

- `tools/email` email-safe HTML and plain text
- `tools/print` blueprint to document to PDF
- `player print mode` the static form the browser prints
- `core/i18n` the message catalog, lazy locales

## Email from a blueprint

renderEmail validates a blueprint at the untrusted tier, then writes a complete email: tables and inline styles mail clients understand, every value escaped, links and images through the untrusted URL policy, and a plain-text part. Content with no email form falls back to its text with a warning; a draft with anything executable is refused whole.

### The weekly digest as an email

Written by `renderEmail` when this page was built, served as its own file and shown on request in a frame with every sandbox restriction on.

### Render it yourself

 Preheader Direction
 Blueprint (JSON, untrusted)

```
{
  "id": "layout-page",
  "props": {
    "title": "Harbor weekly digest",
    "description": "Orders, support and releases for week 44 (fictional data)."
  },
  "children": [
    {
      "id": "blocks-text",
      "props": {
        "block": {
          "type": "heading",
          "level": 2,
          "text": "Highlights",
          "eyebrow": "Week 44"
        }
      }
    },
    {
      "id": "blocks-text",
      "props": {
        "block": {
          "type": "text",
          "markdown": "Orders grew **12%** over last week; see [the orders dashboard](https://example.com/orders).\n\n- Shipping took 2.1 days on average\n- Returns fell to *1.8%*"
        }
      }
    },
    {
      "id": "blocks-data",
      "props": {
        "block": {
          "type": "stat",
          "items": [
            {
              "label": "Revenue",
              "value": "$48.2k",
              "delta": "+4%"
            },
            {
              "label": "Orders",
              "value": "1,284",
              "delta": "+12%"
            },
            {
              "label": "Tickets closed",
              "value": "96",
              "delta": "-3%"
            }
          ]
        }
      }
    },
    {
      "id": "blocks-data",
      "props": {
        "block": {
          "type": "table",
          "caption": "Orders by region",
          "columns": [
            {
              "id": "region",
              "label": "Region"
            },
            {
              "id": "orders",
              "label": "Orders"
            },
            {
              "id": "change",
              "label": "Change"
            }
          ],
          "rows": [
            {
              "region": "East",
              "orders": 512,
              "change": "+9%"
            },
            {
              "region": "West",
              "orders": 431,
              "change": "+15%"
            },
            {
              "region": "South",
              "orders": 341,
              "change": "+11%"
            }
          ]
        }
      }
    },
    {
      "id": "blocks-text",
      "props": {
        "block": {
          "type": "callout",
          "title": "Heads up",
          "body": "Two products are *low* on stock: the walnut desk and the oak shelf."
        }
      }
    },
    {
      "id": "blocks-data",
      "props": {
        "block": {
          "type": "chart",
          "kind": "bar",
          "title": "Orders per day",
          "description": "Orders rose every day from Monday to Friday.",
          "series": [
            {
              "id": "orders",
              "label": "Orders",
              "points": [
                {
                  "x": "Mon",
                  "y": 160
                },
                {
                  "x": "Tue",
                  "y": 171
                },
                {
                  "x": "Wed",
                  "y": 188
                },
                {
                  "x": "Thu",
                  "y": 196
                },
                {
                  "x": "Fri",
                  "y": 214
                }
              ]
            }
          ]
        }
      }
    },
    {
      "id": "blocks-text",
      "props": {
        "block": {
          "type": "quote",
          "quote": "Best week of the quarter, and the calmest on-call yet.",
          "cite": "Ada Example",
          "role": "Operations"
        }
      }
    },
    {
      "id": "blocks-structure",
      "props": {
        "block": {
          "type": "cta",
          "actions": [
            {
              "label": "Open the dashboard",
              "href": "https://example.com/dashboard"
            },
            {
              "label": "Reply to the team",
              "href": "mailto:ops@example.com"
            }
          ]
        }
      }
    }
  ]
}
```

The renderer and the catalog it validates against load when you ask.

The three drafts, rendered at build time

| Draft | Outcome | Diagnostics |
| --- | --- | --- |
| Weekly digest | Email of 8,822 characters | `warning email-unsupported: The `chart` block has no email form; its text stays.` |
| With a javascript: link | Refused: nothing written | `error sink: an executable string``error sink: a URL the untrusted URL policy refuses``error block-schema: block actions.0.href: href must be an https, mailto or same-document link (untrusted tier)` |
| With raw markup and handlers | Refused: nothing written | `error sink: a forbidden "style" key``error sink: an event handler key``error sink: an HTML string``error forbidden-prop: Prop "style" is never passed through.``error invalid-prop: Unknown prop "onclick" was dropped.``error block-schema: block markdown: Expected a string` |

### Ids that paint in email (16)

`layout-page`, `layout-doc`, `layout-card`, `layout-grid`, `layout-deck`, `deck-slide`, `layout-stack`, `layout-row`, `layout-split`, `layout-tile`, `blocks-text`, `blocks-structure`, `blocks-data`, `blocks-media`, `blocks-code`, `media-image`

### Block types inside them (16)

`heading`, `text`, `list`, `quote`, `callout`, `divider`, `code`, `cta`, `columns`, `cards`, `image`, `figure`, `gallery`, `table`, `stat`, `kpi`

Anything else (charts, diagrams, maps, interactive blocks) falls back to its text. Planned: a dark-mode rule set in the head of the email (today it declares `color-scheme: light`) and an email envelope profile in a later spec minor.

## Print and PDF

Two ways to print the same blueprint. In the browser the player has a print mode, a static paint with page-break rules for its frames, tables and code; the print dialog prints this whole page with the digest on it. Headless, @texuli/tools/print maps the blueprint to a validated document and the owned PDF writer draws it, with no JavaScript, forms or embedded files in the file.

 Print mode on screen
 Page size Orientation

Loading the player…

blueprintToDocument: 11 document blocks

| Block type | Count |
| --- | --- |
| `heading` | 2 |
| `paragraph` | 4 |
| `list` | 1 |
| `block` | 2 |
| `callout` | 1 |
| `quote` | 1 |

Diagnostics of the mapping: The `chart` block has no document form; its text stays.

```
import { renderBlueprintPdf } from '@texuli/tools/print';

const pdf = await renderBlueprintPdf(agentDraft, {
  page: { size: 'a4', orientation: 'portrait' },
});
if (pdf.ok) downloadBlob(new Blob([pdf.bytes], { type: pdf.mime }), 'digest.pdf');

<Player blueprint={agentDraft} print />
```

## Every label from one message catalog

Component labels resolve through @texuli/core/i18n: an instance prop first, then the catalog in context, then the English default next to the component. Locales load lazily, plurals and selects follow Intl, and catalogs from outside are validated. English is the default, and opt-in Spanish catalogs ship from 0.14.3; other translations are planned, and hosts can load their own catalogs today.

Locale English (shipped) Español (this page’s catalog, lazy) Pseudo-locale (en-XA)

English is active.

 Rating
 3 of 5

- launch
- billing

 `npm install texuli`
 Files 1 file

The tag group’s name, “Topics”, is an instance prop, so it stays as written in every locale; the rest come from the catalog. Catalogs loaded: en.

313 English labels, by component

| Namespace | Labels |
| --- | --- |
| `texuli.alert-dialog` | 3 |
| `texuli.alert-surface` | 1 |
| `texuli.app-nav` | 4 |
| `texuli.app-shell` | 4 |
| `texuli.auth-split-layout` | 2 |
| `texuli.bottom-nav` | 1 |
| `texuli.breadcrumb` | 2 |
| `texuli.calendar` | 2 |
| `texuli.carousel` | 10 |
| `texuli.collapsible` | 1 |
| `texuli.color-picker` | 2 |
| `texuli.combobox-empty` | 1 |
| `texuli.combobox-error` | 2 |
| `texuli.combobox-loading` | 1 |
| `texuli.command-input` | 2 |
| `texuli.command-palette` | 9 |
| `texuli.command-root` | 1 |
| `texuli.command-trigger` | 1 |
| `texuli.content-layout` | 4 |
| `texuli.copy-button` | 2 |
| `texuli.dashboard-grid-layout` | 2 |
| `texuli.date-range-picker` | 7 |
| `texuli.dialog` | 1 |
| `texuli.ds-kit` | 126 |
| `texuli.dual-sidebar-layout` | 2 |
| `texuli.error-state` | 2 |
| `texuli.file-upload` | 10 |
| `texuli.input-group` | 1 |
| `texuli.input` | 1 |
| `texuli.kpi` | 1 |
| `texuli.landing-layout` | 1 |
| `texuli.layout-chrome` | 1 |
| `texuli.layout-host` | 1 |
| `texuli.marketing-layout` | 3 |
| `texuli.master-detail-layout` | 4 |
| `texuli.mobile-app-frame` | 1 |
| `texuli.mobile-header` | 1 |
| `texuli.mobile-layout` | 7 |
| `texuli.pricing-layout` | 2 |
| `texuli.progress` | 1 |
| `texuli.rail-layout` | 1 |
| `texuli.rating` | 5 |
| `texuli.resizable-handle` | 1 |
| `texuli.settings-layout` | 1 |
| `texuli.sheet-header` | 1 |
| `texuli.sidebar-layout` | 1 |
| `texuli.sidebar` | 3 |
| `texuli.slider` | 1 |
| `texuli.split-view` | 2 |
| `texuli.stat` | 3 |
| `texuli.stepper` | 4 |
| `texuli.tag` | 1 |
| `texuli.tags-input` | 2 |
| `texuli.timeline` | 4 |
| `texuli.toaster` | 2 |
| `texuli.top-nav-layout` | 1 |
| `texuli.tree-view` | 1 |
| `texuli.wizard-layout` | 1 |
| `texuli.wizard` | 5 |
| `primitives.calendar` | 2 |
| `primitives.color-picker` | 1 |
| `primitives.combobox` | 4 |
| `primitives.command` | 2 |
| `primitives.date-field` | 5 |
| `primitives.date-picker` | 9 |
| `primitives.file-drop` | 2 |
| `primitives.number-field` | 2 |
| `primitives.pagination` | 6 |
| `primitives.tags-input` | 6 |
| `primitives.toast` | 2 |
| `primitives.tree` | 2 |

### A translation file from outside

`parseCatalog` kept 2 messages and dropped 5:

- `not an id`: not a namespaced message id
- `__proto__`: not a namespaced message id
- `texuli.copy-button.label`: the message is too long
- `texuli.tag.remove-label`: a plural or select message needs an `other` string
- `texuli.toaster.label`: a message is a string, or a plural or select object

### The pseudo-locale

- Close → ⟦Çlósé⟧
- Next page → ⟦Ñéxt págé⟧
- Remove {label} → ⟦Rémóvé {label}⟧

```
import { createI18n } from '@texuli/core/i18n';
import { setI18nContext } from 'texuli';

const i18n = createI18n({
  locale: 'en',
  loaders: { 'pt-BR': () => import('./locales/pt-BR.ts') }, // loads only when active
});
setI18nContext(i18n); // in the root component's script
await i18n.setLocale('pt-BR');
```

## What keeps it safe

Static outputs are built, not templated. These hold by construction.

- ### Validation first Email and print run validateDraft at the untrusted tier with the deep block schemas and the sink scan before mapping anything; only the sink-free render tree is ever read.
- ### Escaped by construction Email HTML is built with the markup builders of @texuli/core: element and attribute names are allowlisted, every value is escaped, and there is no raw-markup API. No scripts, style elements, forms, frames or event handlers.
- ### Links and images through the URL policy Links must end up absolute https or mailto, images absolute https, at the untrusted tier. A refused link keeps its text; a refused image keeps its alt text.
- ### Messages are text formatMessage makes one pass with no recursion: a value is never scanned again, so a value holding {name} stays literal. Translation files are checked by parseCatalog: bad ids, prototype keys and oversized messages are dropped with an issue.

```
import { renderEmail } from '@texuli/tools/email';

const { ok, html, text, diagnostics } = renderEmail(agentDraft, {
  preheader: 'Orders up 12%',
  baseUrl: 'https://example.com/',
});
if (ok) await mailer.send({ html, text }); // your mail service
```

## Package docs

The site does not render the package docs yet. Read them in the repository:

- Email: `packages/tools/docs/email.md`
- Print and PDF: `packages/tools/docs/print.md`
- Player print mode: `packages/player/docs/embedding.md`
- Message catalog: `packages/core/docs/core-i18n.md`
- Primitive labels: `packages/primitives/docs/i18n.md`

## For agents

One blueprint rendered to several targets; the email preview is the HTML the email writer produces at build time. The live parts run in the browser and are not in this Markdown; the operations below do the same work through the read-only MCP server at https://texuli.com/mcp (no sign-in; see https://texuli.com/auth.md).

MCP operations ([reference](https://github.com/DailybotHQ/texuli/blob/main/packages/tools/docs/ops.md#operations)):

- `blueprint.validate`: Validate a blueprint draft.

Docs:

- [@texuli/tools README](https://github.com/DailybotHQ/texuli/blob/main/packages/tools/README.md)
- [@texuli/player README](https://github.com/DailybotHQ/texuli/blob/main/packages/player/README.md)
