Two of my coupon-admin pages were 1990s string-soup: <%= %> interpolation tangled into hand-written <table> and <form> HTML, the whole thing driven by an rst.MoveNext() ADO recordset loop. I rewrote both into a programmatic builder that reads this.div().span().label().input(), and I did it without touching the app’s runtime, its framework, or its language. No webpack, no React, no rewrite of a revenue app. A small fluent builder bolted onto the page object, swapped in one page at a time.

This is the pattern I keep reaching for on Classic ASP, and it generalizes to any string-template page you’re afraid to touch. Here’s exactly 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: a giant literal <form> with <%= dbC["itemCode"] %> sprinkled through every input value. The classic Classic-ASP shape. Markup and data interleaved so tightly that you can’t change one without reading both, values dumped into HTML with no encoding, and a recordset lifecycle (.EOF, .MoveNext(), .Close()) you have to babysit by hand or leak.

The thing that makes pages like this scary isn’t that the code is bad. It’s that there’s no seam. Every edit is a surgery on live HTML, and a misplaced quote in a <td> is indistinguishable from 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. There’s no framework underneath it. Each method is a thin wrapper that 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.

The win is that you now have a seam. Markup generation is a set of named methods, not a wall of literal HTML, and you can compose them: renderFields(), renderButtons(), renderList() became reusable methods instead of copy-pasted blocks.

Swap 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(), which removes the manual cursor-lifecycle hazards from this layer.

The builder gives you a clean place to put markup; the array query gives you clean data to feed it. Together they turn “loop a cursor and emit strings” into “fetch an array and describe a tree.”

The wins that fell out for free

I 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, values pass through encode(...) at the point they enter the builder: value: encode(this.coupon["itemCode"] || ''). The builder gives that rule one consistent place to apply and review instead of scattering raw interpolation 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', 'text-center'. The decade-old presentational junk evaporated because there was no longer any literal HTML to carry it.

There was also an empty-state win the old loop never had. A recordset loop over zero rows renders nothing, 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.' })
}

The trade-off

This is not a free lunch, and I don’t want to sell it as one. 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 markup readability. You cannot eyeball the page’s HTML structure the way you can with literal tags. You’re reading method calls and inferring the DOM.

What you buy for that cost: composability (the reusable render methods), consistent escaping (one choke point, not N interpolation sites), and an incremental migration path. That last one is the reason this works on a real app. I 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, not in a framework that demands the whole app adopt it.

The numbers and the routing

The interesting part of shipping this is where it routed, not the line counts. The coupon refactor merged to the preview branch, not as a hotfix. It has no user-visible change, 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. Refactors do not get hotfix urgency, and a builder rewrite needs preview eyes on it before it ships.

Roughly 700 lines of change across the two files (478 insertions, 235 deletions). The builder is genuinely more lines than the soup it replaced. You’re trading raw brevity for a seam, and on a revenue app you can’t stop the world to rewrite, that trade pays for itself the first time you touch the third page.