<!-- GENERATED by scripts/build-llms.mjs from llms/conversation.md — do not edit this file. -->

# `lr-markdown`

- **Import** `import '@aceshooting/lyra-ui/components/lr-markdown.js';` (stable tag alias; registers the tag)
- **Class** `LyraMarkdown`, also available unregistered from `@aceshooting/lyra-ui/components/conversation/markdown/markdown.class.js`
- **Family** `components/conversation/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** `dompurify`, `katex`, `marked`, `shiki` — see `llms/peers.md`
- **Themeable via** 12 parts, 16 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-markdown`

Sanitized Markdown-to-HTML rendering (GFM tables, fenced code blocks, links, blockquotes) built on
two optional peer dependencies — `marked` (parsing) and `dompurify` (sanitizing) — both lazy-loaded
independently via `markdown-loader.ts`'s `loadMarkdownDeps()` on first connect, cached per page the
same way `chart-core-loader.ts`/`map-loader.ts` cache their load promise so every `<lr-markdown>`
instance on a page shares one load. `heading`/`code`/`blockquote`/`table`/`link`/`image` tokens are
rendered through a `marked` renderer override that injects `part="..."` attributes directly into the
produced HTML in a single pass (no second DOM walk after insertion).

Removing `content` clears the document and its empty-document tab stop, including while streaming.
The property keeps Lit's `null` readback after removal; an explicitly empty attribute remains an
empty string. Later source text renders normally.

If an instance disconnects and reconnects before that shared promise settles, only the current
connection applies the result and reparses; the stale connection callback is generation-guarded.

To warm that shared cache before the first Markdown instance connects, await the stable public
entry point. This keeps the default lazy behavior for apps that do not need it while letting a
route or startup boundary render from an already-settled cache. The helper is exported by both
the full and core granular entries:

```ts
import { preloadMarkdown } from "@aceshooting/lyra-ui/components/conversation/markdown/markdown.js";

await preloadMarkdown();
```

Fenced code blocks are also syntax-highlighted via the same optional `shiki` peer `<lr-code-block>`
uses, gated by `highlightCode` (default `true`). This is a pure upgrade, not a separate opt-in: it's
already transparently gated by whether `shiki` is installed at all, so an app that never installs the
peer sees byte-identical output to before this property existed. The very first render of any content
is always plain text/code (identical to today's output); highlighting arrives as an asynchronous
upgrade one render later once shiki resolves and the block's language is tokenized. No highlighting
is attempted while `streaming` is `true` — it applies once a stream settles, adding no per-chunk cost
while content is still arriving.

Highlighted blocks follow the page's resolved theme. Shiki emits both palettes at once, so
`[part="content"]` carries `data-dark-theme="true"` whenever the component's own resolved
`--lr-color-text` is lighter than its `--lr-color-surface`, and the stylesheet then paints each
token from `--shiki-dark`/`--shiki-dark-bg` rather than the light inline color. It keys off the
resolved tokens rather than `prefers-color-scheme`, so an app theming with `--lr-theme-color-*`
independently of the OS setting gets the dark palette too — the same mechanism `<lr-code-block>`
uses for its own `[part="body"]`.

**Properties:**

- `content: string = ''` — the Markdown source to render
- `tabSize: number = 4` (attribute `tab-size`) — tab-stop width used to expand tabs in leading
  indentation before parsing. Values are finite-integer guarded and clamped to `[1, 32]` at use;
  invalid values fall back to `4`. This is separate from `--lr-code-block-tab-size`, which controls
  how tabs already inside rendered code are displayed.
- `marked: LyraMarkedParser | undefined` (readonly, no attribute) — this instance's peer-neutral,
  configurable `marked.Marked` parser. It is `undefined` while the optional peer is still resolving
  or unavailable. Each element owns an isolated parser, so `marked.use(...extensions)` affects only
  that element; call `renderMarkdown()` after configuring it. The peer-neutral type deliberately
  models Lyra's stable `defaults`/`use()`/`parse()` surface; consumers using version-specific Marked
  tokenizers, constructors, or helpers should type that local reference with their installed
  `marked` version.
- `htmlMode: 'sanitize' | 'escape' | 'trusted' = 'sanitize'` (attribute `html-mode`) — controls raw
  authored HTML. `sanitize` passes the complete rendered document through DOMPurify and fails closed
  to plain text if the peer is unavailable; `escape` displays raw HTML source as text while ordinary
  Markdown still renders; `trusted` renders raw HTML without sanitization and is only for trusted
  content.
- `gfm: boolean = true` — GitHub-flavored Markdown (tables, strikethrough, autolinks, task lists).
  GFM task-list checkboxes stay disabled. A task's primary inline text supplies its accessible name;
  nested task text is excluded, and a blank task receives no generated name.
- `linkTarget: string | null = '_blank'` (attribute `link-target`) — `target` applied to every
  rendered `<a>`, with `rel="noopener noreferrer"` always added alongside it whenever a `target` is
  emitted. `'_blank'` (the default) preserves the original output; a falsy value (`null`, or the
  empty string via `link-target=""`) omits `target`/`rel` entirely instead of always defaulting to
  `_blank`, so rendered links open in the same tab
- `internalLinkPrefix: string = ''` (attribute `internal-link-prefix`) — when set, a rendered link
  whose `href` _attribute_ (not the browser-resolved `.href` property) starts with this prefix is
  intercepted on click and reported via `lr-link-click` instead of navigating; empty (the default)
  means every link is treated as external
- `headingOffset: number = 0` (attribute `heading-offset`) — added to every rendered heading's
  source `token.depth` before emitting `<h${depth}>` (e.g. `heading-offset="2"` renders a source `#`
  as `<h3>`); clamped to `[1, 6]` so a source `######` with a positive offset stays at `<h6>` rather
  than overflowing past the HTML heading levels. `0` (the default) preserves the original
  `<h${token.depth}>` output
- `streaming: boolean = false` (reflected) — marks the host `aria-busy="true"` while partial Markdown
  is still arriving and lets consumers target `lr-markdown[streaming]`. Both `lr-markdown` and
  `lr-markdown-core` display accumulated plain text without parsing or highlighting while it is
  true. Setting `streaming=false` parses and renders the latest complete content; busy state also
  remains true while parser dependencies are loading
- `highlightCode: boolean = true` (attribute `highlight-code`) — syntax-highlights fenced code
  blocks via the optional `shiki` peer. `true` (the default) upgrades every fenced block once the
  peer is available; set `false` to keep plain output even when `shiki` is installed. No effect
  while `streaming` is `true`
- `languages?: Record<string, ShikiLanguageInput>` (attribute: false) — same shape and purpose as
  `<lr-code-block>`'s own `languages`: a fine-grained, explicit language-grammar bundle scoping
  shiki's build output to just those grammars instead of its full ~200-language bundle. Forwarded
  verbatim to `loadShikiHighlighterCore()`. Unset (the default) uses the default full-bundle loader
- `headingAnchors: boolean = false` (attribute `heading-anchors`) — stamps a computed
  GitHub-slugger-style slug as `id` on every rendered heading.
- `math: boolean = false` — renders `$inline$` and `$$block$$` TeX via the optional `katex` peer,
  lazy-loaded the same way as `marked`/`dompurify`/`shiki`.
- `maxHeight: string = ''` (attribute `max-height`) — a CSS length (e.g. `"20rem"`); once set,
  `[part="content"]` scrolls internally past this height instead of growing the page. Invalid
  values are ignored.
- `highlights: readonly LyraHighlight[] = []` (attribute: false) — host-supplied `text-quote` highlights;
  reassign the array after mutation so painting is refreshed.
- `activeHighlightId: string | null = null` (attribute `active-highlight-id`) — identifies the
  currently active entry in `highlights` for active paint and outline treatment.
- `anchor: LyraAnchor | string | null = null` (attribute: false) — declaratively applies an anchor
  or a highlight id through the same path as `scrollToAnchor()`; assigning the same value again
  deliberately re-runs resolution.
- `anchorKinds: readonly ('fragment' | 'text-quote')[] = ['fragment', 'text-quote']` — the anchor kinds this
  component resolves for the shared anchor-target contract.

Text-quote resolution indexes at most 1,000,000 code units/20,000 text nodes per content
generation, accepts quote fields up to 4,096 code units, and scans at most 4,000,000 code units per
pass. It reuses that index across navigation and painting. Host-highlight admission retains at most
10,000 unique nonempty records after inspecting at most 10,001 inputs; rendering selects at most
1,000 candidates and paints at most 100, with an active entry anywhere in the admitted snapshot
placed first and preserved inside both ceilings.

**Methods:**

- `renderMarkdown(): void` — immediately reruns the current content through the parse, selected
  HTML-mode, and fallback pipeline. Use it to refresh existing content after changing `marked` configuration;
  it safely no-ops while the optional parser is unresolved.
- `getHeadingTree(): MarkdownHeadingItem[]` — returns the document-ordered heading outline
  (`{ id, label, level }[]`)
  computed on every parse, regardless of `headingAnchors`.
- `LyraMarkdown.getMarked(): Marked` — returns the variant's shared compatibility parser (`Marked`
  is the route's exported alias of `LyraMarkedParser`),
  whose configuration seeds instance parses. Await `preloadMarkdown()` first; otherwise this throws.
- `LyraMarkdown.updateAll(): void` — re-renders every connected full Markdown instance after shared
  compatibility-parser configuration changes. Prefer the instance `marked` parser for isolated
  configuration.

**Events:**

- `lr-link-click` (`detail: { href: string }`) — fired, with navigation prevented, when a rendered
  link's `href` starts with `internal-link-prefix`; ordinary external links navigate normally and
  never fire this. If an intercepted link overlaps a painted highlight,
  `lr-highlight-activate` fires first for pointer and Enter activation.
- `lr-render-error` (`detail: { error: unknown }`) — rendering fell back to plain text (see the
  fallback matrix below), or `math` is set but the `katex` peer isn't installed
- `lr-highlight-activate` (`detail: { highlightId: string }`) — a painted `text-quote` highlight was clicked
- `lr-text-select` (`detail: { text: string; anchor: LyraAnchor | null; rects: DOMRect[] }`) — a text
  selection inside the rendered content ended; `anchor` is a `text-quote` anchor scoped to the
  rendered content, or `null` when the selection couldn't be anchored
- `lr-anchor-result` (`detail: { found: boolean }`) — fired after an `anchor` property assignment or
  a `scrollToAnchor()` call is applied (the shared anchor-target contract)
- `lr-content-settled` (`detail: null`, composed, bubbling) — fired whenever newly-rendered content
  actually reaches `[part="content"]`, including a transient plain-text fallback frame and a later
  async syntax-highlight upgrade, not only a final parsed render. Being composed, it crosses this
  element's own shadow boundary — a consumer composing `<lr-markdown>` inside a free-form container
  (e.g. `<lr-thinking-panel>`'s default slot) can listen for it to drive auto-scroll, since a
  light-DOM `MutationObserver` on that container can never see a property-driven update rendered
  entirely inside this element's own shadow root. See `<lr-thinking-panel>`'s own reference at `llms/components/lr-thinking-panel.md`.

**Slots:** none — content comes from the `content` property, not light-DOM children.

**CSS parts:** `content` (the wrapper around the rendered or plain-text-fallback output; respects
`max-height`; carries `data-fallback` while showing the plain-text fallback — still-loading peers
or a failed render — so a consumer can target `lr-markdown [part='content'][data-fallback]` to
style it distinctly), `anchor-live-region` (the aria-hidden, non-live shadow mirror of the latest
anchor-jump message), `heading` (every rendered `<h1>`–`<h6>`, shifted by `heading-offset`),
`paragraph` (every rendered `<p>`), `list` (every rendered `<ul>`/`<ol>`), `code-block` (every
rendered fenced/indented `<pre>`), `inline-code` (every rendered inline `<code>` span — backtick
spans, not fenced blocks), `link` (every rendered `<a>`), `table` (every rendered `<table>`),
`blockquote` (every rendered `<blockquote>`), `img` (every rendered `<img>`), `math` (a rendered
inline or block math span, carrying `data-display="inline"|"block"`)

**Themeable custom properties:** `--lr-markdown-max-height` (default `none` — cap on
`[part="content"]`'s block size, past which the document scrolls internally; the `maxHeight`
property sets this token inline on `[part="content"]`), `--lr-markdown-font-mono` (default `var(--lr-font-mono)` — the
code/code-block font, resolving through the library's shared monospace stack so a
`--lr-theme-font-family-mono` override reaches it), `--lr-markdown-code-bg` (default
`var(--lr-color-brand-quiet)` — background shared by every inline `code` span and the fenced
`code-block` surface, so a consumer can retheme either or both together without repainting every
other surface that reads the shared brand-quiet token), `--lr-markdown-code-padding` (default
`var(--lr-size-0-125rem) var(--lr-size-0-3125rem)` — inline `code` span padding),
`--lr-markdown-code-radius` (default `calc(var(--lr-radius) * 0.5)` — inline `code` span border
radius), `--lr-markdown-code-block-padding` (default `var(--lr-space-s) var(--lr-space-m)` — the
fenced `code-block` surface's padding), `--lr-markdown-code-block-radius` (default `var(--lr-radius)`
— the fenced `code-block` surface's border radius), `--lr-markdown-table-header-bg` (default
`var(--lr-color-brand-quiet)` — background of every rendered `[part="table"]` header cell),
`--lr-code-block-tab-size` (default `2` — tab
width inside a rendered fenced or indented `code-block`), plus shared tokens
`--lr-space-xs/-s/-m/-l`, `--lr-color-brand-quiet`, `--lr-color-brand`, `--lr-color-border`,
`--lr-color-text-quiet`, `--lr-radius`.

**Optional peer deps:** `marked`, `dompurify` (both lazy-loaded via `markdown-loader.ts`'s
`loadMarkdownDeps()`, mirroring `chart-core-loader.ts`'s two-independent-optional-peers shape). Each half
is loaded and caught independently — a consumer who installs only `marked` and explicitly sets
`html-mode="trusted"` (so `dompurify` is never needed) is a valid, supported combination. Also `shiki`,
the same optional peer `<lr-code-block>` uses, for `highlightCode`'s fenced-block syntax
highlighting — independent of the `marked`/`dompurify` pair, and its absence never blocks rendering
(fenced blocks simply stay unhighlighted). The readonly `marked` property becomes available only
after that lazy load resolves; each instance owns its configuration. Call `renderMarkdown()` after
`marked.use(...)` to refresh content that is already shown.

```html
<lr-markdown
  content="# Report&#10;&#10;See the [setup guide](/docs/setup) for details."
  internal-link-prefix="/docs/"
></lr-markdown>
<script>
  document
    .querySelector("lr-markdown")
    .addEventListener("lr-link-click", (e) => {
      router.navigate(e.detail.href);
    });
</script>
```

Rendering never ships unsanitized or broken markup silently. If `marked` fails to load, or throws
while parsing malformed input, the component falls back to plain text (`white-space: pre-wrap`, no
HTML parsing at all — the raw `content` string itself) and fires `lr-render-error`. In the default
`html-mode="sanitize"`, an unavailable or failed `dompurify` peer takes that same fail-closed path:
the component never renders `marked`'s raw HTML output when sanitization was requested. Use
`html-mode="escape"` when authored raw HTML should remain visible as text, or
`html-mode="trusted"` only for content whose complete HTML output is already trusted. While the
optional peers are still resolving, the host carries `aria-busy="true"` (set/
cleared in `updated()` based on whether the deps have loaded) and shows the same plain-text fallback
rendering — there's no separate loading skeleton, since the un-rendered Markdown source is already
legible text in the meantime.

**One tab width for every code surface.** `--lr-code-block-tab-size` is deliberately the same
property name and default (`2`) that `<lr-code-block>` and `<lr-code-editor>` use, so a consumer sets
tab width once for every code surface in the app. It is declared as a `var()` fallback **at the point
of use, never on `:host`** — a `:host` declaration is re-stamped on every instance and shadows any
inherited value, so a page- or container-level declaration could never reach it. This element carries
its own copy of that fallback rather than inheriting `<lr-code-block>`'s because the two are
**sibling** custom elements, not ancestor and descendant: no single declaration inside one of them
can cover the other. The same value can still _look_ different between the two — a markdown code
block inherits `white-space: pre-wrap` while `<lr-code-block>` is `white-space: pre`, and tab stops
restart at the beginning of each visual line, so a wrapped line's tabs land differently.

**Known gotchas:**

- a malformed percent-escape or lone UTF-16 surrogate in a link's raw `href` makes the internal
  `encodeURI`-based validity guard throw, silently dropping just that anchor (the link text still
  renders, with no `href`) — mirrors `marked`'s own default `link()` renderer's defensive behavior.
- every rendered link/image `href`/`src` is additionally scheme-checked against the same allowlist
  `<lr-button>`/`<lr-card>` use for a navigation `href` (`http:`, `https:`, `blob:`, `mailto:`, and
  relative URLs — plus `data:` for images only), independently of `htmlMode` — `sanitize` and
  `escape` both reject a disallowed scheme (e.g. `javascript:`) by dropping the anchor/image and
  rendering only its text/alt content, exactly like the malformed-`href` case above; only
  `html-mode="trusted"` skips this check, consistent with its documented full bypass.
- `target` is not in DOMPurify's default attribute allowlist (unlike `part`/`rel`/`class`, which
  already are), so sanitization is called with `ADD_ATTR: ['target']` — without that, every rendered
  link's `target` would be silently stripped by sanitization even though the anchor itself survives.
- a fresh internal `marked.Marked()` instance (with a fresh renderer) is built on every parse so
  the renderer's `link()` override always closes over the _current_ `linkTarget`. The public
  `marked` parser is still shared: its current configured defaults are copied into that fresh
  instance on each pass, avoiding a stale closure while preserving `marked.use(...)` hooks and
  extensions.
- `internal-link-prefix` matching compares against the raw `href` _attribute_, not the resolved
  `.href` IDL property (always an absolute URL in the browser) — a prefix like `/docs/` matches a
  relative markdown link but would never match against the resolved property.
- rendered output goes through `unsafeHTML`; with `html-mode="trusted"` the component renders
  whatever HTML `marked` produces from `content` completely unsanitized, so untrusted `content`
  must never use trusted mode.

**Additional API surface:**

- `--lr-markdown-table-header-bg` — Background of every rendered `[part="table"]` header cell. Default: `var(--lr-color-brand-quiet)`.
- `--lr-markdown-highlight-accent-bg` — Accent highlight fill. Default: `var(--lr-color-brand-quiet)`.
- `--lr-markdown-highlight-success-bg` — Success highlight fill. Default: `var(--lr-color-success-quiet)`.
- `--lr-markdown-highlight-warning-bg` — Warning highlight fill. Default: `var(--lr-color-warning-quiet)`.
- `--lr-markdown-highlight-danger-bg` — Danger highlight fill. Default: `var(--lr-color-danger-quiet)`.
- `--lr-markdown-highlight-neutral-bg` — Neutral highlight fill. Default: `var(--lr-color-surface)`.
- `--lr-markdown-highlight-active-bg` — Active highlight fill. Default: `var(--lr-color-brand-quiet)`.
- `--lr-markdown-highlight-active-outline-color` — Active highlight outline. Default: `var(--lr-color-brand)`.

---
