# Honest Prose Syntax

> Restrained syntax colors for Markdown, HTML, React, and server-rendered code.

Source: https://ai.honestui.com/docs/prose-syntax

Honest Prose Syntax uses Shiki to recognize code and Honest token roles to style it. Keywords, strings, and functions carry restrained color; comments stay readable. Switching themes changes CSS without tokenizing again.

```tsx
"use client"

import * as React from "react"
import { Check as CheckIcon, Copy as CopyIcon } from "honestui/icons"
import { Button } from "@/components/ui/button"
import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/ui/tabs"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
import { SyntaxCode } from "@/lib/prose-syntax-react"
import { syntaxTailwind } from "@/lib/prose-syntax-tailwind"

const samples = {
  typescript: { label: "TypeScript", code: '// Keep the original order.\ntype User = { name: string; active: boolean }\n\nexport function activeNames(users: User[]) {\n  return users.filter(user => user.active)\n    .map(user => user.name)\n}\n' },
  python: { label: "Python", code: '# Keep the original order.\ndef active_names(users):\n    return [user["name"] for user in users\n            if user["active"]]\n' },
  html: { label: "HTML", code: '<!-- A named, native control. -->\n<label for="name">Name</label>\n<input id="name" name="name" required>\n' },
  sql: { label: "SQL", code: '-- Keep a predictable order.\nSELECT name FROM users\nWHERE active = TRUE\nORDER BY created_at;\n' },
  json: { label: "JSON", code: '{\n  "name": "Ada",\n  "active": true,\n  "roles": ["editor"]\n}\n' },
}
type Language = keyof typeof samples

const palettes = {
  violet: { label: "Violet", description: "Soft violet, blue, and green accents." },
  graphite: { label: "Graphite", description: "Mostly neutral; keywords in muted slate." },
}
type Palette = keyof typeof palettes

const COPIED_MILLISECONDS = 1500

export function ProseSyntaxDemo({ tailwind = false }: { tailwind?: boolean }) {
  const [palette, setPalette] = React.useState<Palette>("violet")
  const id = React.useId()
  const [language, setLanguage] = React.useState<Language>("typescript")
  const [copyState, setCopyState] = React.useState<"idle" | "copied" | "failed">("idle")
  const resetTimer = React.useRef<number | undefined>(undefined)
  React.useEffect(() => () => window.clearTimeout(resetTimer.current), [])

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(samples[language].code)
      setCopyState("copied")
    } catch {
      setCopyState("failed")
    }
    window.clearTimeout(resetTimer.current)
    resetTimer.current = window.setTimeout(() => setCopyState("idle"), COPIED_MILLISECONDS)
  }

  return <section className="not-prose w-full min-w-0" aria-label="Syntax playground">
    <div className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-2">
      <label htmlFor={`${id}-palette`} className="text-sm font-medium">Palette</label>
      <Select items={Object.fromEntries(Object.entries(palettes).map(([key, value]) => [key, value.label]))} value={palette} onValueChange={value => { if (value && Object.hasOwn(palettes, value)) setPalette(value as Palette) }}>
        <SelectTrigger id={`${id}-palette`} aria-label="Palette" aria-describedby={`${id}-palette-description`} size="sm" className="w-32"><SelectValue /></SelectTrigger>
        <SelectContent>{Object.entries(palettes).map(([value, { label }]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent>
      </Select>
      <p id={`${id}-palette-description`} className="m-0 text-sm text-[var(--hui-color-foreground-base-secondary)]">{palettes[palette].description}</p>
    </div>
    <Tabs value={language} onValueChange={value => { setLanguage(value as Language); setCopyState("idle") }}>
      {/* The same frame as the docs' code blocks: a shell with the language and Copy, around the code. */}
      <div className="flex flex-col rounded-[8px] bg-[var(--hui-color-background-neutral-primary)] p-1">
        <div className="flex items-center justify-between gap-2 pr-1 pl-2">
          <TabsList aria-label="Language" variant="underline" className="no-scrollbar min-w-0 justify-start! gap-3! overflow-x-auto">
            {Object.entries(samples).map(([value, { label }]) => <TabsTab key={value} value={value} className="h-5! px-1.5 hover:bg-transparent!">{label}</TabsTab>)}
          </TabsList>
          <Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 rounded" aria-label="Copy code" onClick={() => void copy()}>
            {copyState === "copied" ? <CheckIcon aria-hidden="true" /> : <CopyIcon aria-hidden="true" />}
          </Button>
          <span role="status" className="sr-only">{copyState === "copied" ? "Copied" : copyState === "failed" ? "Copy failed" : ""}</span>
        </div>
        {Object.entries(samples).map(([value, { label, code }]) => <TabsPanel key={value} value={value} className="mt-1">
          {/* The shell above is the frame, so the code keeps only its hairline. */}
          <SyntaxCode data-syntax-palette={palette} aria-label={`${label} example`} code={code} language={value} className={`m-0 min-h-48 [&&]:border-0! [&&]:rounded-[5px]! ${tailwind ? syntaxTailwind : ""}`} />
        </TabsPanel>)}
      </div>
    </Tabs>
  </section>
}

```

## Honest Markdown [#honest-markdown]

Highlighting is automatic for fenced code. Import `styles/prose-syntax-theme.css` alongside your existing Prose stylesheet. Set `syntaxHighlighting={false}` to disable it. A custom `renderCodeLine` takes precedence.

## Registry installation [#registry-installation]

<CliBlock commands="[&#x22;@honestai/prose-syntax-react&#x22;]" />

```tsx
import { SyntaxCode } from "@/lib/prose-syntax-react"
import "@/styles/prose-syntax-theme.css"

<SyntaxCode code={'const name = "Ada"'} language="js" />
```

For server-only use, install `@honestai/prose-syntax` and `@honestai/prose-syntax-theme`. For DOM enhancement, install `@honestai/prose-syntax-browser` and the theme. The browser and React integrations include a worker file; keep it beside the installed library files so your bundler can resolve it.

## npm package [#npm-package]

The package is prepared locally as `@honestui/prose-syntax`; it has not been published. Build it with `npm run syntax:build`, then run `npm pack ./packages/prose-syntax`. Install the resulting tarball in a consuming project. After publication, the same exports will be available under the package name.

```tsx
import { SyntaxCode } from "@honestui/prose-syntax/react"
import "@honestui/prose-syntax/style.css"

<SyntaxCode code={source} language="tsx" />
```

The core and browser entries do not import React. React is an optional peer dependency for the React entry.

## Server rendering and Markdown pipelines [#server-rendering-and-markdown-pipelines]

```ts
import { createHighlighter } from "@honestui/prose-syntax"

const highlighter = createHighlighter()
const html = await highlighter.toHtml(source, { language: "typescript" })
// Insert this escaped, generated HTML in the server-rendered document.
// Dispose when the owning build or server lifecycle ends.
highlighter.dispose()
```

In a Next.js server component, render the generated HTML with `dangerouslySetInnerHTML` and import the stylesheet in the layout. Only pass HTML produced by `toHtml`; never interpolate source into markup yourself. The resulting code needs no client highlighting JavaScript.

For a Markdown build pipeline, await `toHtml` in the renderer's fenced-code handler. For AST-based pipelines, use `highlight` and create text/span nodes from its token lines instead of reparsing HTML. Preserve the source fence's language label; missing and unknown labels remain plain text.

## Plain HTML [#plain-html]

Copy the package's entire `dist` folder to your site's assets. Keep its worker and grammar chunks together. Serve it over HTTP with JavaScript MIME types.

```html
<link rel="stylesheet" href="/assets/prose-syntax/style.css">
<pre data-honest-syntax><code class="language-js">const name = "Ada"</code></pre>
<script type="module">
  import { highlightAll } from '/assets/prose-syntax/prose-syntax-browser.js'
  await highlightAll(document)
</script>
```

Encode `<` and `&` when writing source directly into HTML. The DOM integration reads `textContent`, never executes source, and can be called again after content changes. It skips applying colors during an active selection; call it again after selection ends if needed. It does not observe or rewrite the entire document automatically.

## Languages and extensions [#languages-and-extensions]

The default loaders include JavaScript, TypeScript, JSX, TSX, HTML, CSS, JSON, Markdown, Bash, Python, SQL, and YAML. Aliases include `js`, `ts`, `sh`, `shell`, `py`, `yml`, and `md`. There is no automatic language detection.

Add trusted grammars to a core instance:

```ts
const highlighter = createHighlighter({
  languages: { ruby: () => import('shiki/langs/ruby.mjs') },
})
```

Custom browser grammar sets use a custom worker supplied through `createSyntaxClient({ workerFactory })` or `createBrowserHighlighter({ workerFactory })`. Never construct grammar imports from code-block text or user-provided URLs.

## Themes and Tailwind [#themes-and-tailwind]

The default theme follows `.light`, `.dark`, or the system color scheme. Set `data-syntax-theme="light"` or `"dark"` on a code surface to override it. Use `.honest-syntax` on custom token renderers.

Choose a token palette with `data-syntax-palette="violet"` or `"graphite"` on the code surface. Violet is the default. Graphite stays close to monochrome, with keywords in muted slate. For brand colors, override the `--prose-syntax-*` variables below instead. Each adapts to light and dark mode without changing the frame or retokenizing code.

```tsx
<SyntaxCode code={source} language="tsx" data-syntax-palette="graphite" />
```

In Honest Markdown, use `<Markdown syntaxPalette="graphite">{source}</Markdown>`.

For plain HTML or server-generated markup, set the same attribute on `<pre class="honest-syntax">`. The palettes also work with the generated `syntaxTailwind` utilities. Explicit `--prose-syntax-*` overrides still take precedence.

Override `--prose-syntax-plain`, `comment`, `keyword`, `string`, `constant`, `function`, `type`, `property`, `tag`, and `punctuation` with the same prefix, plus `variable`, `parameter`, `operator`, and `invalid` in editors. Surface variables are `--prose-syntax-background`, `--prose-syntax-highlight`, `--prose-syntax-frame` (the outer shell), `--prose-syntax-rule` (the hairline around the panel), `--prose-syntax-padding`, `--prose-syntax-size`, and `--prose-syntax-leading`. The code font follows `--prose-font-mono`.

```css
article {
  --prose-syntax-keyword: #6941a5;
  --prose-syntax-string: #166553;
}
```

These example overrides are for light surfaces. Check every foreground against the actual background in each theme after customizing.

For Tailwind v4 without a stylesheet, install `@honestai/prose-syntax-tailwind`, keep it in Tailwind's source scan, and apply `syntaxTailwind` to each code surface. It is generated from the same CSS, including theme and forced-color rules. This is separate from `proseTailwind` because Code View opts out of ordinary prose styles. The npm equivalent is `import { syntaxTailwind } from "@honestui/prose-syntax/tailwind"`; add the installed package's `dist/prose-syntax-tailwind.js` to Tailwind's `@source` paths because dependencies are normally excluded from scanning.

## Editors [#editors]

Prose Syntax highlights code people read: replies, docs, previews, and diffs. It retokenizes from the top when text changes anywhere but the end, and it has no cursor, folding, or diagnostics. For code people edit, use CodeMirror 6 or Monaco and keep the same colors.

```tsx
"use client"

import * as React from "react"
import { EditorView, basicSetup } from "codemirror"
import { Compartment, type Extension } from "@codemirror/state"
import { ViewPlugin, type ViewUpdate } from "@codemirror/view"
import { syntaxTree } from "@codemirror/language"
import { linter, type Diagnostic } from "@codemirror/lint"
import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs"
import { honestCodeMirror, setSemanticTokens, type SemanticToken } from "@/lib/prose-syntax-codemirror"

const languages = {
  typescript: {
    label: "TypeScript",
    file: "active-names.ts",
    load: () => import("@codemirror/lang-javascript").then(module => module.javascript({ typescript: true })),
    code: `// Parameters are italic. Type == to see a lint warning.
type User = { name: string; active: boolean; visits: number }

export function activeNames(users: User[], minimum = 1) {
  return users
    .filter(user => user.active && user.visits >= minimum)
    .map(user => \`\${user.name} (\${user.visits})\`)
}
`,
  },
  python: {
    label: "Python",
    file: "active_names.py",
    load: () => import("@codemirror/lang-python").then(module => module.python()),
    code: `# Keep the original order.
def active_names(users, minimum=1):
    return [user["name"] for user in users
            if user["active"] and user["visits"] >= minimum]
`,
  },
  html: {
    label: "HTML",
    file: "signup.html",
    load: () => import("@codemirror/lang-html").then(module => module.html()),
    code: `<!-- A named, native control. -->
<form action="/signup" method="post">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required>
  <button>Sign up</button>
</form>
`,
  },
  css: {
    label: "CSS",
    file: "card.css",
    load: () => import("@codemirror/lang-css").then(module => module.css()),
    code: `/* Follow the theme's surface. */
.card {
  padding: 1rem;
  border: 0.5px solid var(--hui-color-border-base-primary);
  border-radius: 8px;
}
`,
  },
  json: {
    label: "JSON",
    file: "user.json",
    load: () => import("@codemirror/lang-json").then(module => module.json()),
    code: `{
  "name": "Ada",
  "active": true,
  "visits": 12,
  "roles": ["editor"]
}
`,
  },
  sql: {
    label: "SQL",
    file: "active-users.sql",
    load: () => import("@codemirror/lang-sql").then(module => module.sql()),
    code: `-- Keep a predictable order.
SELECT name, visits FROM users
WHERE active = TRUE AND visits >= 1
ORDER BY created_at;
`,
  },
  markdown: {
    label: "Markdown",
    file: "README.md",
    load: () => import("@codemirror/lang-markdown").then(module => module.markdown()),
    code: `# Active users

Lists **active** users in their *original* order. See [the guide](https://example.com).
`,
  },
}
type Language = keyof typeof languages

// Stands in for a language server: marks each function's parameters and their
// uses by name. It ignores shadowing, which a real language service resolves.
function parameterTokens(view: EditorView): SemanticToken[] {
  const tokens: SemanticToken[] = []
  const { state } = view
  syntaxTree(state).iterate({
    enter: node => {
      if (node.name !== "ParamList") return
      const names = new Set<string>()
      let child = node.node.firstChild
      while (child) {
        if (child.name === "VariableDefinition") {
          names.add(state.sliceDoc(child.from, child.to))
          tokens.push({ from: child.from, to: child.to, type: "parameter" })
        }
        child = child.nextSibling
      }
      const body = node.node.parent
      if (!body || names.size === 0) return
      syntaxTree(state).iterate({
        from: node.to,
        to: body.to,
        enter: use => {
          if (use.name === "VariableName" && names.has(state.sliceDoc(use.from, use.to))) tokens.push({ from: use.from, to: use.to, type: "parameter" })
        },
      })
    },
  })
  return tokens
}

const parameterAnalysis = ViewPlugin.fromClass(class {
  constructor(view: EditorView) {
    this.schedule(view)
  }
  update(update: ViewUpdate) {
    if (syntaxTree(update.startState) !== syntaxTree(update.state)) this.schedule(update.view)
  }
  // A view cannot dispatch while it updates, so the result is sent just after.
  schedule(view: EditorView) {
    queueMicrotask(() => view.dispatch({ effects: setSemanticTokens.of(parameterTokens(view)) }))
  }
})

// A small, real rule, to show the theme's lint underline.
const strictEquality = linter(view => {
  const diagnostics: Diagnostic[] = []
  syntaxTree(view.state).iterate({
    enter: node => {
      const text = view.state.sliceDoc(node.from, node.to)
      if (node.name === "CompareOp" && (text === "==" || text === "!=")) {
        diagnostics.push({ from: node.from, to: node.to, severity: "warning", message: `Use ${text}= to compare without type conversion.` })
      }
    },
  })
  return diagnostics
}, { delay: 300 })

export function ProseSyntaxEditorDemo() {
  const host = React.useRef<HTMLDivElement>(null)
  const view = React.useRef<EditorView | null>(null)
  const languageSlot = React.useRef(new Compartment())
  const analysisSlot = React.useRef(new Compartment())
  // Edits survive switching tabs.
  const drafts = React.useRef<Record<string, string>>({})
  const [language, setLanguage] = React.useState<Language>("typescript")
  const shown = React.useRef<Language>("typescript")

  React.useEffect(() => {
    if (!host.current) return
    const editor = new EditorView({
      doc: languages.typescript.code,
      parent: host.current,
      extensions: [basicSetup, languageSlot.current.of([]), analysisSlot.current.of([]), honestCodeMirror(), strictEquality, EditorView.contentAttributes.of({ "aria-label": "Code editor" })],
    })
    view.current = editor
    return () => {
      view.current = null
      editor.destroy()
    }
  }, [])

  React.useEffect(() => {
    let cancelled = false
    void languages[language].load().then((extension: Extension) => {
      const editor = view.current
      if (cancelled || !editor) return
      drafts.current[shown.current] = editor.state.doc.toString()
      shown.current = language
      editor.dispatch({
        changes: { from: 0, to: editor.state.doc.length, insert: drafts.current[language] ?? languages[language].code },
        effects: [
          languageSlot.current.reconfigure(extension),
          analysisSlot.current.reconfigure(language === "typescript" ? parameterAnalysis : []),
          setSemanticTokens.of([]),
        ],
      })
      editor.contentDOM.setAttribute("aria-label", `${languages[language].file}, ${languages[language].label} editor`)
    })
    return () => {
      cancelled = true
    }
  }, [language])

  return <section className="not-prose w-full min-w-0" aria-label="Editor example">
    <Tabs value={language} onValueChange={value => setLanguage(value as Language)}>
      {/* The same frame as the docs' code blocks, with the language tabs in the shell. The file name is in the editor's label. */}
      <div className="flex flex-col rounded-[8px] bg-[var(--hui-color-background-neutral-primary)] p-1">
        <div className="px-2">
          <TabsList aria-label="Language" variant="underline" className="no-scrollbar min-w-0 justify-start! gap-3! overflow-x-auto">
            {Object.entries(languages).map(([value, { label }]) => <TabsTab key={value} value={value} className="h-5! px-1.5 hover:bg-transparent!">{label}</TabsTab>)}
          </TabsList>
        </div>
        <div ref={host} className="mt-1 min-h-56 overflow-hidden rounded-[5px] shadow-[inset_0_0_0_0.5px_var(--hui-color-border-base-primary)] [&_.cm-editor]:min-h-56" />
      </div>
    </Tabs>
  </section>
}

```

In the demo, parameters come from a small scope pass that stands in for a language server, and the only lint rule flags `==`. Each language loads its CodeMirror package when its tab opens.

### CodeMirror [#codemirror]

<CliBlock commands="[&#x22;@honestai/prose-syntax-codemirror&#x22;]" />

```ts
import { EditorView, basicSetup } from "codemirror"
import { javascript } from "@codemirror/lang-javascript"
import { honestCodeMirror } from "@/lib/prose-syntax-codemirror"
import "@/styles/prose-syntax-theme.css"

new EditorView({
  parent: element,
  extensions: [basicSetup, javascript({ typescript: true }), honestCodeMirror({ palette: "graphite" })],
})
```

The extension maps CodeMirror's highlight tags onto the `prose-syntax-*` classes, so palettes, light and dark themes, and `--prose-syntax-*` overrides work as they do in Prose. It styles the gutter, cursor, selection, active line, matching brackets, search matches, panels, and lint underlines from the same variables. Pass `theme: "light"` or `"dark"` to pin a theme. Load another language by adding its `@codemirror/lang-*` package.

Code the grammar cannot parse is marked invalid: red, with a wavy underline. Pass `parseErrors: false` to leave that to a linter. Diagnostics from `@codemirror/lint` use the invalid color for errors, the constant color for warnings, and the function color for information.

### Parameters and language servers [#parameters-and-language-servers]

Grammars cannot tell a parameter from another variable, so parameters, and any type a language server knows better, come from semantic tokens. Decode a `textDocument/semanticTokens` result and send it to the editor:

```ts
import { decodeSemanticTokens, setSemanticTokens } from "@/lib/prose-syntax-codemirror"

const tokens = decodeSemanticTokens(result.data, serverCapabilities.semanticTokensProvider.legend, view.state.doc)
view.dispatch({ effects: setSemanticTokens.of(tokens) })
```

Tokens move with edits until the next result arrives. Parameters are italic as well as colored. The `deprecated` modifier strikes text through. Types without a role, such as `label`, keep the grammar's color.

### Monaco and VS Code [#monaco-and-vs-code]

The same palettes ship as literal colors for editors that cannot read CSS variables.

```ts
import * as monaco from "monaco-editor"
import { monacoTheme } from "@honestui/prose-syntax/themes"

monaco.editor.defineTheme("honest-dark", monacoTheme("violet", "dark"))
monaco.editor.create(element, { language: "typescript", theme: "honest-dark", "semanticHighlighting.enabled": true })
```

For a VS Code extension, point `contributes.themes` at a file in the package's `dist/themes` folder, such as `honest-violet-dark.json`, or generate one with `vscodeTheme(palette, mode)`. Both themes color parameters when the language service sends semantic tokens. With the registry, install `@honestai/prose-syntax-themes`.

Editors add four roles: `--prose-syntax-variable` (matches plain text by default), `--prose-syntax-parameter`, `--prose-syntax-operator` (matches keywords), and `--prose-syntax-invalid`. With npm, CodeMirror is an optional peer dependency of `@honestui/prose-syntax/codemirror`.

## Streaming and failure behavior [#streaming-and-failure-behavior]

Browser integrations load grammars in a worker, keep completed lines colored, and process only the changed suffix when possible. Pending updates coalesce per session; obsolete results never replace newer text. Source remains visible during loading or failure.

The default input limit is 200,000 UTF-8 bytes. Worker jobs time out after one second, including cold startup, and the worker is terminated. `createSyntaxClient` and `createBrowserHighlighter` accept `maxBytes`, `timeoutMs`, `workerFactory`, and `onDiagnostic`. Core instances accept `maxBytes`, `languages`, and `onDiagnostic`. Diagnostics contain a reason and language, never the code.

Use `dispose()` when an instance is no longer needed. Core streaming callers can pass a stable `session` to `highlight` and call `release(session)` when the block is removed. At most 32 completed-prefix sessions are retained per core instance.

## Visual review [#visual-review]

The [syntax comparison](/syntax-review) renders the same JavaScript sample with Honest, Prism, highlight.js, and Shiki in light and dark themes. It is a review aid, not a claim that one library is universally more readable.

The default palette pairs are tested for 4.5:1 text contrast. Keyboard, browser, and assistive-technology checks are separate from palette tests; custom colors and arbitrary host surfaces are not covered by those results.
