# Template composition: variables as rendered HTML chunks Sometimes one template gets too big to live with. Hundreds of `
  • ` items in a payment-methods page, dozens of inline editorial notes, sort-order changes that have to ripple through every variant — at some point one giant nested template becomes the bottleneck instead of the help. The next step is splitting it into a pipeline of small templates, joined by variables that hold already-rendered HTML. ## The mental shift So far in this series a variable has been a _value_: a brand name, a year, a comma-separated list of features. Plain strings substituted into the template at render time. The shift in this guide is small and powerful: **a variable's value can be already-resolved HTML**. Not `"Acme Co."` but `

    Crypto deposits

    `. The resolver does not care; it just substitutes. This unlocks composition. You build the page from a pipeline of small sub-templates, each rendered to a chunk of HTML, then assembled by an orchestrator that is just a few lines long. ### Without composition ```

    Crypto deposits

    Fiat deposits

    Deposit and withdrawal limits

    /# … 20+ rows #/
    ``` That is a 200-line monolith. Adding a coin means editing inside one big enum chain. Sort changes are hand work. Per-currency editorial nuances scatter across the file. ### With composition ``` %CryptoSection% %FiatSection% %LimitsSection% ``` Three lines. Each variable already holds the fully resolved HTML for its part of the page. The values come from a pipeline that runs _before_ the orchestrator is rendered. ## Why this works — the engine pipeline The [syntax reference](https://spintax.net/docs/syntax.md) spells out the order of resolution; the relevant lines for composition are: 1. strip comments; 2. extract `#set` / `#def` directives; 3. merge variables; 4. **expand `%var%` references**; 5. resolve enumerations `{a|b|c}`; 6. resolve permutations `[a|b|c]`; 7. post-process. Variables expand _before_ enum and permutation resolution. By the time enum/perm runs, `%CryptoSection%` has already been replaced with whatever HTML the assembler computed. No special syntax — variable substitution is literally string replacement. You can even mix layers: an outer permutation can shuffle pre-rendered sections. ``` [%CryptoSection%|%FiatSection%|%LimitsSection%] ``` Each section is fully resolved first, then the permutation reorders the chunks. ## The three-level pipeline The pattern lives in three layers, each one stage of refinement: ### Level 1 — Item templates (per id) Smallest reusable unit. One template per data item: per coin, per payment method, per plan tier, per FAQ entry, per product SKU. ``` /# spintax.crypto_item.btc #/
  • {Bitcoin|BTC} — {fastest|the most popular} option, {confirms in 10–60 min|settles within an hour}.
  • /# spintax.crypto_item.eth #/
  • Ethereum — {smart-contract chain|programmable network}, {2–5 min blocks|fast block times}.
  • ``` ### Level 2 — Section templates Wrap the list with structure. Use a placeholder variable for the joined items. ``` /# spintax.section.crypto #/

    {Crypto deposits|Cryptocurrencies accepted}

    {Pick from|We support} the following coins:

    ``` `%CryptoItems%` is "every per-id item resolved and joined as a single string". The assembler builds it. ### Level 3 — Orchestrator The page-level template. References pre-rendered section variables only. ``` /# spintax.payment_options #/

    {Accepted payment methods|How to pay}

    %CryptoSection% %FiatSection% %LimitsSection% ``` That is the whole orchestrator. Editing rules: change a per-coin description? Edit one item template. Add a new currency? Drop a new item template, add the id to the active list. Reorder? Sort field, not template change. ## Walkthrough — a payment-methods page A merchant accepts BTC, USDT, ETH for crypto and Visa, Mastercard, SEPA for fiat. Three lookups and a handful of templates produce the full page. Pseudo-code for the assembler that runs _before_ the orchestrator render: ```js function buildPaymentVars(merchantId, lang) { // 1. Pull active items, in display order. const cryptos = db.query("active cryptos for merchant ordered by sort", merchantId); const fiats = db.query("active fiats for merchant ordered by sort", merchantId); // 2. Resolve each per-id template, join the chunks. const cryptoItems = cryptos .map(c => parser.process(templates.find(`crypto_item.${c.id}`, lang))) .join(""); const fiatItems = fiats .map(f => parser.process(templates.find(`payment_item.${f.id}`, lang))) .join(""); // 3. Resolve each section template with item placeholders. const cryptoSection = cryptos.length ? parser.process(templates.find("section.crypto", lang), { CryptoItems: cryptoItems }) : ""; const fiatSection = fiats.length ? parser.process(templates.find("section.fiat", lang), { FiatItems: fiatItems }) : ""; // 4. Limits section is similar; LimitsRows are joined chunks. const limitsSection = (cryptos.length || fiats.length) ? parser.process(templates.find("section.limits", lang), { LimitsRows: buildLimitsRows(cryptos, fiats, lang) }) : ""; // 5. Return the variables the orchestrator references. return { CryptoSection: cryptoSection, FiatSection: fiatSection, LimitsSection: limitsSection, HasCrypto: cryptos.length ? "1" : "", HasFiat: fiats.length ? "1" : "", }; } ``` The orchestrator render then receives these alongside any normal site/runtime variables and does a final pass. ## Naming conventions Convention beats freedom here, because the assembler finds templates by id. | Pattern | Example | | --- | --- | | `spintax._item.` | `spintax.crypto_item.btc` | | `spintax._row.` | `spintax.crypto_row.btc` (table row) | | `spintax.section.` | `spintax.section.crypto` | | `spintax.` | `spintax.payment_options` | Variables follow the same shape: - `%CryptoItems%`, `%FiatItems%`, `%LimitsRows%` — joined per-id chunks - `%CryptoSection%`, `%FiatSection%`, `%LimitsSection%` — resolved sections - `%HasCrypto%`, `%HasFiat%` — markers (`'1'` or `''`) PascalCase for variables, snake\_case for IDs, ASCII only for both. ## Storage is your problem, not spintax's The pattern works the same regardless of where sub-templates live: - database table (`templates` with id + body + lang) - JSON file: `{ "crypto_item.btc": "
  • …
  • ", … }` - filesystem: `templates/crypto_item/btc.txt` - CMS field per locale The engine doesn't need a database. It just substitutes resolved HTML into variable references. The assembler is your code, written in whatever runtime drives your renders. A WordPress plugin, a Cloudflare Worker, a Node script, a Postgres function — same pattern. **Why not `#include`?** The engine does have an include directive, and for a single shared block it is the shorter path. This pipeline is not built on it, for three reasons: an included template is its own document — it sees runtime variables but never the parent's `#set`/`#def`, so a rolled form cannot be handed down into it; it only works where the host installed a resolver, and two of ours deliberately have none (the playground and the MCP server), where the line simply stays literal; and when the resolver does not know the reference, the line is removed from the output without a word. An assembler keeps resolution, caching and error handling in your own code, where you can see all three. Full behaviour: the [includes section](https://spintax.net/docs/syntax.md#includes) of the syntax reference. ## Per-id editorial nuances This is the killer feature. Editorial nuances live with the data, not in every page. ``` /# spintax.payment_item.visa — 3DS warning baked in #/
  • Visa — {3DS-protected|with 3D Secure} debit and credit cards, {instant deposit|immediate confirmation}.
  • /# spintax.crypto_item.xrp — destination-tag reminder per coin #/
  • XRP — fast and {cheap|low-fee}, {do not forget the destination tag|destination tag is required}.
  • /# spintax.payment_item.qiwi — legacy status per method #/
  • QIWI — {legacy support|now legacy}, {accepted but discouraged|not recommended for new accounts}.
  • ``` Each per-id template captures the nuance once. Three pages, ten pages, a thousand pages — they all inherit the right warnings. Move QIWI to "deprecated" by editing one template; every render flips simultaneously. Without composition, those nuances would be inline strings duplicated across pages. Audit nightmare and a slow-burn legal risk in regulated industries. ## Conditional fallback You will see authors trying to express conditionals with the engine's enum syntax: ``` {%HasCrypto%|%HasFiat%||

    Payment methods coming soon.

    } ``` The hope is "show the fallback when both flags are empty". The reality with a plain `{a|b|c|d}` enum: the engine picks one of the four branches at random with equal probability. The output is non-deterministic and includes `"1"` as a possible variant on the page. Enum branches are a uniform random pick — they never look at variables. For value-driven choice, use the conditional pre-pass: ``` {?!HasCrypto?{?!HasFiat?

    Payment methods coming soon.

    }} ``` Read it as: _if not crypto and not fiat, render the fallback_. The conditional resolves **before** the random branch picker runs, so the output is fully determined by the variables. Composite logic that conditional spintax does _not_ support stays in the assembler — comparisons, `&&`/`||`, computed values. Pre-compute a guard variable, then gate it with `{?Guard?…}`. The dedicated [Conditional spintax guide](https://spintax.net/docs/conditional-spintax.md) covers the three forms, the truthy table, the two-pass pipeline, and the anti-patterns in detail. ## When NOT to compose Composition has overhead — three template kinds to maintain, an assembler to wire, a storage layer to organize. The pipeline pays off when: - you have five or more similar items sharing a structure; - per-id editorial nuances or sort-order requirements exist; - multiple pages reuse the same item set; - editors need to modify items independently. Skip composition when: - the page has one to three items total; - items don't repeat across pages; - nothing in the structure changes for the next year; - nobody but you will edit it. For a one-off about page or a single article, one self-contained template is faster, cleaner, and easier to debug. ### Prompt fragment — instructing the model to compose When asking a model to refactor a giant template into a composition pipeline, include these rules verbatim: - _"Identify groups of repeated structure (3+ items with the same shape). Each group is a candidate for an item template."_ - _"Extract per-item editorial nuances into the item templates. Do not duplicate them in the section or orchestrator."_ - _"Wrap each item group in a section template that uses an `%XxxItems%` placeholder."_ - _"Build the orchestrator from section variables only. Keep it under twenty lines."_ - _"Move every conditional out of spintax into the variable assembler. Spintax does not evaluate conditions; do not use enum branches as if-then-else."_ - _"For every item, suggest a stable id (lowercase, snake\_case) and propose where the item template would live (DB row, JSON key, file path)."_ Working through an agent? The whole series is condensed into the machine-readable [spintax-authoring skill](https://spintax.net/.well-known/agent-skills/spintax-authoring/SKILL.md), and every English page has a clean Markdown mirror — append `.md` to its URL. ## Common mistakes | Don't | Why | Do instead | | --- | --- | --- | | Compose a small page (≤3 items, no editorial variance) | Pipeline overhead is more than the saving. | Keep one self-contained template. | | Encode conditionals in spintax enums | Engine picks randomly, not based on values; output is non-deterministic. | Use `{?VAR?then|else}` for single-variable checks; compute composite logic in the assembler and gate it with `{?Guard?…}`. | | Inline per-id nuances in the orchestrator or section | Loses the "edit once, propagate everywhere" benefit. | Keep nuances in the per-id `_item` template. | | Mix item-level and section-level concerns in one template | Refactoring becomes painful as the page grows. | Three clean levels: item, section, orchestrator. | | Hardcode sort order in the orchestrator | Sort changes need page edits across the catalog. | Sort in the assembler from a single sort field on each item. | | Forget to short-circuit empty sections | Empty `

    ` with no `
      ` below it ships to production. | Return `""` from the assembler when the item list is empty. | | Trust un-resolved `%XxxItems%` leftovers in the rendered page | Missing variable means the placeholder survives literally. | QA pass that flags any leftover `%…%` in production HTML. | ## Composition checklist - Each repeated item group has its own per-id template. - Each section has a single `_section` template that references item placeholders. - The orchestrator references only section variables, not item variables. - Sort order comes from data, not from template content. - Single-variable conditionals use `{?VAR?…}`; composite logic stays in the assembler. Never enum branches. - Empty sections produce `""`, not stray markup. - Per-id editorial nuances are not duplicated in the section or orchestrator. - Five sample renders read cleanly across "all categories empty", "only crypto", "only fiat", "all categories present", and "single legacy item". - No leftover `%…%`, `{…}`, or `[…]` in any render. That is the end of the series for now. You have the mindset, variables, permutations, grammar, and now composition. [Loop back to the mindset](https://spintax.net/docs/authoring-mindset.md) when you start the next article — the workflow gets faster every time. --- ## Continue the series - [Reverse authoring mindset](https://spintax.net/docs/authoring-mindset.md): Write the text first. Add markup last. - [Variables & multi-site reuse](https://spintax.net/docs/variables.md): Precedence, reroll gotchas, separator collisions. - [Permutations in practice](https://spintax.net/docs/permutations.md): Lists, title-style headings, separator rules. - [Grammar-safe synonymization](https://spintax.net/docs/grammar-safe-spintax.md): Binding rules. English agreement. Russian cases: families, prepositions, gender. - [Conditional spintax](https://spintax.net/docs/conditional-spintax.md): Value-driven {?VAR?then|else} — the syntax-level alternative to random enum picks. - [Plural agreement](https://spintax.net/docs/plural-spintax.md): Pick the right noun form by count. RU/UK/BE and SR/HR/BS 3-form, EN-style 2-form. - [Repairing spintax templates](https://spintax.net/docs/repairing-spintax.md): Detect → one named recipe → re-check. Lint codes, the catalogue, the repair prompt. - [Spintax by example](https://spintax.net/examples.md): Every technique in the series, applied to one real paragraph, with real renders. [Open in playground](https://spintax.net/play/) [Back to all guides](https://spintax.net/docs.md) --- Source: https://spintax.net/docs/template-composition/