---
title: "2026-07-31 infrastructure blog formatting latex"
description: "Dashboard blog now renders Notion rich-text annotations and LaTeX (mono#295 -> draft PR mono#296)."
image: "https://docs.virdx.dev/img/virdx-social-card.png"
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.virdx.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# 2026-07-31 infrastructure blog formatting latex

## Work Done

Worked mono issue [#295](https://github.com/virdx/mono/issues/295) ("support
formatting and latex in dashboard-blog") through to draft PR
[#296](https://github.com/virdx/mono/pull/296) on branch
`feat/blog-formatting-latex`.

Diagnosis: the loss was entirely on the **API** side, not the frontend.
`apps/dashboard-api/dashboard_api/services/notion.py` flattened every Notion
rich-text array through `_plain`, which concatenates `plain_text` and discards
`annotations`, `href`, and equation spans. Block-level `equation` blocks carry
no `rich_text` at all, so `to_markdown`'s `elif text:` fallback dropped them
silently. The frontend was already running `react-markdown`, so it would have
rendered anything the API actually emitted.

Changes:

- **dashboard-api**: new `_markup`/`_span` beside `_plain`. `_span` wraps a
  span's _trimmed_ core innermost-first (`` ` `` -> `**` -> `_` -> `~~`), then
  in a link if `href` is set. Equation spans -> `$$...$$`; `equation` blocks ->
  a fenced `$$` on its own lines. `_plain` retained for titles, tagline, image
  captions, and code-block bodies. `underline` deliberately dropped (no
  Markdown equivalent, and no `rehype-raw` in the pipeline).
- **dashboard-frontend**: added `remark-math` + `rehype-katex` + `katex` to
  `features/blog/markdown.tsx`; `.katex-display` gets `overflow-x-auto`;
  `excerpt()` in `presentation.ts` strips `$`/`~` too.

Validation was local only (no browser): 152 pytest cases pass (5 new),
`nx build dashboard-frontend` emits the KaTeX font assets, biome clean on
`src/features/blog/`, `check-boundaries` passes. Two pre-existing `tsc`
errors (`DataSourcePicker.tsx`, `useBenchmarkSeries.ts`) reproduce unchanged
on `main`.

## Lessons Learned: Pitfalls

- **`ntn` (Notion CLI) is installed but not logged in**, and the claude.ai
  Notion connector needs an interactive OAuth run. Listing 1Password items to
  recover the dashboard's Notion token was blocked by the permission
  classifier. Net effect: **there is no way to look at real Notion block JSON
  from a headless session.** I proceeded from the documented Notion rich-text
  schema and a hand-built fixture instead, which was fine — but a future agent
  should not burn turns trying to reach live Notion. Worth recording in the
  dashboard SOP.
- The `dashboard-app-dev-and-deploy` SOP's mock-mode advice does **not** cover
  the blog: `DASHBOARD_MOCK=true` has no blog fixture at all (`mock_data.py`
  contains nothing blog-related, and `routers/notion.py` has no `settings.mock`
  fast-path, unlike every other router). So "just use mock mode" is not an
  option for blog work. The SOP reads as if mock mode covers _every_ endpoint;
  it should be narrowed.
- `bunx nx run dashboard-frontend:typecheck` does not exist — the frontend
  `project.json` only defines `dev`/`build`/`format`. Typecheck and lint run as
  `bun run typecheck:check` / `bun run lint:check` from the app directory.
- Repo-wide `bun run lint:check` on dashboard-frontend reports ~171
  pre-existing biome errors and `tsc --noEmit` two pre-existing type errors.
  Scope both to touched files, and stash-and-rerun to confirm a baseline before
  chasing anything.
- `pixi run` needs the sandbox disabled (global rattler cache lock is outside
  the writable set), same class of problem as the already-recorded `op` note.
- `ruff format --check` flags `services/notion.py` and `tests/test_blog_service.py`
  on `main` already. Do not "fix" it in a feature PR — the diff lands in
  untouched code.

## Lessons Learned: Improvements

The single most valuable missing fact, which cost the most time to establish
empirically: **remark-math only treats `$$` as _display_ math when the fence
sits on its own line.** Emitting `$$expr$$` inline produced _inline_ math
(verified: `katex-display: 0`). Related and non-obvious: remark-math's default
`singleDollarTextMath: true` would turn "storage runs $5 per scan and $12 per
cohort" into math, so this PR emits inline equations doubled (`$$x$$`) and sets
`singleDollarTextMath: false`. Both facts belong wherever the blog rendering
pipeline is documented.

Second: **the headless render harness is the validation technique to reuse.**
With no browser tooling and no live Notion, the way to actually prove the
change works end-to-end was to pipe a Notion-block fixture through the real
Python `to_markdown`, then the resulting Markdown through the real React plugin
chain via `renderToStaticMarkup` under `bun`. Two gotchas: the script must live
_inside_ `apps/dashboard-frontend/` (bun resolves `react/jsx-dev-runtime` from
the script's own directory, so `$TMPDIR` fails), and assert on counts in the
emitted HTML (`class="katex"`, `katex-display`, `katex-error`, `<strong>`,
literal `$` survival) rather than eyeballing it.

Suggested edits to `wiki/workstreams/infrastructure/sops/dashboard-app-dev-and-deploy.md`:

1. Narrow the mock-mode claim — name the blog as the endpoint mock mode does
   _not_ cover, and say that blog work therefore needs either a Notion token or
   a fixture-driven headless render.
2. Add a short "validating rendering changes headlessly" note with the
   `renderToStaticMarkup` recipe and the bun-resolves-from-script-dir gotcha.
3. Add the frontend's real typecheck/lint invocations (`bun run
typecheck:check` / `lint:check` from the app dir; no nx targets) plus the
   pre-existing-failure baselines, so the next agent does not mistake them for
   their own breakage.

Source: https://docs.virdx.dev/knowledge/inbox/2026-07-31-infrastructure-blog-formatting-latex/index.mdx
