import type { ReactNode } from "react"; import type { SlotProps } from "../slotProps.ts"; export type MarkdownViewProps = SlotProps & { className?: string; /** * The accessible name of the region, as `aria-label`. * * Worth passing on anything that is not the only thing on the * page. A read view is announced as an article, and "article" with * no name tells a reader stepping through the page nothing about * which article it is. */ label?: string; /** * Called when the reader ticks a `- [ ]` box, with the **whole** * next markdown document — the same shape the editors' `onChange` * hands back, so the same autosave wires to both. * * **Passing it is what makes the checkboxes operable at all.** * Leave it out and they render disabled: the state is still on * screen, and nothing pretends the reader can change it. That is * deliberate rather than defensive. Two of the three surfaces this * component was built for — a rendered comment, and a repo file * fetched at a commit hash — have nowhere to put a tick, and a * checkbox that visibly toggles and then loses the change on * reload is worse than one that says up front it cannot. * * The view still applies the tick to its own document * immediately, so the box responds under the pointer. If the * consumer rejects the change, the next `value` it passes puts it * back. */ onToggleTask?: (nextValue: string) => void; /** * Re-point a link before it becomes an `href`, for a document * whose paths mean something only the app knows. * * The case it exists for is a **relative** path in a fetched or * imported file. `[the runbook](../docs/runbook.md)` is resolved * by the browser against the page the reader is on — the app — * so it lands on nothing. Return the URL it should point at * instead, or `undefined` to leave the link exactly as written. * * The document's own URL has already been through the scheme * guard when it arrives, so `javascript:` never reaches this. * What comes back is the app's own string and is used as given. * * **Links only.** An image's `src` is deliberately not offered: * an app that maps a document path to a page URL would turn a * working image into a broken one. * * Read once, at mount, through a ref — so a resolver defined * inline in a render does not rebuild the view and lose the * reader's scroll position. */ resolveUrl?: (url: string) => string | undefined; /** * The markdown to render, and **controlled** — unlike either * editor. * * The editors take `defaultValue` because reassigning their * document from outside would throw away the undo history that * belonged to it, which is a real cost and is why they ask for a * `key` instead. A view has no undo history and no caret to * protect, so it has nothing to lose by simply rendering what it * is given: fetch a file at a different commit hash, pass the new * string, and the page changes. No remount, no `key`, no stale * document above a new title. */ value: string; }; /** * Rendered markdown, with no toolbar and nothing to type into. * * ## Why this is a component and not `isReadOnly` on the editors * * Both editors already have `isReadOnly`, and it means *an editor * you cannot type in*: the nine formatting buttons are still in the * DOM, still queryable, still in the accessibility tree, drawn in * their disabled colours above a document nobody is editing. That is * the right shape for a form field that is temporarily locked. It is * the wrong shape for reading a file, and a `hasToolbar={false}` * prop would not fix it — the type would still carry * `onUploadImage`, `icons`, `placeholder` and `rawModeLabel`, none * of which mean anything to a reader, and "no toolbar" would be a * branch that could be got wrong rather than a thing that does not * exist. * * So this is its own component, the way `Listbox`, `Combobox` and * `Picker` are their own components rather than three modes of one. * The props are the four a reader needs, and there is no code path * in this file that could render a toolbar. * * ## Why it is in the CodeMirror subpath * * Because the requirement is that it renders **the same** as the * editor a consumer edits the same document in — and the only way * to guarantee that is to be the same code, not to be a second * implementation that agrees today. * * A `react-markdown` reader in the main barrel was the obvious * alternative and is the one that goes wrong slowly: two parsers, * two table implementations, two answers to what a bare URL does, * and a bug report six months later that says "the table changed * when I saved it". This shares `livePreviewRanges.ts` — the 1,200 * lines that decide what a document *looks* like — with * `MarkdownEditorCodeMirror`, and the theme with it too. There is no * second renderer to keep in step. * * The cost is that it lives behind the same optional peers, so a * consumer who wants only the reader still installs CodeMirror. That * is the honest trade and it is the right way round: an app that * renders markdown in three places and edits it in one should pay * once. * * ## What differs from the editor, and why each one differs * * | | editor | this | * | --- | --- | --- | * | Toolbar | nine buttons | **none in the DOM** | * | Value | `defaultValue`, uncontrolled | `value`, **controlled** | * | Content role | `textbox` | **`article`** | * | Headings | scaled text | scaled **and `role="heading"`** | * | Links | painted span, opened by a handler | **real ``** | * | Markers | revealed by the caret | never revealed | * | Task boxes | always operable | **inert unless `onToggleTask`** | * * Every one of those follows from there being no caret. The editor * paints a span instead of an anchor because an `` inside a * `contenteditable` competes with the caret for the line; a document * has no caret, so the link is simply a link — tabbable, activated * by Enter, and copyable from the context menu. The editor reveals * markup when the selection touches it because you cannot edit a URL * you cannot see; there is nothing here to edit. * * ## Hostile markdown * * This renders files fetched from a git host, so the document is * untrusted by default, and the answer is mostly structural rather * than a sanitiser: * * - **Raw HTML in the source is never parsed as HTML.** There is no * `innerHTML` in this subpath, no `dangerouslySetInnerHTML`, and * no sanitiser to have a bug in. CodeMirror's state holds the * source as *text*, and every rendered node is `createElement` * plus `textContent`. A `