writeonce/docs/04-ui.md
2026-04-05 00:45:02 +02:00

7.9 KiB

User Interface — Server-Rendered HTMLX

No Angular. No React. No frontend framework. The UI is a set of .htmlx template files that the server parses, populates with content from the embedded database, and serves as plain HTML. Real-time updates arrive via SSE and are applied with minimal client-side scripting.

Why Not Angular

The current writeonce-app is an Angular 18 SPA with Tailwind, PrismJS, ngx-markdown, and FontAwesome. It works, but it's a heavy delivery mechanism for what is fundamentally a read-heavy content site:

  • ~200MB of node_modules for a site that renders markdown articles
  • Client-side routing for content that doesn't need it — every article is a distinct URL, not an interactive application
  • JavaScript-dependent rendering — content doesn't exist until Angular boots, hydrates, and fetches from the API
  • Separate build pipeline — npm run build produces static assets that must be deployed to nginx independently of the API

The content is static between updates. The interactivity is limited to navigation and code highlighting. A server-rendered approach matches the actual requirements.

HTMLX Templates

The author defines the site layout using .htmlx files — HTML with embedded data bindings that the server resolves at render time.

Template Structure

templates/
  layout.htmlx           # outer shell: <html>, <head>, <body>
  header.htmlx           # site header, navigation
  footer.htmlx           # site footer
  home.htmlx             # homepage: article list
  article.htmlx          # single article view
  about.htmlx            # static page
  contact.htmlx          # static page
  components/
    article-card.htmlx   # summary card for article listings
    code-snippet.htmlx   # code block with language + title
    img-caption.htmlx    # image with caption
    section.htmlx        # article section (heading + paragraphs)

Template Syntax

Templates use a binding syntax that references content from the database. The server parses these bindings, resolves them against the current content, and outputs plain HTML.

<!-- header.htmlx -->
<header>
  <nav>
    <a href="/">writeonce</a>
    <a href="/about">about</a>
    <a href="/contact">contact</a>
  </nav>
</header>
<!-- article.htmlx -->
<article>
  <h1>{{article.title}}</h1>
  <p class="meta">by {{article.author}} &middot; {{article.tags}}</p>

  {{#each article.sections}}
  <section>
    <h2>{{heading}}</h2>
    {{#each paragraphs}}
      <p>{{this}}</p>
    {{/each}}
  </section>
  {{/each}}

  {{#each article.codes}}
    {{> code-snippet snippet=this}}
  {{/each}}

  {{#each article.images}}
    {{> img-caption image=this}}
  {{/each}}
</article>
<!-- home.htmlx -->
<main>
  <h1>articles</h1>
  {{#each articles}}
    {{> article-card article=this}}
  {{/each}}
</main>

The {{> partial}} syntax includes another .htmlx file as a component. The server resolves these at render time — no client-side component tree.

Content Subscription in Templates

Templates declare what data they need. The server resolves these declarations against the embedded database and subscribes the client to changes:

<!-- article.htmlx -->
<!-- subscribe: article WHERE sys_title = :route_param -->

<article>
  <h1>{{article.title}}</h1>
  ...
</article>
<!-- home.htmlx -->
<!-- subscribe: articles WHERE published = true ORDER BY date DESC LIMIT 10 -->

<main>
  {{#each articles}}
    {{> article-card article=this}}
  {{/each}}
</main>

The <!-- subscribe: ... --> comment is a directive to the server. It declares the query that populates the template's data context. The same query is used to register an SSE subscription for live updates (as described in 03-data.md).

Rendering Pipeline

   Browser request
        |
        v
   Route match (/blog/linux-misc)
        |
        v
   Load template (article.htmlx)
        |
        v
   Parse subscribe directive
   (article WHERE sys_title = "linux-misc")
        |
        v
   Query embedded database (.seg index)
        |
        v
   Resolve template bindings ({{article.title}}, etc.)
        |
        v
   Compose with layout.htmlx + header.htmlx + footer.htmlx
        |
        v
   Inject SSE subscription script
        |
        v
   Send complete HTML response

The browser receives a fully rendered page on first load. No JavaScript framework boots. No API call fires. The content is already in the HTML.

Live Updates via SSE

After the initial HTML is delivered, a small inline script opens an SSE connection for the page's subscription query:

<script>
  const source = new EventSource('/subscribe/blog/linux-misc');
  source.onmessage = (event) => {
    const diff = JSON.parse(event.data);
    applyDiff(diff);
  };
</script>

The applyDiff function is a lightweight client-side updater — it targets DOM elements by data attribute and patches their content. No virtual DOM, no reconciliation, no framework. For a content site where updates are infrequent and localized (a paragraph changed, a tag was added), direct DOM manipulation is sufficient.

<h1 data-bind="article.title">Linux Misc</h1>
<p data-bind="article.sections[0].paragraphs[0]">First paragraph...</p>

When a diff arrives for article.title, the script finds the element with data-bind="article.title" and replaces its text content. This is the minimal client-side code the architecture requires.

Code Highlighting

The current frontend uses PrismJS for syntax highlighting. In the server-rendered model, highlighting can happen at either layer:

Server-side (preferred): The server parses code blocks during template rendering and emits pre-highlighted HTML with CSS classes. The browser only needs the PrismJS CSS theme, not the JavaScript library. This eliminates client-side parsing entirely.

Client-side (fallback): Include PrismJS as a small script that runs on page load and on SSE update. Simpler to implement initially but adds a JavaScript dependency.

Markdown Rendering

The current frontend uses ngx-markdown and marked to parse markdown in the browser. In the target architecture, markdown is rendered to HTML on the server during template composition. The browser never sees raw markdown.

This aligns with the content model: the JSON metadata already defines the article structure (sections, paragraphs, code snippets, images). The markdown file provides prose content. The server combines both into final HTML — the template just places the pre-rendered blocks.

What Gets Removed

Current (Angular) Target (HTMLX)
writeonce-app/ (full Angular project) templates/ (handful of .htmlx files)
node_modules/ (~200MB) None
angular.json, tsconfig.json, karma.conf.js None
npm build pipeline Template parsed at request time
Nginx static file serving Binary serves its own HTML
Client-side routing Server-side route matching
Client-side markdown parsing Server-side rendering
Client-side code highlighting Server-side or minimal JS

Styling

Templates use plain CSS. Tailwind can optionally be used as a build-time utility (generating a static CSS file), but there is no runtime CSS framework. The author writes styles in a styles.css file that the server serves as a static asset.

templates/
  styles/
    main.css              # site-wide styles
    article.css           # article-specific styles
    code-theme.css        # syntax highlighting theme (PrismJS compatible)

Template Authoring Experience

The .htmlx files are editable by the same author who writes articles. The template syntax is intentionally close to HTML — there's no JSX, no TypeScript, no build step. An author who knows HTML can modify the site layout.

This closes the loop on the writeonce philosophy: the author writes content (markdown + JSON) and layout (.htmlx + CSS) as files, and the binary turns them into a live site.