Two of our coupon-admin pages were 1990s string-soup. <%= %> interpolation was tangled into hand-written <table> and <form> HTML, and the whole thing was driven by an rst.MoveNext() ADO recordset loop. We rewrote both into a programmatic builder that reads this.div().span().label().input(). We did it without touching the app’s runtime, its framework, or its language. There was no webpack, no React, and no rewrite of a revenue app. It’s a small fluent builder bolted onto the page object, swapped in one page at a time.
We keep reaching for this pattern on the legacy application, and it applies to any string-template page you’re afraid to touch. Here is what changed and what each piece does.
The before: string-soup driven by a recordset
The list page opened a recordset and looped it straight into raw markup:
openRecordset("itemID, itemCode, ...", "dbo.coupons", "", "itemID desc");for (; !rst.EOF; rst.MoveNext()) { // <tr><td><%= itemInfo.itemCode %></td>...}The edit page was worse in a different way. It was a giant literal <form> with <%= dbC["itemCode"] %> sprinkled through every input value. Markup and data were interleaved so tightly that you couldn’t change one without reading both. Values went into the HTML with no encoding. The recordset lifecycle (.EOF, .MoveNext(), .Close()) had to be babysat by hand or it leaked.
Pages like this are scary because there’s no seam, not because the code is bad. Every edit is surgery on live HTML, and a misplaced quote in a <td> looks exactly like a logic bug until it renders.
The after: a fluent builder on the page prototype
The rewrite moves markup generation onto Page.prototype as chainable methods. Every element method appends a node and returns this, so the page reads as a tree instead of a string. The coupon table:
this.table({ parent: 'listContainer', css: 'table table-striped' }) .thead(...) .tr(...) .th(...);And the edit form, one field at a time:
this.div({ parent: 'editForm', id: 'field_itemCode' }) .span(...) .label({ "for": 'itemCode', content: 'Coupon Code:' }) .input({ type: 'text', css: 'form-control', name: 'itemCode', value: encode(this.coupon["itemCode"] || '') });That’s the whole trick, and there’s no framework underneath it. Each method is a thin wrapper. It creates an element, applies the attributes you passed, attaches it to its parent, and hands this back so the next call chains. You can write the first version in an afternoon and grow it method by method as pages need new tags.
Now there’s a seam. Markup generation is a set of named methods instead of a wall of literal HTML, and the methods compose. renderFields(), renderButtons() and renderList() became reusable methods instead of copy-pasted blocks.
Swapping the recordset for an array query
The builder rewrite pulled the data layer along with it. The ADO recordset became a plain array query:
this.coupons = db.query({ select, from, where, orderBy });db.query(...) returns a normal array, iterated with a normal for loop. The view no longer owns .EOF, .MoveNext() or .Close(), so the manual cursor-lifecycle hazards are gone from this layer.
The builder gives the markup a clean place to live, and the array query gives it clean data to consume. Together they turn “loop a cursor and emit strings” into “fetch an array and describe a tree.”
What fell out for free
We didn’t set out to fix security or styling. Both came along with the refactor anyway.
A visible encoding rule. In the old code, values went raw into HTML. In the new code they pass through encode(...) at the point they enter the builder: value: encode(this.coupon["itemCode"] || ''). That gives the rule one consistent place to apply and review, instead of raw interpolation scattered through the page.
Bootstrap classes replaced legacy cruft. The old pages carried <b class="b1"></b> rounded-corner spacers and inline style="text-align:right". The builder calls just pass css: 'table table-striped', 'form-control', 'text-right' and 'text-center'. The presentational junk disappeared because there was no longer any literal HTML to carry it.
An empty state. A recordset loop over zero rows renders nothing, which leaves a silently blank table. The builder version handles it explicitly:
if (!this.coupons || !this.coupons.length) { // td({ colspan: headers.length + 1, content: 'No coupon codes found.' })}What it costs
This is not a free lunch. A fluent builder is more verbose per element than inline HTML. this.input({ type: 'text', css: 'form-control', name: 'itemCode', value: ... }) is more characters than <input type="text" ...>, and you lose at-a-glance readability. You can’t eyeball the page’s HTML structure the way you can with literal tags. You read method calls and infer the DOM.
In exchange you get composability through the reusable render methods, consistent escaping through one choke point instead of N interpolation sites, and an incremental migration path. That last one is why this works on a real app. We converted two pages. The other few hundred still run the old recordset-and-string pattern, untouched, in production. Old and new coexist with zero coordination because the builder lives on the page object, and no framework demands that the whole app adopt it.
Where it shipped
The coupon refactor merged to the preview branch, not as a hotfix. It has no user-visible change because it’s internal admin, so it rides the preview lane and goes out in the normal release. The same morning’s two actual production bugs got hotfixed and tagged immediately. A builder rewrite needs preview eyes on it before it ships.
The change is roughly 700 lines across the two files, 478 insertions and 235 deletions. The builder is more lines than the soup it replaced. You’re trading raw brevity for a seam, and on a revenue app you can’t stop to rewrite, that trade pays for itself the first time you touch the third page.
Related
- Server-Side JavaScript on Classic ASP in 2026: Prototype Pages and the DB Helper Pattern: the page-prototype and database-helper patterns that the DOM builder extends
- Don’t invent a new style, expose the existing one: a product-taste lesson in feature design: the same incremental principle applied to a different design surface