README.md

# Glamour

> Zero-flash SSR + claim for Lustre/Gleam on the BEAM.

Glamour renders your Lustre view on the server, serialises the exact model, fingerprints the markup, and claims the live DOM on the client without wiping it. If the fingerprint changes, the client falls back to hydrate and logs helpful errors. The goal is a two-line upgrade: one call on the server, one on the client, no flash between the first paint and the claimed DOM.

Project home: https://github.com/thirdreplicator/glamour

---

## Why Glamour

- **Zero-flash first paint** – Server delivers the finished HTML and reuses it, so the user never sees an empty shell.
- **One-line ergonomics** – `server.render` on the backend and `client.claim` on the frontend.
- **Fingerprint safety** – Stable SHA-256 hash over serialised state + markup keeps server/client in sync.
- **Helpful diagnostics** – Clear console output for missing state, selector mismatches, and Lustre start failures.
- **Target flexibility** – Works on both Erlang and JavaScript backends; incubated here prior to Hex publishing.

---

## Installation

### Incubator workflow (inside this repo)

1. Add Glamour as a path dependency (already configured here):
   ```toml
   # gleam.toml
   [dependencies]
   glamour = { path = "../lib/glamour" }
   ```
2. Fetch dependencies and build:
   ```bash
   gleam deps download
   gleam build --target erlang
   ```

### Future Hex package (roadmap)

Once published, replace the path dependency with a semantic version:
```toml
[dependencies]
glamour = "~> 0.1"
```

---

## Usage

### 1. Describe your Lustre app

Create an application spec that tells Glamour how to render, serialise, and decode your model. The spec assumes the Lustre app’s `start_args` type matches the model.

```gleam
import glamour/app
import lustre
import lustre/element/html as html
import gleam/json
import gleam/dynamic/decode as decode

pub type Model {
  Model(count: Int)
}

fn init(model: Model) -> Model { model }
fn update(model: Model, _msg) -> Model { model }
fn view(Model(count:)) -> html.Node { html.text(int.to_string(count)) }

fn encode_model(Model(count:)) -> json.Json {
  json.int(count)
}

fn decode_model() -> decode.Decoder(Model) {
  decode.map(decode.int, Model)
}

pub fn spec() -> app.Spec(Model, msg) {
  let lustre_app = lustre.simple(init, update, view)
  app.new(lustre_app, view, encode_model, decode_model())
  |> app.with_selector("#app")            // optional, default is "#app"
  |> app.with_state_script_id("glamour-state") // optional, default is "glamour-state"
}
```

### 2. Render on the server

Call `glamour/server.render/3` inside your HTTP handler and return the HTML string it produces. The helper embeds the SSR fragment, serialised state, fingerprint, and client script tags.

```gleam
import glamour/server
import gleam/option

pub fn render_page(model) -> server.Rendered {
  let spec = spec()
  let options =
    server.default_options()
    |> with_glamour_scripts()
    |> server.Options(..)
    |> option.Some

  server.render(spec, model, options)
}

fn with_glamour_scripts(options: server.Options) -> server.Options {
  server.Options(
    ..options,
    title: option.Some("Dashboard"),
    client_scripts: ["/assets/glamour/main.mjs"],
    head: [
      ..options.head,
      "    <link rel=\"stylesheet\" href=\"/assets/app.css\">\n",
    ],
  )
}
```

`server.Options` fields:

- `lang` – HTML language tag (`"en"` default).
- `title` – Optional document title.
- `csp_nonce` – Attach CSP nonce to embedded script tags.
- `head` – Extra head markup (strings that already contain trailing newlines).
- `client_scripts` – `<script>` tags (module or classic) appended after the head entries.
- `stream` – Reserved for future streaming support.

The returned `Rendered(html)` contains a complete HTML document. Render failures bubble up as normal BEAM errors.

### 3. Claim on the client

Bundle a small entry point that calls `glamour/client.claim/2` (or `/3` with options) for each page that should reuse the SSR DOM.

```gleam
import glamour/client
import glam_app/dashboard
import gleam/option

@target(javascript)
pub fn main() -> Nil {
  case client.claim(dashboard.spec(), option.None) {
    Ok(_) -> Nil
    Error(error) -> handle_error(error)
  }
}
```

`client.Options` currently supports `strict` mode. When `strict: True`, a fingerprint mismatch logs an error and skips hydration; otherwise the client logs a warning and hydrates.

### 4. Ship the JavaScript bundle

1. Compile the JS target:
   ```bash
   gleam build --target javascript
   ```
2. Copy the generated module(s) into your static assets directory. For example:
   ```bash
   mkdir -p priv/static/assets/glamour
   cp build/dev/javascript/myapp/myapp/glamour/client_bundle.mjs priv/static/assets/glamour/main.mjs
   rsync -a build/dev/javascript/glamour priv/static/assets/glamour/
   ```
   Adjust the paths to match your app name and deployment pipeline.
3. Reference the bundle in the `client_scripts` list so the browser loads it:
   ```html
   <script type="module" src="/assets/glamour/main.mjs"></script>
   ```

---

## Source Layout

- `src/glamour/*` – Target-agnostic modules used on both Erlang and JavaScript (server rendering, fingerprints, spec helpers).
- `src-js/glamour/*` – JavaScript-only modules (`client.gleam`, `dom.gleam`, `dom.ffi.mjs`). Keeping them in `src-js` means you do **not** need to move files between targets; Gleam automatically picks the right version during compilation.

---

## Contracts & Assumptions

- Your Lustre `App` must accept its model as `start_args`; Glamour passes the reclaimed model directly into `lustre.start`.
- The `view` used on the server must match the one supplied to `lustre.start` or fingerprints will diverge.
- JSON encoders/decoders must round-trip the model losslessly. If parsing fails the client logs `JsonParse` and leaves the SSR DOM untouched.
- The DOM selector in `Spec.selector` must resolve to the root element rendered by the server. A missing selector logs `MissingRoot`.
- Glamour embeds the state as `<script type="application/json" id="{state_script_id}">`. Do not mutate or rename this node on the server.
- Fingerprints rely on the serialised JSON and raw HTML fragment. Any server-side post-processing (e.g., analytics scripts) should wrap, not mutate, the `<div>` Glamour controls.

---

## Error Handling

- **Server render failures** bubble up as normal exceptions; let your HTTP framework surface them or wrap the call to return a diagnostics page.
- **Client errors** return `Result(Nil, client.Error)` and log to the console:
  - `NotInBrowser` – Guard for server-side or test environments.
  - `MissingRoot`, `MissingStateScript`, `MissingFingerprint` – Misconfiguration signals.
  - `JsonParse`, `LustreStart` – Data/initialisation issues.
  - `FingerprintMismatch` – Fingerprints differ; hydration fallback or strict abort.

Use the error constructors in tests to assert the correct behaviour.

---

## Conventions

- Selector defaults to `#app`; state script id defaults to `glamour-state`.
- Script tags inserted by `server.Options.client_scripts` should include trailing newlines, matching how Gleam concatenates head elements.
- Client bundle entry points live under `@target(javascript)` modules (e.g., `src-js/your_app/glamour/client_bundle.gleam`).
- When incubating inside another project, keep build artefacts in `priv/static/assets/glamour/` so they can be served by Mist or Plug.

---

## Example: MyApp Admin

A typical integration renders admin routes with Glamour:

- Server specs: `src/myapp/glamour/login.gleam`, `src/myapp/glamour/admin.gleam`
- Client bundle: `src-js/myapp/glamour/client_bundle.gleam` → `/assets/glamour/main.mjs`
- Your HTTP handler returns the Glamour-produced HTML document.

Run the developer server with:
```bash
gleam run --target erlang --module main -- server
```

---

## Testing

Server-side tests:
```bash
gleam test --target erlang
```

Client-side behaviour can be smoke-tested by loading the bundled app in a browser; upcoming work will add harness tests around `client.claim`.

---

## Roadmap

- Streaming SSR support (`Options.stream`)
- Dev overlay with pretty error rendering
- Hex package metadata & publish checklist
- Production-ready asset pipeline (tree-shaken JS bundle)

---

Released under the MIT licence.