Two-Step View
Posted by Dustin Boston .
The Two-Step View pattern renders domain data into HTML in two distinct stages. The first step transforms the data into a uniform, presentation-oriented structure. This structure is free of specific formatting (like HTML). For example, it might include fields or tables that represent the logical layout of a screen. The second step takes the intermediate structure and renders it to HTML.
Source Code Listing
code.ts
/**
* Domain Data Model for Artists
*/
const artists = [
{name: "Eminem", genre: "Rap", origin: "Detroit, MI"},
{name: "Deftones", genre: "Alternative Metal", origin: "Sacramento, CA"},
{name: "Sublime", genre: "Ska Punk", origin: "Long Beach, CA"},
];
export class Artist {
static findNamed(name: string | null): Artist | undefined {
if (!name) return undefined;
const artist = artists.find(
(x) => x.name.toLowerCase() === name.toLowerCase(),
);
return artist
? new Artist(artist.name, artist.genre, artist.origin)
: undefined;
}
constructor(
public name: string,
public genre: string,
public origin: string,
) {}
}
const albums = [
{artist: "Eminem", title: "The Marshall Mathers LP", year: 2000},
{artist: "Deftones", title: "White Pony", year: 2000},
{artist: "Sublime", title: "40oz. to Freedom", year: 1992},
];
export class Album {
static findNamed(name: string | null): Album[] {
if (!name) return [];
return albums
.filter((album) => album.title.toLowerCase() === name.toLowerCase())
.map((album) => new Album(album.artist, album.title, album.year));
}
constructor(
public artist: string,
public title: string,
public year: number,
) {}
}
export class Screen {
constructor(
public field: string,
public value: string,
public table: [key: string, value: string][],
) {}
}
export class CustomRequest {
public model: Artist | undefined;
constructor(protected request: Request) {}
get url(): URL {
return new URL(this.request.url);
}
}
/**
* First Step: Transform Artist and Album into Screens
*/
class ArtistScreen {
constructor(public artist: Artist) {}
render(): Screen {
return {
field: "Artist",
value: this.artist.name,
table: [
["Genre", this.artist.genre],
["Origin", this.artist.origin],
],
};
}
}
class AlbumScreen {
constructor(public album: Album) {}
render(): Screen {
return {
field: "Album",
value: this.album.title,
table: [
["Artist", this.album.artist],
["Year", this.album.year.toString()],
],
};
}
}
/**
* Second Step: Transform a Screens into HTML
*/
class PageCreator {
constructor(public screen: Screen) {}
process() {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${this.screen.field}: ${this.screen.value}</title>
</head>
<body>
<h1>${this.screen.field}: ${this.screen.value}</h1>
<table>
<tr><th>Property</th><th>Value</th></tr>
${this.screen.table
.map(([key, value]) => `<tr><td>${key}</td><td>${value}</td></tr>`)
.join("\n")}
</table>
</body>
</html>
`;
}
}
/**
* Front Controller Pattern
* Implements the two-step pattern.
*/
class ArtistCommand {
constructor(public request: Request) {}
process(): Response {
const url = new URL(this.request.url);
const name = url.searchParams.get("name");
const artist = Artist.findNamed(name);
if (!artist) {
return new Response("Artist not found", {status: 404});
}
const artistScreen = new ArtistScreen(artist).render();
const pageCreator = new PageCreator(artistScreen);
return new Response(pageCreator.process(), {status: 200});
}
}
class AlbumCommand {
constructor(public request: Request) {}
process(): Response {
const url = new URL(this.request.url);
const name = url.searchParams.get("name");
const albums = Album.findNamed(name);
if (albums.length === 0) {
return new Response("Album not found", {status: 404});
}
const albumScreen = new AlbumScreen(albums[0]).render();
const pageCreator = new PageCreator(albumScreen);
return new Response(pageCreator.process(), {status: 200});
}
}
Bun.serve({
routes: {
"/artist": (req) => {
return new ArtistCommand(req).process();
},
"/album": (req) => {
return new AlbumCommand(req).process();
},
},
});