# Code Block

> Line-numbered code listing with copy, or a unified diff.

- **Slug:** `code-block`
- **Kind:** component · **Category:** Content
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/primitives/CodeBlock.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/code-block
- **All files in one bundle:** https://viewership.ai/ui/components/code-block.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/code-block.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy these files into `src/components/viewership/code-block/`:

   - `CodeBlock.astro` (raw: https://viewership.ai/ui/raw/code-block/CodeBlock.astro.txt)
   - `highlight.ts` (raw: https://viewership.ai/ui/raw/code-block/highlight.ts.txt)

npm packages: none.

## Usage

```astro
---
import CodeBlock from "../components/viewership/code-block/CodeBlock.astro";
---

<CodeBlock />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import CodeBlock from "./CodeBlock.astro";

interface Props {
  variant?: string;
}
const { variant = "Code" } = Astro.props;

const before = `export async function draftPost() {
  const brief = await getBrief("spring-trails");
  const data = await ga4.fetch({ brief });
  await voice.apply(data, { tone: "plain" });
  return data.summary;
}`;

const after = `export async function draftPost() {
  const brief = await getBrief("spring-trails");
  const data = await ga4.fetch({ brief });
  await voice.apply(data, { tone: "direct" });
  if (!data.approved) return null;
  return data.summary;
}`;

const brief = `{
  "campaign": "Spring Trails",
  "channel": "Email",
  "drafts": 12,
  "score": 87.5,
  "approved": true,
  "publishDate": null
}`;
---

<div class="stage">
  {variant === "Code" && <CodeBlock filename="draft.ts" code={after} />}
  {variant === "Diff" && <CodeBlock variant="diff" filename="draft.ts" diff={{ before, after }} />}
  {variant === "JSON" && <CodeBlock filename="briefs/spring-trails.json" code={brief} />}
</div>

<style>
  .stage {
    display: flex;
    justify-content: center;
  }
</style>
```

## Variants

Demo variants: `Code`, `Diff`, `JSON`.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"code" \| "diff"` | `"code"` | Code listing or unified diff. |
| `code` | `string` |  | Source text for the code view. |
| `lines` | `string[]` |  | Alternative to `code`: one string per line. |
| `language` | `"ts" \| "js" \| "json" \| "text"` |  | Highlighting hint. Inferred from `filename`. |
| `diff` | `DiffRow[] \| { before: string; after: string }` |  | Diff rows, or two texts diffed at build time. |
| `filename` | `string` | `"index.ts"` | Shown in the header. |
| `copyText` | `string` |  | Clipboard text. Defaults to the listing. |
| `labels` | `{ copy?, copied?, copiedAnnouncement? }` |  | Copy button and screen reader text. |

## Source

### `src/components/viewership/code-block/CodeBlock.astro`

```astro
---
/**
 * CodeBlock: a light editor panel with two views.
 *   - code: line-numbered listing with a Copy button
 *   - diff: unified diff with word-level add/del highlights
 * Syntax colouring happens at build time, so no-JS renders fully.
 *
 * Adapted from Beautiful UI CodeBlock (MIT, © 2026 Shane Levine).
 *
 * @example
 * <CodeBlock filename="draft.ts" code={source} />
 * <CodeBlock variant="diff" filename="draft.ts" diff={{ before, after }} />
 *
 * Events: `vs-code-copy` (detail: { text }) fires on the root after a copy.
 */
import type { HTMLAttributes } from "astro/types";
import { diffLines, languageFor, tokenize, tokenizeLines, type DiffRow, type Language } from "./highlight";

export type { CodePiece, DiffRow, Language } from "./highlight";

type Props = HTMLAttributes<"div"> & {
  /** "code" (line-numbered listing) or "diff" (unified diff). */
  variant?: "code" | "diff";
  /** Source text for the code view (split on newlines). */
  code?: string;
  /** Alternative to `code`: one string per line. */
  lines?: string[];
  /** Language hint for highlighting. Inferred from `filename` when omitted. */
  language?: Language;
  /** Diff view: explicit rows, or two texts to diff at build time. */
  diff?: DiffRow[] | { before: string; after: string };
  /** Filename shown in the header (also the region's accessible name). */
  filename?: string;
  /** Text placed on the clipboard; defaults to the listing. */
  copyText?: string;
  labels?: { copy?: string; copied?: string; copiedAnnouncement?: string };
};

const {
  variant = "code",
  code,
  lines,
  language,
  diff = [],
  filename = "index.ts",
  copyText,
  labels,
  class: className,
  ...rest
} = Astro.props;

const text = { copy: "Copy", copied: "Copied", copiedAnnouncement: "Code copied to clipboard", ...labels };
const lang = language ?? languageFor(filename);
const isDiff = variant === "diff";

const source = code ?? (lines ?? []).join("\n");
const listing = isDiff ? [] : tokenizeLines(source.replace(/\n$/, ""), lang);
const rows: DiffRow[] = Array.isArray(diff) ? diff : diffLines(diff.before, diff.after);
const added = rows.filter((r) => r.type === "add").length;
const removed = rows.filter((r) => r.type === "del").length;
const gutter = Math.max(isDiff ? Math.max(0, ...rows.map((r) => r.old ?? r.cur ?? 0)) : listing.length, 1);
const gutterWidth = gutter >= 100 ? 28 : 20;

const config = JSON.stringify({ text: copyText ?? source, copied: text.copied, announce: text.copiedAnnouncement });
---

<div
  class:list={["vs-code-block", className]}
  data-variant={variant}
  style={`--vs-code-gutter: ${gutterWidth}px`}
  {...rest}
>
  <div class="head">
    <span class="file">
      <svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
        <path d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5"></path>
      </svg>
      <span class="name">{filename}</span>
    </span>

    {isDiff ? (
      <span class="stat">
        <span class="plus"><span class="sr-only">{added} added, </span><span aria-hidden="true">+{added}</span></span>
        <span class="minus"><span class="sr-only">{removed} removed</span><span aria-hidden="true">-{removed}</span></span>
      </span>
    ) : (
      <>
        <button type="button" class="copy" data-copy>
          <svg class="icon-copy" aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2.5"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
          <svg class="icon-check" aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"></path></svg>
          <span data-copy-label>{text.copy}</span><span class="sr-only"> code</span>
        </button>
        <span class="sr-only" role="status" aria-live="polite" data-copy-status></span>
        <script type="application/json" data-vs-config set:html={config} />
      </>
    )}
  </div>

  <div class="body">
    <div class="lines">
      <span class="rule" aria-hidden="true"></span>
      {isDiff
        ? rows.map((r) => (
            <div class:list={["row", r.type !== "ctx" && `row--${r.type}`]}>
              {r.type !== "ctx" && <span class="bar" aria-hidden="true"></span>}
              <span class="num" aria-hidden="true">{(r.type === "del" ? r.old : r.cur) ?? ""}</span>
              {/* one line: whitespace between expressions would render inside pre-wrap */}
              <code>{r.type !== "ctx" && <span class="sr-only">{r.type === "add" ? "Added: " : "Removed: "}</span>}{r.pieces.map((p) => { const kids = tokenize(p.text, lang).map((t) => (t.kind ? <span class={`tk tk-${t.kind}`}>{t.text}</span> : t.text)); return p.change === "add" ? <ins>{kids}</ins> : p.change === "del" ? <del>{kids}</del> : kids; })}</code>
            </div>
          ))
        : listing.map((line, i) => (
            <div class="row">
              <span class="num" aria-hidden="true">{i + 1}</span>
              <code>{line.map((t) => (t.kind ? <span class={`tk tk-${t.kind}`}>{t.text}</span> : t.text))}</code>
            </div>
          ))}
    </div>
  </div>
</div>

<script>
  function init(root: HTMLElement) {
    const button = root.querySelector<HTMLButtonElement>("[data-copy]");
    const configEl = root.querySelector<HTMLScriptElement>("script[data-vs-config]");
    if (!button || !configEl) return;
    const label = button.querySelector<HTMLElement>("[data-copy-label]")!;
    const status = root.querySelector<HTMLElement>("[data-copy-status]")!;
    const idle = label.textContent ?? "Copy";
    const config = JSON.parse(configEl.textContent || "{}") as { text: string; copied: string; announce: string };
    let timer: ReturnType<typeof setTimeout> | undefined;

    button.addEventListener("click", async () => {
      try {
        await navigator.clipboard.writeText(config.text);
      } catch {
        return; // clipboard unavailable (insecure context) or permission denied
      }
      clearTimeout(timer);
      root.dataset.copied = "";
      label.textContent = config.copied;
      status.textContent = config.announce;
      root.dispatchEvent(new CustomEvent("vs-code-copy", { detail: { text: config.text }, bubbles: true }));
      timer = setTimeout(() => {
        delete root.dataset.copied;
        label.textContent = idle;
        status.textContent = "";
      }, 1500);
    });
  }

  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-code-block:not([data-vs-ready])").forEach((root) => {
      root.dataset.vsReady = "";
      init(root);
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>

<style>
  .vs-code-block {
    width: 100%;
    max-width: 420px;
    overflow: hidden;
    border-radius: var(--vs-radius-card);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-card);
  }

  .sr-only {
    user-select: none;
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
  }

  /* header: file, then diff stat or copy */
  .head {
    display: flex;
    align-items: center;
    gap: 8px;
    height: 44px;
    padding: 0 16px;
    border-bottom: 1px solid var(--vs-line);
    font-size: 12.5px;
  }
  .file {
    display: inline-flex;
    min-width: 0;
    align-items: center;
    gap: 7px;
  }
  .file svg {
    flex-shrink: 0;
    color: var(--vs-ink-3);
  }
  .name {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-family: var(--vs-font-mono);
    line-height: 1;
    color: var(--vs-ink);
  }
  .stat {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    margin-left: auto;
    font-family: var(--vs-font-sans);
    font-size: 12px;
    line-height: 1;
    font-variant-numeric: tabular-nums;
  }
  .plus {
    color: var(--vs-green);
  }
  .minus {
    color: var(--vs-red);
  }

  .copy {
    display: flex;
    align-items: center;
    gap: 4px;
    height: 24px;
    margin-left: auto;
    margin-right: -4px;
    padding: 0 6px;
    border: none;
    border-radius: 6px;
    background: transparent;
    color: var(--vs-ink-3);
    font: inherit;
    font-size: 12px;
    font-weight: 500;
    cursor: pointer;
    transition:
      color 100ms,
      background-color 100ms;
  }
  .copy:hover {
    background: var(--vs-hover);
    color: var(--vs-ink);
  }
  .copy:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }
  .icon-check {
    display: none;
  }
  .vs-code-block[data-copied] .copy {
    color: var(--vs-green);
  }
  .vs-code-block[data-copied] .icon-copy {
    display: none;
  }
  .vs-code-block[data-copied] .icon-check {
    display: block;
  }

  /* body: equal 12px inset on top / left / right; lines wrap */
  .body {
    padding: 12px 0;
    font-family: var(--vs-font-mono);
    font-size: 12.5px;
    line-height: 1.65;
    color: var(--vs-ink-2);
  }
  .lines {
    position: relative;
  }
  .rule {
    position: absolute;
    top: 0;
    bottom: 0;
    left: var(--vs-code-gutter);
    width: 1px;
    background: var(--vs-line);
    pointer-events: none;
  }
  .row {
    position: relative;
    display: grid;
    grid-template-columns: var(--vs-code-gutter) minmax(0, 1fr);
    align-items: start;
  }
  .num {
    user-select: none;
    text-align: center;
    font-size: 11px;
    color: var(--vs-ink-3);
  }
  code {
    min-height: 1lh;
    padding: 0 12px 0 4px;
    font: inherit;
    white-space: pre-wrap;
    overflow-wrap: break-word;
  }

  /* syntax: strings & numbers, keywords, function calls */
  .tk-string,
  .tk-number {
    color: var(--vs-orange);
  }
  .tk-keyword {
    color: var(--vs-accent-ink);
  }
  .tk-fn,
  .tk-key {
    color: var(--vs-ink);
    font-weight: 500;
  }
  .tk-comment {
    color: var(--vs-ink-3);
  }

  /* diff rows */
  .row--add {
    background: var(--vs-green-tint);
  }
  .row--del {
    background: var(--vs-red-tint);
  }
  .row--add .num {
    color: var(--vs-green);
  }
  .row--del .num {
    color: var(--vs-red);
  }
  .bar {
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    width: 3px;
  }
  .row--add .bar {
    background: var(--vs-green);
  }
  .row--del .bar {
    background: repeating-linear-gradient(45deg, var(--vs-red) 0, var(--vs-red) 1.5px, transparent 1.5px, transparent 3px);
  }
  ins,
  del {
    margin: 0 -1px;
    padding: 0 2px;
    border-radius: 3px;
    text-decoration: none;
    -webkit-box-decoration-break: clone;
    box-decoration-break: clone;
  }
  ins {
    background: color-mix(in srgb, var(--vs-green) 18%, transparent);
  }
  del {
    background: color-mix(in srgb, var(--vs-red) 18%, transparent);
  }
</style>
```

### `src/components/viewership/code-block/highlight.ts`

```ts
/**
 * Build-time helpers for CodeBlock: a JS/TS/JSON tokenizer and a unified
 * line diff with word-level pieces.
 *
 * Adapted from Beautiful UI CodeBlock (MIT, © 2026 Shane Levine).
 */

export type Language = "ts" | "js" | "json" | "text";

export type TokenKind = "keyword" | "string" | "number" | "fn" | "key" | "comment";
export type Token = { text: string; kind?: TokenKind };

/** A run of code within a diff row; `change` marks a word-level add/del. */
export type CodePiece = { text: string; change?: "add" | "del" };
/** One row of a unified diff: old/new line numbers, its kind, and its pieces. */
export type DiffRow = {
  old: number | null;
  cur: number | null;
  type: "ctx" | "add" | "del";
  pieces: CodePiece[];
};

const JS_KEYWORDS = new Set([
  "import", "from", "export", "default", "async", "function", "const", "let", "var", "await",
  "return", "if", "else", "for", "while", "new", "throw", "try", "catch", "null", "true", "false",
  "undefined", "type", "interface", "as", "of", "in", "class", "extends", "switch", "case", "break",
  "continue", "this", "typeof", "satisfies",
]);

const JS_TOKEN =
  /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)|(\b\d+(?:\.\d+)?\b)|([A-Za-z_$][\w$]*)/g;

const JSON_TOKEN = /("(?:\\.|[^"\\\n])*")|(-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|\b(true|false|null)\b/g;

/** Infer a language from a filename extension. */
export function languageFor(filename = ""): Language {
  const ext = filename.split(".").pop()?.toLowerCase() ?? "";
  if (ext === "json" || ext === "jsonc") return "json";
  if (["js", "mjs", "cjs", "jsx"].includes(ext)) return "js";
  return "ts";
}

/** Tokenize source text. Unmatched text becomes plain (kind-less) tokens. */
export function tokenize(text: string, language: Language = "ts"): Token[] {
  if (language === "text") return [{ text }];
  const out: Token[] = [];
  let last = 0;
  const push = (t: string, kind?: TokenKind) => {
    if (!t) return;
    const prev = out[out.length - 1];
    if (!kind && prev && !prev.kind) prev.text += t;
    else out.push({ text: t, kind });
  };
  const re = new RegExp(language === "json" ? JSON_TOKEN : JS_TOKEN);
  for (const m of text.matchAll(re)) {
    const idx = m.index ?? 0;
    const rest = text.slice(idx + m[0].length);
    push(text.slice(last, idx));
    if (language === "json") {
      if (m[1]) push(m[1], /^\s*:/.test(rest) ? "key" : "string");
      else if (m[2]) push(m[2], "number");
      else push(m[0], "keyword");
    } else if (m[1]) push(m[1], "comment");
    else if (m[2]) push(m[2], "string");
    else if (m[3]) push(m[3], "number");
    else if (m[4] && JS_KEYWORDS.has(m[4])) push(m[4], "keyword");
    else if (m[4] && /^\s*\(/.test(rest)) push(m[4], "fn");
    else push(m[0]);
    last = idx + m[0].length;
  }
  push(text.slice(last));
  return out;
}

/** Tokenize a whole listing, then split it into per-line token arrays
 *  (so block comments and template strings may span lines). */
export function tokenizeLines(text: string, language: Language = "ts"): Token[][] {
  const lines: Token[][] = [[]];
  for (const tok of tokenize(text, language)) {
    tok.text.split("\n").forEach((part, i) => {
      if (i > 0) lines.push([]);
      if (part) lines[lines.length - 1].push({ text: part, kind: tok.kind });
    });
  }
  return lines;
}

/* ── diff ── */

function lcsTable<T>(a: T[], b: T[]): number[][] {
  const t = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0));
  for (let i = a.length - 1; i >= 0; i--)
    for (let j = b.length - 1; j >= 0; j--)
      t[i][j] = a[i] === b[j] ? t[i + 1][j + 1] + 1 : Math.max(t[i + 1][j], t[i][j + 1]);
  return t;
}

type Op<T> = { type: "ctx" | "add" | "del"; value: T; ai: number; bi: number };

function diffSeq<T>(a: T[], b: T[]): Op<T>[] {
  const t = lcsTable(a, b);
  const ops: Op<T>[] = [];
  let i = 0;
  let j = 0;
  while (i < a.length && j < b.length) {
    if (a[i] === b[j]) ops.push({ type: "ctx", value: a[i], ai: i++, bi: j++ });
    else if (t[i + 1][j] >= t[i][j + 1]) ops.push({ type: "del", value: a[i], ai: i++, bi: -1 });
    else ops.push({ type: "add", value: b[j], ai: -1, bi: j++ });
  }
  while (i < a.length) ops.push({ type: "del", value: a[i], ai: i++, bi: -1 });
  while (j < b.length) ops.push({ type: "add", value: b[j], ai: -1, bi: j++ });
  return ops;
}

const WORD = /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`[^`]*`|[\w$]+|\s+|[^\w$\s]/g;

/** Word-level pieces for a removed/added line pair. Returns null when the
 *  lines share too little to make a highlight meaningful. */
function wordPieces(before: string, after: string): [CodePiece[], CodePiece[]] | null {
  const a = before.match(WORD) ?? [];
  const b = after.match(WORD) ?? [];
  const ops = diffSeq(a, b);
  const common = ops.filter((o) => o.type === "ctx").reduce((n, o) => n + o.value.trim().length, 0);
  const longest = Math.max(before.trim().length, after.trim().length, 1);
  if (common / longest < 0.4) return null;

  const build = (side: "del" | "add") => {
    const pieces: CodePiece[] = [];
    const kept = ops.filter((o) => o.type === "ctx" || o.type === side);
    kept.forEach((o, k) => {
      let changed = o.type === side;
      // whitespace sandwiched between two changes joins the highlight
      if (!changed && /^\s+$/.test(o.value) && kept[k - 1]?.type === side && kept[k + 1]?.type === side) changed = true;
      const prev = pieces[pieces.length - 1];
      const change = changed ? side : undefined;
      if (prev && prev.change === change) prev.text += o.value;
      else pieces.push({ text: o.value, change });
    });
    return pieces;
  };
  return [build("del"), build("add")];
}

/** Unified diff of two texts, with word-level highlights on paired lines. */
export function diffLines(before: string, after: string): DiffRow[] {
  const ops = diffSeq(before.split("\n"), after.split("\n"));
  const rows: DiffRow[] = [];
  let k = 0;
  while (k < ops.length) {
    const op = ops[k];
    if (op.type === "ctx") {
      rows.push({ old: op.ai + 1, cur: op.bi + 1, type: "ctx", pieces: [{ text: op.value }] });
      k++;
      continue;
    }
    // a hunk: every consecutive del/add, rendered dels-then-adds
    const dels: Op<string>[] = [];
    const adds: Op<string>[] = [];
    while (k < ops.length && ops[k].type !== "ctx") (ops[k].type === "del" ? dels : adds).push(ops[k++]);
    const pairs = dels.map((d, i) => (adds[i] ? wordPieces(d.value, adds[i].value) : null));
    dels.forEach((d, i) =>
      rows.push({ old: d.ai + 1, cur: null, type: "del", pieces: pairs[i]?.[0] ?? [{ text: d.value }] }),
    );
    adds.forEach((a, i) =>
      rows.push({ old: null, cur: a.bi + 1, type: "add", pieces: pairs[i]?.[1] ?? [{ text: a.value }] }),
    );
  }
  return rows;
}
```
