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

216 lines
7.9 KiB
Markdown

# 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.
```html
<!-- header.htmlx -->
<header>
<nav>
<a href="/">writeonce</a>
<a href="/about">about</a>
<a href="/contact">contact</a>
</nav>
</header>
```
```html
<!-- 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>
```
```html
<!-- 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:
```html
<!-- article.htmlx -->
<!-- subscribe: article WHERE sys_title = :route_param -->
<article>
<h1>{{article.title}}</h1>
...
</article>
```
```html
<!-- 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](./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:
```html
<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.
```html
<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.