diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b25d593..f8c00c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: # Reads the pinned version from packageManager in package.json. - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 22 cache: pnpm diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5ebf69e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +Selector changes get their own line. They are the change class most likely to cause +a field regression, so they should never be buried inside a general entry. + +## 1.0.0 + +Initial release. + +- Syntax highlighting for Google Chat code blocks, rendered into a shadow root beside + the original so message data is never modified. +- Copy button per block, copying the clean source with any language tag line removed. +- 15 bundled languages: bash, cpp, css, diff, go, java, javascript, json, markdown, + python, rust, sql, typescript, xml, yaml. +- Auto-detection gated on a relevance score, so low-confidence blocks render as plain + monospace instead of being mislabeled. +- Six themes plus Auto mode, which follows Chat's own appearance via background + luminance rather than `prefers-color-scheme`. +- Optional Gmail support, off by default, registered at runtime on user consent. +- Selectors: `article[role="code"]` (primary), `div.kaPZDd > article` (fallback), + `div.kaPZDd` (container), `div[role="main"]` (observer root). Captured 2026-08-06. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b65d01 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# GCCH — Google Chat Code Highlighter + +Syntax highlighting and a copy button for code blocks in Google Chat. + +Chat renders fenced code blocks as flat monospace text with no coloring and no way +to copy them cleanly. GCCH fixes both, without touching the composer and without +altering message data. + +## Quick start + +Requires Node 20.11+ and [pnpm](https://pnpm.io) 9+ (`corepack enable` is enough — +the version is pinned via `packageManager` in `package.json`). + +```bash +pnpm install +pnpm build +``` + +Then load the unpacked extension: `chrome://extensions` → enable Developer mode → +**Load unpacked** → select `dist/`. + +```bash +pnpm test # unit tests +pnpm check # typecheck + lint + format + tests +pnpm dev # rebuild the content script on change +pnpm package # zip dist/ into releases/ for the Web Store +``` + +After `pnpm dev` rebuilds, hit reload on the extension card and refresh Chat. + +pnpm is used deliberately rather than npm: its non-flat `node_modules` makes phantom +dependencies a hard error, so a package we never declared cannot quietly get bundled +into the shipped extension. + +## How it works + +A content script watches the message list, finds `article[role="code"]` blocks, and +renders a highlighted copy into a shadow root **beside** the original — the original +`
` is never modified, only hidden by our own stylesheet. + +That one decision buys three things: Chat's native "copy message" stays byte-exact, +Google's Wiz renderer never sees mutated nodes it might choke on, and disabling the +extension restores the page perfectly without a reload. + +Highlighting uses highlight.js with 15 explicitly registered languages. Blocks with +an explicit language tag use it; blocks without one go through auto-detection that is +gated on a relevance score, so logs and stack traces render as clean monospace rather +than being confidently mislabeled. + +## Layout + +``` +src/ + content/ + index.ts lifecycle: settings, enable/disable, teardown + observer.ts MutationObserver, rAF-batched and throttled + detect.ts finds unprocessed blocks, rejects the composer + extract.ts article -> source text [pure] + language.ts language-tag parsing and aliases [pure] + highlight.ts hljs subset + auto-detect confidence gate + render.ts shadow host, copy button, theme swapping + theme.ts luminance-based light/dark detection [pure] + shared/ + constants.ts EVERY Chat selector lives here + settings.ts storage schema, validated on read + logger.ts the only sanctioned console user + themes/ hljs theme CSS bundled as strings + popup/ settings UI +tests/ + fixtures/ verbatim DOM captures from real Chat +docs/DOM-NOTES.md ground truth for the selectors +``` + +The `[pure]` modules take plain values and return plain values — no `chrome.*`, no +globals, no DOM mutation. They hold nearly all the logic that can actually be wrong, +and they are fully covered by tests. + +## When Google changes Chat's markup + +This extension sits on top of a DOM that can change without notice, so that case is +designed for rather than hoped against: + +- **Every selector is in `src/shared/constants.ts`**, each tagged `DURABLE` (a11y + role, semantic tag) or `VOLATILE` (obfuscated class). Nothing else in the codebase + contains a Chat selector. +- **Failure is silent, not destructive.** Detection returns empty rather than + throwing, mounting bails when the container is missing, and a highlighter error + falls back to plain monospace with a working copy button. The worst realistic + outcome is that GCCH does nothing — never that Chat breaks. +- **A health check names the problem.** If a page has messages but no block matches + after several scans, GCCH logs a one-time warning pointing at `constants.ts`. +- **Turn on Debug logging in the popup** to see blocks found per scan, scan duration, + language decisions with relevance scores, and any selector-tier fallback taken. + +Fixing a break: capture the new markup per the recipe in +[`docs/DOM-NOTES.md`](docs/DOM-NOTES.md), add it as a _new_ fixture beside the old +one, update the selector, and get both fixtures passing. + +## Privacy and permissions + +- `storage` — preferences only. +- `scripting` — registers the Gmail content script at runtime, if you opt in. +- `chat.google.com` — where the extension does its work. +- `mail.google.com` — **optional**, off by default, requested only when you enable + "Chat in Gmail" in the popup. It is not in `host_permissions` because that install + warning reads as "this extension can read your email". + +Everything runs locally. No network requests, no analytics, no message content leaves +the page. highlight.js and all theme CSS are bundled — nothing is fetched at runtime, +and there is no `eval` anywhere, so the MV3 CSP story is trivial. + +## Adding a language + +Two places, both required: + +1. Import and register it in `src/content/highlight.ts` (`LANGUAGES`). +2. Add its aliases to `ALIASES` in `src/content/language.ts`. + +Auto-detection can only ever return a registered language, so step 1 also widens what +untagged blocks can be detected as. Update the list assertion in +`tests/highlight.test.ts`. diff --git a/docs/DOM-NOTES.md b/docs/DOM-NOTES.md new file mode 100644 index 0000000..85bf436 --- /dev/null +++ b/docs/DOM-NOTES.md @@ -0,0 +1,79 @@ +# Google Chat DOM notes + +Ground truth for every selector in `src/shared/constants.ts`. Captured **2026-08-06** +from a live `chat.google.com` message. + +If Chat's markup changes, update this file in the same commit as the selector fix. +The next person should not have to re-derive any of this. + +## The code block + +```html +
+
+
 Prerequisites
+

+
  export GH_PAT='<your-github-pat>'
+
+
+ +
+
+``` + +## What this means in practice + +**`article[role="code"]` is the durable selector.** It is an accessibility role, not +a build-generated class. Google changes these rarely because doing so breaks screen +readers. `div.kaPZDd` and `.Byzfyc` are obfuscated and should be assumed disposable. + +**One `
` per source line.** This is why `extract.ts` exists. +`article.textContent` concatenates every line with no separator and gives you one +long unusable string. `tests/extract.test.ts` asserts that divergence directly so +nobody "simplifies" the extractor back into a bug. + +**Blank lines are `

`** — no text content at all. + +**Indentation is ` ` (U+00A0), not spaces.** Both leading indentation and +repeated interior spaces. Extraction converts them back to U+0020; if it did not, +copied code would break shell heredocs and YAML. + +**Every line carries an empty ``.** Today they are empty, +so skipping them changes nothing. They are clearly a slot for something, and if Chat +ever puts text in them it would silently corrupt every copied snippet. `extract.ts` +excludes `display:none` subtrees for that reason. + +**`exclude-from-clipboard="true"` is Chat's own convention** for keeping injected UI +out of copied message text. Their toolbar carries it, so our shadow host carries it +too. Without it, copying a message would yield the code twice. + +**Chat does not parse ` ```lang ` fences.** It strips the backticks and keeps +everything else verbatim, so a language tag survives as the literal first line of the +block. `language.ts` strips it, but only when the line is a bare token — `python` is +a tag, `python -m venv .venv` is a shell command. + +## Ancestors worth knowing + +| Selector | Notes | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `div[role="main"]` | Scrolling message list. Observer root. Chat **replaces this node** on space navigation, which is why `observer.ts` watches for the swap. | +| `[data-message-id]` | Present on sent messages. Used by the health check to distinguish "no messages" from "selectors broke". | +| `div.DTp27d` (`jsname="bgckF"`) | Message body wrapper. Volatile, currently unused. | +| `c-wiz[data-topic-id]` | Thread root. Volatile, currently unused. | + +## Accepted limitation + +Google's "Toggle wrap" button restyles the `
`, which we hide. So it is a +no-op on our render. Mitigation if it ever matters: listen for clicks on +`div.n5Mo1b button[aria-label="Toggle wrap"]` and toggle a wrap class inside our +shadow root — no dependency on Google's internal classes. Deliberately not in v1. + +## Capturing a new fixture + +When Chat's markup changes: + +1. In Chat DevTools, select the block wrapper and run `$0.outerHTML`. +2. Save it as a **new** file in `tests/fixtures/`, alongside the old one. Do not + replace the old fixture — keeping both is how compatibility across Chat rollouts + stays proven rather than assumed. +3. Add it to the test matrix and adjust selectors until both fixtures pass. diff --git a/docs/PROJECT-STATUS.md b/docs/PROJECT-STATUS.md new file mode 100644 index 0000000..f90b481 --- /dev/null +++ b/docs/PROJECT-STATUS.md @@ -0,0 +1,115 @@ +# Project status + +Snapshot as of **2026-08-07**, v1.0.0. Written so this project can be picked up cold +months later without re-deriving anything. + +Read alongside [`DOM-NOTES.md`](DOM-NOTES.md) (the Chat markup ground truth) and +[`../CONTRIBUTING.md`](../CONTRIBUTING.md) (workflow). + +## Where things stand + +**Working and confirmed in real Google Chat.** Code blocks are detected, highlighted, +wrapped, and copyable. Verified by the author on `chat.google.com` in dark mode. + +**Automated gate is green:** 59 tests, typecheck, lint, format. `pnpm check` runs all +of it. CI runs the same plus a build and a bundle-shape assertion. + +## Verified by hand + +- Highlighting appears on real sent messages (markdown and bash blocks) +- Copy button works +- Rounded corners, tight padding, line wrapping +- Dark mode detection + +## NOT yet verified — do these before trusting it further + +These are the real remaining risks. None are covered by tests because they need a +live browser and a Google account. + +1. **Native message copy is byte-exact.** Copy a message with a code block using + Chat's own menu, paste into an editor, compare against the original. This is the + most important one - it protects the user's actual data. It should hold, because + the original `
` is never modified, but it has not been confirmed since + the toolbar-hiding change. +2. **Wrap toggle off.** Click "Wrap" on a wide block; it should revert to horizontal + scrolling. Collapsing `overflow-x` and `white-space` inside a shadow root is the + least-trusted CSS path here. +3. **Composer untouched.** Type a ``` block in the composer without sending. Nothing + should be highlighted or altered until sent. +4. **Scroll performance.** DevTools Performance recording while scrolling a long + space. No long tasks attributable to the observer. +5. **Light mode / theme switching.** Switch Chat's own theme while leaving the OS + theme fixed. Highlighting must follow Chat, which is the whole point of luminance + detection over `prefers-color-scheme`. +6. **Space navigation.** Switch between spaces; blocks must still highlight after + Chat replaces `div[role="main"]`. +7. **Disable toggle.** Turn highlighting off in the popup; the page must return to + normal without a reload. +8. **Gmail path.** Entirely unexercised. `chrome.scripting.registerContentScripts` + after granting the optional permission has never run. +9. **Untagged auto-detect quality.** Send a raw stack trace with no language tag; it + should stay plain monospace rather than being mislabeled. +10. **Explicit language tag.** Send a block whose first line is `python`. The tag line + should vanish from the render and the block should be Python-colored. **This path + is only tested synthetically** - the original DOM capture had no language tag, so + the assumption that Chat preserves the tag as a literal first line is unconfirmed + against real markup. + +## Tuning knobs + +Both live in `src/shared/constants.ts`: + +| Constant | Now | Change it if | +| -------------------- | --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `AUTO_RELEVANCE_MIN` | 8 | Untagged blocks stay plain when they should be colored (lower it), or logs get mislabeled as a language (raise it). This is a starting guess, not a tuned value. | +| `SCAN_THROTTLE_MS` | 150 | Scrolling stutters (raise), or new messages take too long to highlight (lower). | + +## Decisions worth not relitigating + +- **Shadow DOM sibling, original never mutated.** This is load-bearing. It keeps + Chat's native copy byte-exact, avoids provoking Google's Wiz renderer, and makes + disable/unmount perfectly reversible. Do not "simplify" it into an innerHTML + rewrite of the article. +- **Google's native toolbar is hidden.** Its "Toggle wrap" restyles the article we + hide, so it was dead, and it occupied the same corner as our controls. We ship our + own wrap toggle. (The original plan kept it visible; that was reversed once the + button collision showed up in practice.) +- **Wrap is on by default.** Chat panes are narrow; horizontal scrolling to read the + end of a line is worse than soft wrapping. +- **Atom One is the default theme.** GitHub's themes washed out at chat font sizes. +- **15 languages, registered explicitly.** Auto-detection can only ever return a + registered language, so the list bounds both features. +- **Everything is local.** No network calls at all - verified by grepping the built + bundle for `fetch`/XHR/WebSocket/dynamic import (zero hits). highlight.js and every + theme are bundled as strings. Keep it that way; it is the extension's main privacy + claim and what makes the CSP story trivial. + +## Known limitations + +- Inline code (single backticks) is not highlighted, only fenced blocks. Deliberate. +- Wrap state is per-block and resets on re-render; it is not persisted. +- Only the languages in `highlight.ts` are recognized. + +## Repo notes + +- **Git identity is repo-local**: `Dinesh Sutihar `, set via + `git config --local` so the machine's global Motorq identity is untouched. **A fresh + clone will not carry this** - `.git/config` is not cloned. Re-run: + ```bash + git config --local user.name "Dinesh Sutihar" + git config --local user.email "dineshsutihar9@gmail.com" + ``` +- **pnpm, not npm.** Pinned via `packageManager`. `corepack enable` then `pnpm install`. +- `icons/*.png` are gitignored and regenerated by the `prebuild` hook, so a fresh + clone still builds. +- `tests/fixtures/chat-code-block.html` contains a real internal Helm/kubectl snippet + (namespace, resource group, AKS cluster, Azure subscription id). Harmless in a + private repo; **scrub it before making this public.** + +## If it suddenly stops working + +Almost certainly Google changed Chat's markup. Turn on **Debug logging** in the popup, +reload Chat, and check the console for `[GCCH]` lines - a `selector miss` or +`selector-fallback` warning names the failure. Then follow the fixture-capture recipe +in [`DOM-NOTES.md`](DOM-NOTES.md). Every selector is in `src/shared/constants.ts` and +nowhere else, so the fix is usually one line. diff --git a/tests/detect.test.ts b/tests/detect.test.ts new file mode 100644 index 0000000..ee65c99 --- /dev/null +++ b/tests/detect.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { containerFor, findCodeBlocks, hasMessages, isInComposer } from '../src/content/detect.js'; +import { PROCESSED_ATTR } from '../src/shared/constants.js'; +import { loadFixture } from './fixtures/index.js'; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +function mountFixture(): HTMLElement { + const block = loadFixture('chat-code-block'); + document.body.appendChild(block); + return block; +} + +describe('findCodeBlocks', () => { + it('finds the block in real captured markup', () => { + mountFixture(); + const found = findCodeBlocks(document); + + expect(found).toHaveLength(1); + expect(found[0]?.getAttribute('role')).toBe('code'); + }); + + it('skips blocks already processed', () => { + const block = mountFixture(); + block.querySelector('article')?.setAttribute(PROCESSED_ATTR, 'true'); + + expect(findCodeBlocks(document)).toHaveLength(0); + }); + + it('finds blocks via the fallback when the role attribute is gone', () => { + const block = mountFixture(); + block.querySelector('article')?.removeAttribute('role'); + + expect(findCodeBlocks(document)).toHaveLength(1); + }); + + it('returns empty rather than throwing when nothing matches', () => { + document.body.innerHTML = '
no code here
'; + expect(findCodeBlocks(document)).toEqual([]); + }); +}); + +describe('composer guardrail', () => { + // The single most important guardrail: formatting must only ever apply to + // messages that have been sent. Touching the composer would corrupt what the + // user is typing. + it('rejects a code block inside a contenteditable composer', () => { + const composer = document.createElement('div'); + composer.setAttribute('contenteditable', 'true'); + composer.appendChild(loadFixture('chat-code-block')); + document.body.appendChild(composer); + + expect(findCodeBlocks(document)).toEqual([]); + }); + + it('detects composer ancestry at any depth', () => { + const composer = document.createElement('div'); + composer.setAttribute('contenteditable', 'true'); + composer.innerHTML = '
'; + document.body.appendChild(composer); + + const article = composer.querySelector('article'); + expect(article).not.toBeNull(); + expect(isInComposer(article as Element)).toBe(true); + }); +}); + +describe('containerFor', () => { + it('resolves the Chat block wrapper', () => { + mountFixture(); + const article = document.querySelector('article'); + expect(containerFor(article as HTMLElement)?.className).toBe('kaPZDd'); + }); + + it('falls back to the parent element when the wrapper class changes', () => { + const parent = document.createElement('section'); + const article = document.createElement('article'); + parent.appendChild(article); + document.body.appendChild(parent); + + expect(containerFor(article)).toBe(parent); + }); +}); + +describe('hasMessages', () => { + it('distinguishes an empty page from a page of messages', () => { + expect(hasMessages(document)).toBe(false); + + document.body.innerHTML = '
'; + expect(hasMessages(document)).toBe(true); + }); +}); diff --git a/tests/extract.test.ts b/tests/extract.test.ts new file mode 100644 index 0000000..e95311b --- /dev/null +++ b/tests/extract.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { extractCode } from '../src/content/extract.js'; +import { loadFixture, makeArticle } from './fixtures/index.js'; + +function fixtureArticle(): Element { + const article = loadFixture('chat-code-block').querySelector('article'); + if (article === null) throw new Error('fixture lost its article'); + return article; +} + +describe('extractCode', () => { + it('reconstructs one line per child div', () => { + const code = extractCode(fixtureArticle()); + const lines = code.split('\n'); + + expect(lines[0]).toBe(' Prerequisites'); + expect(lines[1]).toBe(''); + expect(lines[2]).toBe(' # Need GH_PAT set (GitHub PAT for helm repo access)'); + }); + + it('diverges from textContent, which is why this module exists', () => { + const article = fixtureArticle(); + const extracted = extractCode(article); + + expect(extracted).toContain('\n'); + expect(article.textContent).not.toContain('\n'); + expect(extracted).not.toBe(article.textContent); + }); + + it('converts non-breaking spaces to real spaces', () => { + const code = extractCode(fixtureArticle()); + + expect(code).not.toContain(' '); + expect(code).toContain(' export GH_PAT='); + }); + + it('preserves indentation depth exactly', () => { + const code = extractCode(fixtureArticle()); + const continuation = code + .split('\n') + .find((line) => line.includes('--values outputs/fmca-api-config.yaml')); + + expect(continuation).toBe(' --values outputs/fmca-api-config.yaml \\'); + }); + + it('preserves interior blank lines', () => { + const code = extractCode(fixtureArticle()); + expect(code).toContain('\n\n'); + }); + + it('decodes HTML entities back to their characters', () => { + const code = extractCode(fixtureArticle()); + expect(code).toContain("export GH_PAT=''"); + expect(code).toContain('helm repo update && \\'); + }); + + it('excludes display:none descendants', () => { + const article = makeArticle(['visible']); + const ghost = article.querySelector('span'); + if (ghost === null) throw new Error('expected a ghost span'); + ghost.textContent = 'SHOULD NOT APPEAR'; + + expect(extractCode(article)).toBe('visible'); + }); + + it('drops trailing blank lines but keeps leading ones', () => { + const article = makeArticle(['', 'code', '', '']); + expect(extractCode(article)).toBe('\ncode'); + }); + + it('does not mutate the article', () => { + const article = fixtureArticle(); + const before = article.outerHTML; + extractCode(article); + expect(article.outerHTML).toBe(before); + }); + + it('falls back to raw text when the per-line structure is gone', () => { + const article = document.createElement('article'); + article.textContent = 'flat text'; + expect(extractCode(article)).toBe('flat text'); + }); +}); diff --git a/tests/fixtures/chat-code-block.html b/tests/fixtures/chat-code-block.html new file mode 100644 index 0000000..3f62dde --- /dev/null +++ b/tests/fixtures/chat-code-block.html @@ -0,0 +1,12 @@ + +
 Prerequisites

  # Need GH_PAT set (GitHub PAT for helm repo access)
  export GH_PAT='<your-github-pat>'

  # Set kubectl context to the right cluster
  az aks get-credentials --resource-group motorq-release --name motorq-aks-release

  # Must run from the config dir
  cd ~/.ocr/.configs/replay337v4

  helm repo update && \
  helm template fmca-api-replay337v4 motorq/q \
    --values outputs/fmca-api-config.yaml \
    --namespace replay337v4 | \
  kapp deploy -a fmca-api-replay337v4 -f - -n replay337v4 --yes

  kubectl get pods -n replay337v4 | grep -E "fmca-api|fmca-app"
diff --git a/tests/fixtures/index.ts b/tests/fixtures/index.ts new file mode 100644 index 0000000..9c9b3b3 --- /dev/null +++ b/tests/fixtures/index.ts @@ -0,0 +1,34 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const dir = import.meta.dirname; + +/** Parses a captured fixture into a detached DOM and returns its root element. */ +export function loadFixture(name: string): HTMLElement { + const html = readFileSync(resolve(dir, `${name}.html`), 'utf8'); + const container = document.createElement('div'); + container.innerHTML = html; + const root = container.querySelector('div.kaPZDd'); + if (root === null) throw new Error(`fixture ${name} has no div.kaPZDd root`); + return root; +} + +/** Builds a code-block article from plain source lines, for focused unit tests. */ +export function makeArticle(lines: readonly string[]): HTMLElement { + const article = document.createElement('article'); + article.setAttribute('role', 'code'); + for (const line of lines) { + const div = document.createElement('div'); + if (line === '') { + div.appendChild(document.createElement('br')); + } else { + // Chat renders leading and repeated spaces as non-breaking spaces. + div.append(line.replace(/ {2,}|^ /g, (run) => ' '.repeat(run.length))); + const ghost = document.createElement('span'); + ghost.style.display = 'none'; + div.appendChild(ghost); + } + article.appendChild(div); + } + return article; +} diff --git a/tests/highlight.test.ts b/tests/highlight.test.ts new file mode 100644 index 0000000..a7229ff --- /dev/null +++ b/tests/highlight.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { highlightCode, registeredLanguages } from '../src/content/highlight.js'; + +describe('registered language set', () => { + it('registers exactly the 15 planned languages', () => { + expect(registeredLanguages()).toEqual([ + 'bash', + 'cpp', + 'css', + 'diff', + 'go', + 'java', + 'javascript', + 'json', + 'markdown', + 'python', + 'rust', + 'sql', + 'typescript', + 'xml', + 'yaml', + ]); + }); +}); + +describe('highlightCode with an explicit language', () => { + it('honors the requested language', () => { + const result = highlightCode('def f():\n return 1', 'python'); + expect(result?.language).toBe('python'); + expect(result?.html).toContain('hljs-keyword'); + }); + + it('escapes HTML in the source rather than emitting it', () => { + const result = highlightCode('', 'xml'); + expect(result?.html).not.toContain('