Discovery
On the lqdev.me homepage timeline, review cards showed the correct specific type for a recent movie ("Movie") but fell back to the generic "Review" for an older book ("We"). Both reviews use the same :::review block with itemType: "movie" / itemType: "book", so the data was clearly present somewhere.
Root Cause
The homepage has two rendering paths for timeline cards:
- Server-side (F# / Giraffe ViewEngine) — the newest N items are rendered as HTML by
Views/TimelineViews.fs. ItsextractReviewItemTypeparses the hiddendata-item-type="…"span emitted byReviewProcessor.RenderCard, so recent reviews showed the right badge. - Client-side (JavaScript) — older items are embedded as JSON (
<script id="remainingContentData-reviews">) and rendered by_src/js/timeline.js. The JS version ofextractReviewItemTypeonly parsed the legacyitem-type-badge badge bg-secondary">pattern. The server had switched to the newdata-item-typehidden span, so the JS parser returnednulland the badge fell back to"Review".
The real bug was two divergent HTML parsers that had to be kept in sync. The recent movie happened to be server-rendered; the older book was client-rendered, exposing the mismatch.
Solution
Stop parsing HTML in the client. Add a structured field to the progressive-loading JSON model and let the server compute the value once using the existing (correct) parser.
F# model change
Views/TimelineViews.fs:
type ProgressiveContentItem =
{ title: string
contentType: string
date: string
url: string
content: string
rsvpStatus: string
reviewItemType: string // "Book", "Movie", "" for non-reviews
tags: string[] }
Populate it when serializing remaining items:
{ title = item.Title
contentType = item.ContentType
date = item.Date
url = properPermalink
content = item.BodyHtml.Value
rsvpStatus = (match item.RsvpStatus with Some s -> s | None -> "")
reviewItemType =
(if item.ContentType = "reviews" then
extractReviewItemType item.Content |> Option.defaultValue ""
else "")
tags = item.Tags }
JavaScript change
_src/js/timeline.js:
const contentTypeBadge = (() => {
if (item.contentType === 'reviews') {
// Structured field emitted server-side
return item.reviewItemType || 'Review';
}
// ... other content types
})();
Then remove the now-unused extractReviewItemType method entirely.
Prevention
- One source of truth: compute metadata server-side and ship it as data, not as markup to be reverse-engineered.
- Avoid duplicating parsers: if F# and JS both need the same value, put it in the JSON payload rather than parsing the same HTML twice in two languages.
- Audit existing HTML scraping in JS: search for
indexOf,innerHTMLslicing, or regex parsing in_src/js/*.js; these are candidates for structured fields. - When markup must carry data, use
data-*attributes and expose the same value in JSON, so the client never needs to parse the DOM string.