Law firms, HR teams and freelancers draft the same Word documents over and over: engagement letters, NDAs, offer letters, invoices. Document automation tools exist for this, but the established ones are cloud services. They cost roughly $80 to $400 a month, and they all start with the same step: upload your templates and your clients' details to someone else's server.
I wanted to see how far the browser alone could go. The result is Clausery: you take a .docx you already use, mark the parts that change with tags, and it turns them into a short questionnaire. Answer it, and the finished Word file is assembled on your own machine. There is no backend at all. The whole product is a folder of static files on GitHub Pages.
This post covers how it works and the parts that were harder than I expected.
From tags to a questionnaire
A template is an ordinary Word document with tags in it:
Dear {client_salutation},
This letter confirms that {firm_name} will represent {client_name} in {matter_description}.
{#has_retainer}We require an advance deposit of {retainer_amount}.{/has_retainer}
{#attorneys}
• {name}, {role}: {rate} per hour
{/attorneys}
docxtemplater does the actual filling, and its inspect module gives me every tag and where it sits. The interesting part is turning that list into questions nobody has to configure by hand. Clausery guesses from the tag names:
-
client_nameis short text,client_addressis long text,invoice_dateis a date,retainer_amountis money,term_yearsis a number. -
{#has_retainer}…{/has_retainer}is a yes/no question, because of thehas_prefix (alsois_,include_,needs_and a few others). Every question inside it is hidden until the box is ticked. -
{#attorneys}with tags inside and a plural name becomes a repeating group: "Add attorney", with name, role and rate for each. - Tags sharing a prefix (
client_*,party_a_*) are grouped into sections.
The guesses are only a starting point. A designer screen lets you change labels, types, help text and rules, and the library templates ship with a hand-written setup on top of the inferred one.
Two Word quirks needed handling before any of this worked reliably:
-
Tags split across runs. Word happily stores
{client_name}as{cli+ent_na+me}in three runs with different formatting. docxtemplater copes with this, which is most of the reason I used it. -
Stray whitespace around section tags. A
{#has_guarantor}alone on its own line should disappear along with its paragraph. It does, but only if the tag is the paragraph's only content, and Word users very often leave a trailing space or tab. That left blank lines all over generated documents. The fix is a small pass over the XML before the engine sees it, trimming whitespace from paragraphs that hold nothing but a section tag.
A tag that opens in the first cell of a table row and closes in the last cell repeats the whole row, which is how invoices get a proper line-item table:
| {#line_items}{item_name} | {item_quantity} | {item_rate} | {item_amount}{/line_items} |
A tiny expression language instead of eval
Questions need rules ("show this only if the client is a company") and some fields need calculations (line amount = quantity × rate, then subtotal, tax and total). The obvious shortcut is to let template authors write JavaScript. I didn't want that, for two reasons:
- The app's Content Security Policy is
script-src 'self'with nounsafe-eval, sonew Function()is off the table anyway. - Templates get shared between people. A template should never be able to run code.
So there is a small expression language with its own lexer, a recursive-descent parser and an evaluator. Identifiers resolve only against the answers object: there is no access to globals or prototypes. It supports arithmetic, comparisons, and/or/not, lists, member access into repeating rows, and 33 functions (sum, count, round, if, days_between, words for amounts in words, and so on). The invoice template's tax line looks like this:
round((subtotal_amount - if(has_discount, discount_amount, 0)) * tax_rate / 100, 2)
Nesting is capped at 100 levels, so a hostile template gets a friendly error instead of a stack overflow. Chains of the same operator (a + b + c + …) parse to one flat node and don't count towards that limit.
Evaluation order turned out to be the subtle part. A computed total can depend on a field that is hidden by a rule that depends on another computed field. Clausery builds a dependency graph of every field, including each child of a repeating group, and evaluates in topological order. For each field it first decides visibility, then blanks hidden answers before anything else reads them. That way a total never includes a discount from a box that was unticked three edits ago. A cycle made only of expressions (a = b + 1, b = a + 1) is reported on the fields involved instead of hanging the form.
Storage, encryption and lock-down
Templates, drafts and settings live in IndexedDB. There is an optional "vault" that encrypts the workspace at rest using only WebCrypto:
// passphrase → PBKDF2-SHA256, 600,000 iterations → AES-256-GCM key
const base = await crypto.subtle.importKey('raw', enc.encode(passphrase.normalize('NFKC')), 'PBKDF2', false, ['deriveKey']);
const key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' },
base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
A small encrypted verifier tells the app whether a passphrase is right, so neither the passphrase nor the key is ever stored. There is no recovery: lose the passphrase and the data is gone, which is the point.
"Nothing leaves your browser" is a claim, so the app is built to make it checkable. The Content Security Policy is the real contract:
default-src 'self'; script-src 'self'; connect-src 'self' https://api.lemonsqueezy.com;
object-src 'none'; base-uri 'self'; form-action 'none'
connect-src allows the app's own origin, plus a license check that only ever sends a license key if you buy one. There are no analytics or third-party scripts in the app. A service worker precaches the app files, so after the first visit it works offline, and each release ships a new cache version so updates are atomic.
Live preview
The newest feature renders the actual document beside the questions as you type. Every change (debounced to 600 ms) builds the .docx in memory and hands it to docx-preview, which renders it as HTML in a side panel scaled to fit. Unanswered questions show as [Client name]-style placeholders, so you can see the whole shape of the document from the first second.
Rendering into a staging element and swapping it in, guarded by a sequence number, stops a slow render from overwriting a newer one when you type quickly.
Client intake without a server
Firms often want the client to answer some of the questions. With no server, there is nowhere to host a form. So Clausery exports the questionnaire as a single self-contained HTML file with an inlined runtime. The client opens it offline, fills it in and saves an answers file, which the firm imports into a draft. It is clunkier than a hosted form, but no client data ever touches a third party, including me.
Numbers
- About 3,500 lines of app JavaScript: ES modules, no framework, no build step for the app itself.
- One vendor bundle (docxtemplater, PizZip, docx-preview) of 138 KB gzipped.
- 84 unit tests with
node:test(the expression language, evaluation order, the vault, licensing) and 118 Playwright tests covering the app, accessibility checks with axe, and the static site. - 87 free templates in the library, from NDAs to invoices, each with a page showing its full wording.
What I'd like feedback on
The weakest part is the step where you add tags in Word. Inference covers common names well, but real firm precedents are messy: tracked changes, content controls, numbering that breaks when a section disappears. If you have a Word file that breaks it, I'd like to see what happens.
You can try it at getclausery.github.io. The template library is free with no account, and there's a free invoice template if you want to see the calculations and live preview without writing any tags.
Top comments (2)
I'd make tracked changes part of the template contract before inferring questions: does the app use the accepted version, the visible markup, or reject unresolved revisions? A tag inside deleted text could otherwise become a question that nobody sees in the Word document.
A useful fixture would put a conditional clause in a numbered list, split its tag across runs, and delete one tag fragment with Track Changes. Test both keeping and removing the clause, then open the generated DOCX in Word or LibreOffice as well as the HTML preview. That would check that question discovery, numbering, and the delivered document agree, rather than only the preview looking right.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.