SLOPSHOPPER

share-preview

See how the page you're building shows up in Google and when shared on X, LinkedIn, Slack and Facebook, with the problems that would cost you clicks. /preview…

newpaneguardcommandnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · share-preview
│ ┃ Share preview ✕ › fix the failing auth test and add an audit log call │ ┃ Could not read /work/app/src/cache.ts: │ ┃ ENOENT: no such file /work/app/src/cache.ts ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Usage: /preview [url | file] /preview ⏺ Update(src/auth.ts) │ ┃ the page Claude edited last ⎿ Added 2 lines, removed 1 line │ ┃ /preview src/app/page.tsx a page, ⏺ Bash(bun test) │ ┃ template or Markdown file /preview ⎿ 3 pass, 1 fail │ ┃ localhost:3000/about the rendered page │ ┃ (most accurate) /preview ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ https://example.com a live page │ ✻ Worked for 42s · done 4:20 PM │ │ › /preview │ ⎿ share-preview: Could not read /work/app/src/cache.ts: ENOENT: no │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Share preview
Could not read /work/app/src/cache.ts: ENOENT: no such file /work/app/src/cache.ts Usage: /preview [url | file] /preview the page Claude edited last /preview src/app/page.tsx a page, template or Markdown file /preview localhost:3000/about the rendered page (most accurate) /preview https://example.com a live page
README

Claude Code mods by krysmalskipl

Mods for Claude Code, built for SEO and content work. Install only the ones you want.

Install

Every mod installs on its own. The quick version:

claude plugin marketplace add krysmalskipl/claude-code-mods
claude plugin install <mod>@krysmalskipl

Then restart Claude Code. Each mod below has the full steps, including any key it needs.

Needs Claude Code 2.1.287+ in the terminal, or the Claude desktop app's Code tab. Plugins installed from the terminal show up in both.

Mods

ModWhat it doesCommand
🪙openrouter-balanceYour OpenRouter credit: the real balance with a gauge, today's spend and the model the money mostly went to. A regular API key shows its own spend and limit instead/openrouter-balance
📏meta-lintThe SEO basics of the page or post Claude just edited: title and meta description length, H1 count, images without alt, word count. Markdown, MDX, Astro and HTML/meta-lint [file]
🔗link-checkChecks the external links Claude adds. Broken ones go back to Claude so it can fix them, and show above the prompt/links [file]
🪧share-previewHow the page looks in Google (desktop and mobile, cut by pixel width) and as a shared link on LinkedIn, Facebook, Slack and X, with what would cost you clicks. Live while Claude edits`/preview [url \file]`
⏱️cache-statsYour prompt cache above the prompt: hit rate, how long it stays warm, what a cold restart re-caches, and how long the session has run. /cache lists every miss and why`/cache [hide\show]`

📏 meta-lint

meta-lint and link-check in Claude Code: /meta-lint details with a search snippet preview, /links, and the SEO line above the prompt

One line above the prompt after Claude edits a page or post:

◆ SEO  post.md  title 58/60 ✓ · description 186/160 ✗ · H1 1 ✓ · 2 images without alt · 2,726 words
Install
claude plugin marketplace add krysmalskipl/claude-code-mods   # once per machine
claude plugin install meta-lint@krysmalskipl

Restart Claude Code or type /reload-plugins. No key, no setup: it reads files on your machine and sends nothing anywhere. The line appears after Claude's first Write or Edit of a .md, .mdx, .markdown, .astro, .html or .htm file.

What it checks
CheckWhere it looksGreenYellowRed
TitleFrontmatter seo_title, meta_title or title (the first one set), else <title>, else the first # H130 to 60 charactersunder 30over 60, or missing
DescriptionFrontmatter seo_description, meta_description, description or excerpt, else <meta name="description">70 to 160 charactersunder 70over 160, or missing
H1# lines and <h1> tags in the body, outside code blocksone, or none when the frontmatter has a title (templates usually render it as the H1)more than one, or none at all
Images without alt and <img> without altany
Wordsthe body, without codeshown, not judged
Commands
  • /meta-lint shows the details of the last page: each value, where it came from, how far over or under it is, and a search snippet preview cut where Google would likely cut it
  • /meta-lint path/to/file.md checks any file, edited or not

Limits are in characters. Google cuts titles by pixel width (about 600 px), so a title of wide letters can be cut sooner: treat the numbers as a guide.

🔗 link-check

Checks every external link Claude adds to a page, post or template, while Claude is still working.

◆ Links  2 not working · https://gone.dev/page (404) in post.md and 1 more · /links
Install
claude plugin marketplace add krysmalskipl/claude-code-mods   # once per machine
claude plugin install link-check@krysmalskipl

Restart Claude Code or type /reload-plugins. No key, no setup.

How it works
  • After each Write or Edit of a .md, .mdx, .astro, .html, .tsx, .jsx, .vue, .svelte, .php, .njk, .liquid or .twig file, it finds the http(s) links new in that edit and asks each one (a HEAD, or a GET when the host refuses HEAD)
  • Claude is told: links that answer 4xx or 5xx, or don't answer at all, go back to Claude with the edit's result, so it can fix or remove them in the same turn
  • You see them above the prompt until they're gone from the file. /links lists them
  • An edit waits at most 6 seconds for its links. A slower host's answer comes later as a toast
  • 401, 403 and 429 count as working: they're usually bot walls or rate limits, and the page opens in a browser
  • Skipped: localhost, 127.0.0.1, example.com, .local, .test and links with template placeholders (${…}, {{…}})
  • Each URL is asked once per session, at most 25 per edit and 6 at a time. Requests go from your machine straight to each link's host, nowhere else
Commands
  • /links lists the links that don't work, and where
  • /links path/to/file.md checks every link in a file now (up to 100), with ✓ working, ↪ redirect, ~ blocks bots and ✗ broken

🪧 share-preview

See the page you're building the way people will meet it: as a Google result and as a link someone shares. A pane next to the chat, updated while Claude edits.

✗ og:image "/og.png" is relative: platforms need a full https:// URL
! Title is 668px wide, Google shows about 600px: the end is cut
! No twitter:card: X shows a small thumbnail, add summary_large_image for the big card
✓ Description fits desktop and mobile (512px)
Install
claude plugin marketplace add krysmalskipl/claude-code-mods   # once per machine
claude plugin install share-preview@krysmalskipl

Restart Claude Code or type /reload-plugins. No key, no setup.

Use it
  • /preview shows the page Claude edited last: HTML, Astro, Vue, Svelte, JSX with <Head>, a Next.js metadata export or Markdown frontmatter. The pane follows every later edit of that file
  • /preview localhost:3000/pricing reads the page your dev server renders, the most accurate view. It refreshes after each of Claude's turns
  • /preview https://example.com checks a live page
  • In the pane: g Google, s shared link, r refresh
What it shows
  • Google: the desktop and mobile result, the title cut at about 600 px and the description at about 920 px (desktop) and 680 px (mobile), measured in Arial glyph widths the way Google draws them, not in characters
  • Shared link: the LinkedIn and Facebook card, Slack's unfurl and X's card (large, small or none, from twitter:card), with the fallbacks each platform uses when a tag is missing
  • Checks, worst first: noindex, a missing title, a missing or relative or local og:image, an og:image that doesn't load (it asks the URL, or looks in public/ and static/ for a local file), a canonical pointing at localhost, titles and descriptions too long or too short, a missing twitter:card, og:image:alt or <html lang>
  • Tags set in code ({post.title}, generateMetadata) are marked as such: /preview <url> reads them rendered

The findings also go to Claude as context, so "fix these" just works. Pages and images are fetched from your machine; nothing is sent anywhere else.

Google publishes no snippet limits: the pixel widths are the ones SEO tools have measured for years. Treat them as a close guide, not a promise.

⏱️ cache-stats

Claude Code resends the whole conversation with every request, and the prompt cache is what keeps that cheap and fast. This shows what the cache is doing, live.

◆ Cache  █████████░ 96% hit · warm 58m · 133k cached · session 1h 12m (23m working) · avg 94% · 38 requests
◆ Cache  ░░░░░░░░░░ 0% hit · cold, next message re-caches 133k · session 2h 05m (31m working)
Install
claude plugin marketplace add krysmalskipl/claude-code-mods   # once per machine
claude plugin install cache-stats@krysmalskipl

Restart Claude Code or type /reload-plugins. The line appears after Claude's first reply. It reads Claude Code's own usage figures and sends nothing anywhere.

Set the cache lifetime

The hit rate is measured, but the warm countdown needs to know how long your cache lives: 1h on a Claude subscription (the default) or 5m on an API key. On an API key, set it in /config → cache-stats, or from a shell:

echo '{"ttl":"5m"}' | claude plugin configure cache-stats@krysmalskipl --values-stdin

Not sure which you have? Look at your session transcript: ephemeral_1h_input_tokens in the usage rows means a 1h cache.

What the line shows
  • Hit rate of the last request: how much of the prompt the cache served. Green from 80%, yellow from 50%, red under
  • Warm 3m: time left before the cache lapses, counted from the last reply. Once it has, the line says what the next message re-caches
  • Cached: tokens in the cache now, the size of the last prompt
  • Session time since the session started, and how much of it the model spent working
  • avg and requests: the hit rate over the whole session and the number of requests to the model

On a narrow terminal the right-hand parts drop out first.

Commands
  • /cache shows the details: state, session totals (read from cache, written, sent uncached, generated), every miss with the reason, and the last requests
  • /cache hide and /cache show take the line away and bring it back

A miss is a large prompt (5,000+ tokens) the cache mostly did not serve. The reason is told by the gap before it: idle longer than the cache lives means it simply expired; soon after the last request means something under the cache changed, such as /model, /compact, an edited CLAUDE.md or a new MCP tool. That second kind is the one worth hunting down, since each costs a full re-cache.

Subagents run on caches of their own and are not counted.

🪙 openrouter-balance

openrouter-balance: OpenRouter key limit and today's spend above the Claude Code prompt

Your OpenRouter credit in one line above the prompt, refreshed every 5 minutes and after each turn.

1. Check your Claude Code version
claude --version

You need 2.1.287 or newer. Older? Update with claude update (or npm install -g @anthropic-ai/claude-code). In the desktop app, type /status in a Code tab session.

2. Add this marketplace

Once per machine, in a terminal:

claude plugin marketplace add krysmalskipl/claude-code-mods

Or inside a Claude Code session: /plugin marketplace add krysmalskipl/claude-code-mods.

3. Install the mod
claude plugin install openrouter-balance@krysmalskipl

Or inside a session: /plugin install openrouter-balance@krysmalskipl.

It ends with "1 userConfig option not yet set". That's the optional key from step 4. If your key is already in your environment or Keychain, ignore it.

4. Give it an OpenRouter key

Pick the kind of key first:

KeyWhat the line showsWhere to make one
Management key (recommended)The account's real balance, today's spend and the model the money mostly went toopenrouter.ai/settings/management-keys. It can't call models, so it's safe to keep around
Regular API keyOnly that key's own spend today and this month, and what its limit leavesopenrouter.ai/settings/keys. The OPENROUTER_API_KEY you already use works

Then put it in one place. The mod looks in this order and takes the first it finds:

  1. /config → openrouter-balance in an interactive claude terminal session, or from a shell:
   echo '{"apiKey":"sk-or-v1-..."}' | claude plugin configure openrouter-balance@krysmalskipl --values-stdin

That line lands in your shell history; the Keychain (3) keeps the key out of it.

  1. An environment variable: OPENROUTER_MANAGEMENT_KEY for a management key, OPENROUTER_API_KEY for a regular one. Add it to ~/.zshrc (or your shell's startup file):
   export OPENROUTER_MANAGEMENT_KEY="sk-or-v1-..."
  1. The macOS Keychain (no key in any file). Paste the key when asked:
   security add-generic-password -a "$USER" -s openrouter-key -w
  1. Your login shell: when Claude Code doesn't see the variables itself (the desktop app started from the Dock never reads ~/.zshrc), the mod asks your shell for OPENROUTER_MANAGEMENT_KEY and OPENROUTER_API_KEY. So a key exported in ~/.zshrc works there too.

The key is kept in memory only and sent only to openrouter.ai.

5. Restart and check

Start a new Claude Code session (or type /reload-plugins in an open one). Within a few seconds a line appears above the prompt:

◆ OpenRouter  █████████▁ $2.67 of $3.00 key limit left · $0.33 today

Type /openrouter-balance for the breakdown: balance, today, yesterday, last 30 days and spend, requests and tokens by model. With a regular key: today, this week, this month, all time, its limit and budget.

Update or remove
claude plugin marketplace update krysmalskipl
claude plugin update openrouter-balance@krysmalskipl

claude plugin uninstall openrouter-balance@krysmalskipl
If something's off
The line saysWhyFix
Nothing at allThe mod isn't loaded yetStart a new session, or /reload-plugins. Check claude plugin list shows it enabled
no key setNo key in any of the four placesStep 4. In the desktop app, the Keychain is the surest place
key rejectedThe key was revoked or disabledMake a new one, put it where the old one was
can't reach OpenRouter yetNo network, or OpenRouter is downIt retries every minute by itself
$0.42 today · $12.30 this month and no balanceA regular key with no limit: it can't see the accountUse a management key for the balance, or set a limit on the key
… · offlineThe last refresh failedThe numbers are the last good ones; it retries every minute
How it counts
  • Green from $5, yellow under $5, red under $2
  • "Today" is the UTC day. With a management key it's the usage now minus where it stood when the mod first saw the day; a day first seen after 00:15 UTC shows "since HH:MM" (UTC)
  • Spend by model comes from OpenRouter's /activity, which holds completed UTC days only

Develop

claude plugin validate mods/<name>
CLAUDE_CONFIG_DIR=$(mktemp -d) claude plugin test mods/<name>

More mods: hamzafer/claude-code-mods.

License

MIT

Source 5 files
hooks/register.tsx 292 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ImageCheck, PageMeta, Preview, PreviewSource } from '../types'
5import { cardValues, checkMeta } from './checks'
6import type { Level } from './checks'
7import { GOOGLE, SOCIAL, cutToChars, cutToWidth } from './measure'
8import { PREVIEWABLE, hasAnyMeta, parseMeta } from './parse'
9
10const PANE = 'share-preview'
11const preview = atom({ plugin: 'share-preview', key: 'preview' } as const, null)
12const lastFile = atom({ plugin: 'share-preview', key: 'lastFile' } as const, null)
13const tab = atom({ plugin: 'share-preview', key: 'tab' } as const, 'google')
14
15const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
16const PUBLIC_DIRS = ['public', 'static', 'assets', '.']
17
18const USAGE = [
19  'Usage: /preview [url | file]',
20  '  /preview                       the page Claude edited last',
21  '  /preview src/app/page.tsx      a page, template or Markdown file',
22  '  /preview localhost:3000/about  the rendered page (most accurate)',
23  '  /preview https://example.com   a live page',
24].join('\n')
25
26const isUrlLike = (target: string) => /^(https?:\/\/|localhost\b|127\.0\.0\.1\b|\[::1\])/i.test(target)
27
28function hostAndPath(url: string | undefined): { host?: string; path?: string } {
29  if (!url) return {}
30  try {
31    const parsed = new URL(url.startsWith('//') ? `https:${url}` : url)
32    return { host: parsed.host, path: parsed.pathname === '/' ? '' : parsed.pathname }
33  } catch {
34    return {}
35  }
36}
37
38async function checkRemoteImage($: EngineInterface, url: string): Promise<ImageCheck> {
39  try {
40    let response = await $.http.fetch(url, { method: 'HEAD' })
41    if (response.status === 405 || response.status === 403) response = await $.http.fetch(url)
42    if (!response.ok) return { status: 'broken', note: `og:image answers HTTP ${response.status}: shares show no picture` }
43    const type = response.headers['content-type'] ?? ''
44    if (type && !type.startsWith('image/')) return { status: 'broken', note: `og:image is ${type.split(';')[0]}, not an image` }
45    const bytes = Number(response.headers['content-length'] ?? 0)
46    if (bytes > 5_000_000) return { status: 'broken', note: `og:image is ${(bytes / 1e6).toFixed(1)} MB: X and LinkedIn skip images over 5 MB` }
47    return { status: 'ok', note: `og:image loads${type ? ` (${type.split(';')[0]}${bytes ? `, ${Math.round(bytes / 1000)} kB` : ''})` : ''}` }
48  } catch {
49    return { status: 'unchecked', note: 'Could not reach the og:image to check it' }
50  }
51}
52
53async function checkImage($: EngineInterface, meta: PageMeta, source: PreviewSource): Promise<ImageCheck> {
54  const image = cardValues(meta).image
55  if (!image) return { status: 'missing', note: '' }
56  if (/^https?:\/\//i.test(image)) return checkRemoteImage($, image)
57  if (image.startsWith('//')) return checkRemoteImage($, `https:${image}`)
58  if (source.kind === 'url') return checkRemoteImage($, new URL(image, source.target).href)
59  // A relative image in a source file: look for it where frameworks serve static files from.
60  const relative = image.replace(/^\.?\//, '')
61  for (const dir of PUBLIC_DIRS) {
62    const path = dir === '.' ? relative : `${dir}/${relative}`
63    if (await $.fs.exists(path).catch(() => false)) return { status: 'unchecked', note: `og:image file found at ${path}` }
64  }
65  return { status: 'broken', note: `og:image "${image}" is not in ${PUBLIC_DIRS.filter(d => d !== '.').join('/, ')}/ or the project root` }
66}
67
68async function load($: EngineInterface, raw: string): Promise<Preview> {
69  const at = await $.clock.now()
70  const isUrl = isUrlLike(raw)
71  const source: PreviewSource = {
72    kind: isUrl ? 'url' : 'file',
73    target: isUrl && !/^https?:\/\//i.test(raw) ? `http://${raw}` : raw,
74  }
75  let text: string
76  try {
77    if (source.kind === 'url') {
78      const response = await $.http.fetch(source.target, { headers: { 'user-agent': 'Mozilla/5.0 (compatible; share-preview; +https://github.com/krysmalskipl/claude-code-mods)' } })
79      if (!response.ok) throw new Error(`HTTP ${response.status}`)
80      text = response.text
81    } else {
82      text = await $.fs.read(source.target)
83    }
84  } catch (error) {
85    const reason = error instanceof Error ? error.message : String(error)
86    return { source, meta: { dynamic: [] }, image: { status: 'unchecked', note: '' }, hasHtml: false, error: `Could not read ${source.target}: ${reason}`, at }
87  }
88
89  const meta = parseMeta(text)
90  if (source.kind === 'url') {
91    // Relative tags resolve against the page, as a crawler resolves them.
92    for (const key of ['ogImage', 'twitterImage', 'canonical', 'ogUrl'] as const) {
93      const value = meta[key]
94      if (value && !/^https?:\/\//i.test(value)) {
95        try {
96          meta[key] = new URL(value, source.target).href
97        } catch {}
98      }
99    }
100  }
101  const image = await checkImage($, meta, source)
102  const where = hostAndPath(meta.canonical ?? meta.ogUrl ?? (source.kind === 'url' ? source.target : undefined))
103  const empty = hasAnyMeta(meta) ? undefined : `No title, meta or Open Graph tags in ${source.target}`
104  return { source, meta, image, ...where, hasHtml: source.kind === 'url' || /<html\b/i.test(text), error: empty, at }
105}
106
107async function show($: EngineInterface, target: string) {
108  const result = await load($, target)
109  await update($, preview, () => result)
110  return result
111}
112
113const ICON: Record<Level, string> = { error: '✗', warn: '!', info: '·', ok: '✓' }
114const TONE: Record<Level, string> = { error: 'error', warn: 'warning', info: 'inactive', ok: 'success' }
115// Google's own result colors, readable on light and dark themes alike.
116const LINK = '#4c8bf5'
117
118export const register: Register = on => {
119  on('session.start', async ($, e, next) => {
120    await $.command.register({
121      name: 'preview',
122      description: 'Preview the page in Google and as a shared link on X, LinkedIn, Slack and Facebook',
123      argumentHint: '[url | file]',
124    })
125    return next(e)
126  })
127
128  on('command.run', { command: 'preview' }, async ($, e) => {
129    const arg = e.args.trim()
130    if (arg === 'help') return { text: USAGE }
131    const target = arg || (await read($, lastFile))
132    if (!target) return { text: `Nothing to preview yet: no page was edited in this session.\n${USAGE}` }
133    const result = await show($, target)
134    await $.ui.open({ id: PANE, title: 'Share preview', focus: true })
135    if (result.error) return { text: result.error }
136    const issues = checkMeta(result.meta, result.image, result.hasHtml)
137    const errors = issues.filter(issue => issue.level === 'error').length
138    const warnings = issues.filter(issue => issue.level === 'warn').length
139    return {
140      text: `Share preview of ${target}: ${errors} errors, ${warnings} warnings.`,
141      context: [
142        `share-preview found in ${target}:\n${issues
143          .filter(issue => issue.level !== 'ok')
144          .map(issue => `- [${issue.level}] ${issue.text}`)
145          .join('\n')}`,
146      ],
147    }
148  })
149
150  // Follow the page as Claude works on it: remember the last page edited, and
151  // refresh an open preview of that file, or of a dev server, after the edit.
152  on('tool.call', async ($, e, next) => {
153    const ran = await next(e)
154    if (!EDIT_TOOLS.has(String(e.tool)) || ran.isError) return ran
155    const input = e as { file_path?: string; notebook_path?: string }
156    const path = input.file_path ?? input.notebook_path
157    if (!path || !PREVIEWABLE.test(path)) return ran
158    await update($, lastFile, () => path)
159    const current = await read($, preview)
160    if (current && current.source.kind === 'file' && current.source.target === path) await show($, path)
161    return ran
162  }).catch(($, e, next) => next(e)) // never let a preview refresh get in the way of a tool
163
164  on('turn.complete', async ($, e, next) => {
165    const current = await read($, preview)
166    if (current?.source.kind === 'url' && /\/\/(localhost|127\.|\[::1\])/.test(current.source.target)) {
167      await show($, current.source.target)
168    }
169    return next(e)
170  })
171
172  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
173    const { Box, Text, Button } = $.ui.resolve(e)
174    const current = await read($, preview)
175    const view = await read($, tab)
176    const width = Math.max(30, Math.min(e.props.bodyColumns ?? 80, 100))
177
178    if (!current) return <Text dimColor>{USAGE}</Text>
179    if (current.error && !hasAnyMeta(current.meta)) {
180      return (
181        <Box flexDirection="column" gap={1}>
182          <Text color="error">{current.error}</Text>
183          <Text dimColor>{USAGE}</Text>
184        </Box>
185      )
186    }
187
188    const { meta } = current
189    const card = cardValues(meta)
190    const host = current.host ?? 'your-site.com'
191    const crumbs = [host, ...(current.path ?? '').split('/').filter(Boolean)].join(' › ')
192    const site = meta.siteName ?? host.replace(/^www\./, '')
193    const issues = checkMeta(meta, current.image, current.hasHtml)
194    const placeholder = (field: string, fallback: string) => (meta.dynamic.includes(field) ? `{${field} set in code}` : fallback)
195
196    const title = meta.title ?? placeholder('title', 'Untitled page')
197    const desktopTitle = cutToWidth(title, GOOGLE.titleFont, GOOGLE.titlePx)
198    const description = meta.description ?? placeholder('description', 'Google picks a sentence from the page when there is no meta description.')
199    const desktopDesc = cutToWidth(description, GOOGLE.descFont, GOOGLE.descPxDesktop)
200    const mobileDesc = cutToWidth(description, GOOGLE.descFont, GOOGLE.descPxMobile)
201
202    const tabs = (
203      <Box gap={1}>
204        <Button key="google" hotkey="g" variant={view === 'google' ? 'primary' : 'secondary'} label="Google" onPress={() => update($, tab, () => 'google')} />
205        <Button key="social" hotkey="s" variant={view === 'social' ? 'primary' : 'secondary'} label="Shared link" onPress={() => update($, tab, () => 'social')} />
206        <Button key="refresh" hotkey="r" label="Refresh" onPress={() => show($, current.source.target)} />
207      </Box>
208    )
209
210    const google = (
211      <Box flexDirection="column" gap={1}>
212        <Text dimColor>DESKTOP</Text>
213        <Box flexDirection="column" width={width - 2}>
214          <Text bold>{site}</Text>
215          <Text dimColor wrap="truncate-end">{crumbs}</Text>
216          <Text color={LINK}>{desktopTitle.text}</Text>
217          <Text>{desktopDesc.text}</Text>
218        </Box>
219        <Text dimColor>MOBILE</Text>
220        <Box flexDirection="column" width={Math.min(width - 2, 46)} borderStyle="round" borderDimColor paddingX={1}>
221          <Text bold>{site}</Text>
222          <Text dimColor wrap="truncate-end">{crumbs}</Text>
223          <Text color={LINK}>{title}</Text>
224          <Text>{mobileDesc.text}</Text>
225        </Box>
226      </Box>
227    )
228
229    const shareTitle = cutToChars(card.title ?? title, SOCIAL.titleChars).text
230    const shareDesc = card.description ? cutToChars(card.description, SOCIAL.descChars).text : undefined
231    const imageLine = card.image
232      ? current.image.status === 'broken'
233        ? '[ image fails to load ]'
234        : `[ ${card.image.split('/').pop()} ]`
235      : '[ no image ]'
236    const isLarge = card.xCard === 'summary_large_image'
237
238    const social = (
239      <Box flexDirection="column" gap={1}>
240        <Text dimColor>LINKEDIN · FACEBOOK</Text>
241        <Box flexDirection="column" width={Math.min(width - 2, 60)} borderStyle="round" borderDimColor>
242          <Box height={3} justifyContent="center" alignItems="center">
243            <Text dimColor>{imageLine}</Text>
244          </Box>
245          <Box flexDirection="column" paddingX={1}>
246            <Text dimColor>{site.toUpperCase()}</Text>
247            <Text bold>{shareTitle}</Text>
248          </Box>
249        </Box>
250        <Text dimColor>SLACK</Text>
251        <Box flexDirection="column" width={Math.min(width - 2, 60)} borderStyle="bold" borderDimColor paddingLeft={1}>
252          <Text bold>{site}</Text>
253          <Text color={LINK}>{shareTitle}</Text>
254          {shareDesc && <Text>{shareDesc}</Text>}
255          <Text dimColor>{imageLine}</Text>
256        </Box>
257        <Text dimColor>X {isLarge ? '(large card)' : card.xCard ? '(small card)' : '(no card: plain link)'}</Text>
258        {card.xCard ? (
259          <Box flexDirection={isLarge ? 'column' : 'row'} width={Math.min(width - 2, 60)} borderStyle="round" borderDimColor>
260            <Box height={isLarge ? 3 : 2} width={isLarge ? undefined : 14} justifyContent="center" alignItems="center">
261              <Text dimColor>{imageLine}</Text>
262            </Box>
263            <Box flexDirection="column" paddingX={1}>
264              <Text dimColor>{host}</Text>
265              <Text>{cutToChars(card.xTitle ?? title, SOCIAL.titleChars).text}</Text>
266            </Box>
267          </Box>
268        ) : (
269          <Text color={LINK}>{`https://${host}${current.path ?? ''}`}</Text>
270        )}
271      </Box>
272    )
273
274    return (
275      <Box flexDirection="column" gap={1}>
276        {tabs}
277        <Text dimColor wrap="truncate-middle">
278          {current.source.kind === 'url' ? 'Rendered page' : 'Source'}: {current.source.target}
279        </Text>
280        {view === 'google' ? google : social}
281        <Box flexDirection="column">
282          {issues.map(issue => (
283            <Text color={TONE[issue.level]}>
284              {ICON[issue.level]} {issue.text}
285            </Text>
286          ))}
287        </Box>
288      </Box>
289    )
290  })
291}
292
hooks/checks.ts 74 lines
1import type { ImageCheck, PageMeta } from '../types'
2import { GOOGLE, SOCIAL, textWidth } from './measure'
3
4export type Level = 'error' | 'warn' | 'ok' | 'info'
5export type Issue = { level: Level; text: string }
6
7/** What each platform falls back to when its own tag is missing. */
8export function cardValues(meta: PageMeta) {
9  return {
10    title: meta.ogTitle ?? meta.title,
11    description: meta.ogDescription ?? meta.description,
12    image: meta.ogImage ?? meta.twitterImage,
13    xTitle: meta.twitterTitle ?? meta.ogTitle ?? meta.title,
14    xDescription: meta.twitterDescription ?? meta.ogDescription ?? meta.description,
15    xImage: meta.twitterImage ?? meta.ogImage,
16    xCard: meta.twitterCard ?? (meta.twitterImage || meta.ogImage ? 'summary' : undefined),
17  }
18}
19
20const isLocal = (url: string) => /^https?:\/\/(localhost|127\.|0\.0\.0\.0|\[::1\]|[^/]+\.(local|test))(?:[:/]|$)/i.test(url)
21const isAbsolute = (url: string) => /^https?:\/\//i.test(url)
22
23/** The problems that would cost the page clicks, worst first, then what is fine. */
24export function checkMeta(meta: PageMeta, image: ImageCheck, hasHtmlTag = true): Issue[] {
25  const issues: Issue[] = []
26  const add = (level: Level, text: string) => issues.push({ level, text })
27  const dynamic = (field: string) => meta.dynamic.includes(field)
28
29  if (meta.robots && /noindex/i.test(meta.robots)) add('error', `robots says "${meta.robots}": Google won't show this page at all`)
30
31  if (meta.title) {
32    const px = textWidth(meta.title, GOOGLE.titleFont)
33    if (px > GOOGLE.titlePx) add('warn', `Title is ${px}px wide, Google shows about ${GOOGLE.titlePx}px: the end is cut`)
34    else if ([...meta.title].length < 15) add('warn', `Title is only ${[...meta.title].length} characters: room for the keyword and a reason to click`)
35    else add('ok', `Title fits Google (${px}/${GOOGLE.titlePx}px)`)
36  } else if (!dynamic('title')) add('error', 'No <title>: Google makes one up from the page')
37
38  if (meta.description) {
39    const px = textWidth(meta.description, GOOGLE.descFont)
40    if (px > GOOGLE.descPxDesktop) add('warn', `Description is cut on desktop (${px}/${GOOGLE.descPxDesktop}px)`)
41    else if (px > GOOGLE.descPxMobile) add('info', `Description fits desktop, mobile cuts it after about ${GOOGLE.descPxMobile}px`)
42    else add('ok', `Description fits desktop and mobile (${px}px)`)
43    if ([...meta.description].length < 50) add('warn', 'Description is under 50 characters: Google often swaps in its own text')
44    if (meta.title && meta.description.toLowerCase() === meta.title.toLowerCase()) add('warn', 'Description repeats the title word for word')
45  } else if (!dynamic('description')) add('warn', 'No meta description: Google picks a sentence from the page')
46
47  const card = cardValues(meta)
48  if (!card.image && !dynamic('ogImage')) add('error', 'No og:image: shares on LinkedIn, Facebook, Slack and X show no picture')
49  else if (card.image && !isAbsolute(card.image) && !card.image.startsWith('//')) add('error', `og:image "${card.image}" is relative: platforms need a full https:// URL`)
50  else if (card.image && card.image.startsWith('http://')) add('warn', 'og:image is http://: some platforms only load https images')
51  if (card.image && isLocal(card.image)) add('error', 'og:image points at a local address: nobody outside your machine can load it')
52  if (image.status === 'broken') add('error', image.note)
53  else if (image.status === 'ok') add('ok', image.note)
54  else if (image.status === 'unchecked' && image.note) add('info', image.note)
55  if (card.image && !meta.ogImageAlt) add('info', 'No og:image:alt: screen readers on X and LinkedIn get nothing')
56
57  if (!meta.ogTitle && !dynamic('ogTitle') && meta.title) add('info', 'No og:title: shares use the <title>')
58  if (card.title && [...card.title].length > SOCIAL.titleChars) add('warn', `Share title is ${[...card.title].length} characters: cards cut it after about ${SOCIAL.titleChars}`)
59
60  if (!meta.twitterCard && card.xImage) add('warn', 'No twitter:card: X shows a small thumbnail, add summary_large_image for the big card')
61
62  if (meta.canonical) {
63    if (isLocal(meta.canonical)) add('error', `canonical is ${meta.canonical}: Google is told the real page is on your machine`)
64    else if (!isAbsolute(meta.canonical)) add('warn', 'canonical is relative: use the full URL')
65  } else if (!dynamic('canonical')) add('info', 'No canonical: fine for one URL, needed once the page has variants (?utm, trailing /)')
66
67  if (hasHtmlTag && !meta.lang && !dynamic('lang')) add('info', 'No <html lang>: search engines and screen readers guess the language')
68
69  if (meta.dynamic.length > 0) add('info', `Set in code (${meta.dynamic.join(', ')}): /preview <url> reads the rendered page`)
70
71  const order: Record<Level, number> = { error: 0, warn: 1, info: 2, ok: 3 }
72  return issues.sort((a, b) => order[a.level] - order[b.level])
73}
74
hooks/measure.ts 88 lines
1// Google cuts snippets by rendered width, not by characters: "WWW" runs out of
2// room long before "iii". Widths below are Arial's (Google's snippet font), in
3// thousandths of the font size, so a title is measured as Google draws it.
4
5const ARIAL: Record<string, number> = {
6  ' ': 278, '!': 278, '"': 355, '#': 556, $: 556, '%': 889, '&': 667, "'": 191,
7  '(': 333, ')': 333, '*': 389, '+': 584, ',': 278, '-': 333, '.': 278, '/': 278,
8  ':': 278, ';': 278, '<': 584, '=': 584, '>': 584, '?': 556, '@': 1015,
9  A: 667, B: 667, C: 722, D: 722, E: 667, F: 611, G: 778, H: 722, I: 278,
10  J: 500, K: 667, L: 556, M: 833, N: 722, O: 778, P: 667, Q: 778, R: 722,
11  S: 667, T: 611, U: 722, V: 667, W: 944, X: 667, Y: 667, Z: 611,
12  '[': 278, '\\': 278, ']': 278, '^': 469, _: 556, '`': 333,
13  a: 556, b: 556, c: 500, d: 556, e: 556, f: 278, g: 556, h: 556, i: 222,
14  j: 222, k: 500, l: 222, m: 833, n: 556, o: 556, p: 556, q: 556, r: 333,
15  s: 500, t: 278, u: 556, v: 500, w: 722, x: 500, y: 500, z: 500,
16  '{': 334, '|': 260, '}': 334, '~': 584,
17  '–': 556, '—': 1000, '…': 1000, '·': 278, '•': 350, '’': 222, '‘': 222,
18  '“': 333, '”': 333, '«': 556, '»': 556, '€': 556, '£': 556,
19}
20
21function charWidth(char: string): number {
22  if (char >= '0' && char <= '9') return 556
23  const known = ARIAL[char]
24  if (known !== undefined) return known
25  // Accented Latin letters are as wide as their base letter (ą, é, ñ, ü).
26  const base = char.normalize('NFD')[0] ?? char
27  const baseWidth = ARIAL[base]
28  if (base !== char && baseWidth !== undefined) return baseWidth
29  if (char === 'ł') return 222
30  if (char === 'Ł') return 556
31  // CJK, emoji and the rest take about a full em.
32  return (char.codePointAt(0) ?? 0) > 0x2e80 ? 1000 : 556
33}
34
35/** The rendered width of `text` in CSS pixels at `fontPx`. */
36export function textWidth(text: string, fontPx: number): number {
37  let total = 0
38  for (const char of text) total += charWidth(char)
39  return Math.round((total * fontPx) / 1000)
40}
41
42/**
43 * Snippet limits. Google publishes none: these are the widths its result
44 * column has drawn for years, measured by the SEO tools that check them.
45 */
46export const GOOGLE = {
47  titleFont: 20,
48  titlePx: 600,
49  descFont: 14,
50  descPxDesktop: 920,
51  descPxMobile: 680,
52} as const
53
54/** Social cards cut by characters; these are the lengths that show whole on each. */
55export const SOCIAL = {
56  titleChars: 70,
57  descChars: 160,
58} as const
59
60export type Cut = { text: string; isCut: boolean; width: number }
61
62/** `text` as shown in at most `maxPx`: cut at a word where it can be, with " ..." as Google ends it. */
63export function cutToWidth(text: string, fontPx: number, maxPx: number): Cut {
64  const width = textWidth(text, fontPx)
65  if (width <= maxPx) return { text, isCut: false, width }
66  const room = maxPx - textWidth(' ...', fontPx)
67  let used = 0
68  let end = 0
69  for (const char of text) {
70    used += (charWidth(char) * fontPx) / 1000
71    if (used > room) break
72    end += char.length
73  }
74  const head = text.slice(0, end)
75  const space = head.lastIndexOf(' ')
76  const kept = (space > head.length * 0.6 ? head.slice(0, space) : head).replace(/[\s,.;:–-]+$/, '')
77  return { text: `${kept} ...`, isCut: true, width }
78}
79
80/** `text` cut at `max` characters, at a word where it can be. */
81export function cutToChars(text: string, max: number): { text: string; isCut: boolean } {
82  const chars = [...text]
83  if (chars.length <= max) return { text, isCut: false }
84  const head = chars.slice(0, max - 1).join('')
85  const space = head.lastIndexOf(' ')
86  return { text: `${(space > head.length * 0.6 ? head.slice(0, space) : head).trimEnd()}…`, isCut: true }
87}
88
hooks/parse.ts 222 lines
1import type { PageMeta } from '../types'
2
3// Reads the tags a link preview is built from out of a page's source: rendered
4// HTML, a template (Astro, Vue, Svelte, JSX with <Head>), a Next.js `metadata`
5// export, or Markdown frontmatter. No DOM here, so it is regular expressions
6// over the text, which is enough for tags and keeps the mod dependency-free.
7
8/** Files whose source can carry a page's tags. */
9export const PREVIEWABLE = /\.(html?|astro|vue|svelte|jsx|tsx|js|ts|mdx?|php|erb|hbs|liquid|njk|twig)$/i
10
11const META_KEYS: Record<string, keyof PageMeta> = {
12  description: 'description',
13  robots: 'robots',
14  'og:title': 'ogTitle',
15  'og:description': 'ogDescription',
16  'og:image': 'ogImage',
17  'og:image:url': 'ogImage',
18  'og:image:secure_url': 'ogImage',
19  'og:image:alt': 'ogImageAlt',
20  'og:url': 'ogUrl',
21  'og:site_name': 'siteName',
22  'twitter:card': 'twitterCard',
23  'twitter:title': 'twitterTitle',
24  'twitter:description': 'twitterDescription',
25  'twitter:image': 'twitterImage',
26  'twitter:image:src': 'twitterImage',
27}
28
29type Field = Exclude<keyof PageMeta, 'dynamic'>
30
31export function decodeEntities(text: string): string {
32  return text
33    .replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
34    .replace(/&#(\d+);/g, (_, dec: string) => String.fromCodePoint(Number(dec)))
35    .replace(/&quot;/g, '"')
36    .replace(/&apos;|&#39;/g, "'")
37    .replace(/&lt;/g, '<')
38    .replace(/&gt;/g, '>')
39    .replace(/&nbsp;/g, ' ')
40    .replace(/&ndash;/g, '–')
41    .replace(/&mdash;/g, '—')
42    .replace(/&hellip;/g, '…')
43    .replace(/&amp;/g, '&')
44}
45
46const clean = (text: string) => decodeEntities(text).replace(/\s+/g, ' ').trim()
47
48/** An attribute value: quoted text, or `{...}` (JSX, Astro, Svelte), text only when it is a plain string literal. */
49type AttrValue = { text: string } | { dynamic: true }
50
51function readAttrs(tag: string): Record<string, AttrValue> {
52  const attrs: Record<string, AttrValue> = {}
53  const pattern = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|\{([^}]*)\})/g
54  for (const match of tag.matchAll(pattern)) {
55    const name = match[1]!.toLowerCase()
56    if (match[4] !== undefined) {
57      const literal = /^\s*(["'`])([^"'`$]*)\1\s*$/.exec(match[4])
58      attrs[name] = literal ? { text: literal[2]! } : { dynamic: true }
59    } else {
60      attrs[name] = { text: match[2] ?? match[3] ?? '' }
61    }
62  }
63  return attrs
64}
65
66/** A template's own syntax inside text: `{x}`, `{{ x }}`, `${x}`, `<?= ?>`, `<%= %>`. */
67const isTemplated = (text: string) => /\{|\$\{|<\?|<%/.test(text)
68
69function set(meta: PageMeta, field: Field, value: AttrValue | undefined) {
70  if (!value || meta[field] !== undefined || meta.dynamic.includes(field)) return
71  if ('dynamic' in value || isTemplated(value.text)) {
72    meta.dynamic.push(field)
73    return
74  }
75  const text = clean(value.text)
76  if (text !== '') meta[field] = text
77}
78
79function fromMarkup(source: string, meta: PageMeta) {
80  const title = /<title\b[^>]*>([\s\S]*?)<\/title>/i.exec(source)
81  if (title) set(meta, 'title', { text: title[1]! })
82
83  for (const match of source.matchAll(/<meta\b([^>]*?)\/?>/gi)) {
84    const attrs = readAttrs(match[1]!)
85    const key = attrs.property ?? attrs.name
86    if (!key || 'dynamic' in key) continue
87    const field = META_KEYS[key.text.trim().toLowerCase()]
88    if (field) set(meta, field as Field, attrs.content)
89  }
90
91  for (const match of source.matchAll(/<link\b([^>]*?)\/?>/gi)) {
92    const attrs = readAttrs(match[1]!)
93    if (attrs.rel && 'text' in attrs.rel && attrs.rel.text.toLowerCase() === 'canonical') {
94      set(meta, 'canonical', attrs.href)
95    }
96  }
97
98  const html = /<html\b([^>]*)>/i.exec(source)
99  if (html) set(meta, 'lang', readAttrs(html[1]!).lang)
100}
101
102/** The text between a `{` and its matching `}`, starting at the brace. */
103function block(source: string, open: number): string {
104  let depth = 0
105  for (let i = open; i < source.length; i++) {
106    if (source[i] === '{' || source[i] === '[') depth++
107    else if (source[i] === '}' || source[i] === ']') {
108      depth--
109      if (depth === 0) return source.slice(open, i + 1)
110    }
111  }
112  return source.slice(open)
113}
114
115/** The block a key opens (`openGraph: {`), or '' when the key is absent. */
116function keyBlock(source: string, key: string): string {
117  const match = new RegExp(`\\b${key}\\s*:\\s*([\\[{])`).exec(source)
118  return match ? block(source, match.index + match[0].length - 1) : ''
119}
120
121/** A key's string value in a JS object literal; dynamic when it is a template with `${}` or an expression. */
122function keyString(source: string, key: string): AttrValue | undefined {
123  const match = new RegExp(`\\b${key}\\s*:\\s*(?:(["'\`])((?:\\\\.|(?!\\1)[^\\\\])*)\\1|([^,}\\n]+))`).exec(source)
124  if (!match) return undefined
125  if (match[3] !== undefined) return { dynamic: true }
126  if (match[1]! === '`' && match[2]!.includes('${')) return { dynamic: true }
127  return { text: match[2]!.replace(/\\(.)/g, '$1') }
128}
129
130/** The first image URL of an `images:` value: a string, a list of strings or a list of `{ url }`. */
131function imagesValue(source: string): AttrValue | undefined {
132  const direct = keyString(source, 'images')
133  if (direct && 'text' in direct) return direct
134  const list = keyBlock(source, 'images')
135  if (!list) return direct
136  const url = keyString(list, 'url')
137  if (url) return url
138  const first = /(["'`])([^"'`]+)\1/.exec(list)
139  return first ? { text: first[2]! } : { dynamic: true }
140}
141
142function fromNextMetadata(source: string, meta: PageMeta) {
143  if (/export\s+(async\s+)?function\s+generateMetadata/.test(source)) {
144    meta.dynamic.push('title', 'description', 'ogImage')
145    return
146  }
147  const start = /export\s+const\s+metadata\b[^=]*=\s*\{/.exec(source)
148  if (!start) return
149  const metadata = block(source, start.index + start[0].length - 1)
150  const og = keyBlock(metadata, 'openGraph')
151  const twitter = keyBlock(metadata, 'twitter')
152  // What is left of the top level once the nested groups are taken out.
153  const top = [og, twitter, keyBlock(metadata, 'alternates')].reduce(
154    (rest, part) => (part ? rest.replace(part, '{}') : rest),
155    metadata,
156  )
157
158  const titleGroup = keyBlock(top, 'title')
159  set(meta, 'title', titleGroup ? (keyString(titleGroup, 'absolute') ?? keyString(titleGroup, 'default')) : keyString(top, 'title'))
160  set(meta, 'description', keyString(top, 'description'))
161  set(meta, 'canonical', keyString(keyBlock(metadata, 'alternates'), 'canonical'))
162  set(meta, 'robots', keyString(top, 'robots'))
163  if (og) {
164    set(meta, 'ogTitle', keyString(og, 'title'))
165    set(meta, 'ogDescription', keyString(og, 'description'))
166    set(meta, 'siteName', keyString(og, 'siteName'))
167    set(meta, 'ogUrl', keyString(og, 'url'))
168    set(meta, 'ogImage', imagesValue(og))
169  }
170  if (twitter) {
171    set(meta, 'twitterCard', keyString(twitter, 'card'))
172    set(meta, 'twitterTitle', keyString(twitter, 'title'))
173    set(meta, 'twitterDescription', keyString(twitter, 'description'))
174    set(meta, 'twitterImage', imagesValue(twitter))
175  }
176  const base = /metadataBase\s*:\s*new\s+URL\(\s*(["'`])([^"'`]+)\1/.exec(metadata)
177  if (base && !meta.ogUrl && !meta.canonical) meta.ogUrl = base[2]
178}
179
180const FRONTMATTER_KEYS: Record<string, Field> = {
181  title: 'title',
182  description: 'description',
183  summary: 'description',
184  excerpt: 'description',
185  image: 'ogImage',
186  cover: 'ogImage',
187  ogimage: 'ogImage',
188  og_image: 'ogImage',
189  heroimage: 'ogImage',
190  canonical: 'canonical',
191  canonicalurl: 'canonical',
192  canonical_url: 'canonical',
193  lang: 'lang',
194}
195
196function fromFrontmatter(source: string, meta: PageMeta) {
197  const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source)
198  if (!front) return
199  for (const line of front[1]!.split(/\r?\n/)) {
200    const match = /^([\w-]+)\s*:\s*(.+)$/.exec(line)
201    if (!match) continue
202    const field = FRONTMATTER_KEYS[match[1]!.toLowerCase()]
203    const value = match[2]!.trim().replace(/^(["'])(.*)\1$/, '$2')
204    if (field && value !== '' && !value.startsWith('{') && !value.startsWith('[')) set(meta, field, { text: value })
205  }
206}
207
208/** Every tag the source sets, read in order of trust: markup first, then a Next.js export, then frontmatter. */
209export function parseMeta(source: string): PageMeta {
210  const meta: PageMeta = { dynamic: [] }
211  fromMarkup(source, meta)
212  fromNextMetadata(source, meta)
213  fromFrontmatter(source, meta)
214  // A tag found later as text is no longer only dynamic.
215  meta.dynamic = [...new Set(meta.dynamic)].filter(field => meta[field as Field] === undefined)
216  return meta
217}
218
219/** Whether a source sets anything a preview can show. */
220export const hasAnyMeta = (meta: PageMeta) =>
221  meta.dynamic.length > 0 || Object.keys(meta).some(key => key !== 'dynamic')
222
types/index.d.ts 53 lines
1/** Where a preview's tags were read from. */
2export type PreviewSource = { kind: 'file' | 'url'; target: string }
3
4/** The tags a link preview is built from; a value that is code, not text, is marked dynamic. */
5export type PageMeta = {
6  title?: string
7  description?: string
8  canonical?: string
9  lang?: string
10  robots?: string
11  siteName?: string
12  ogTitle?: string
13  ogDescription?: string
14  ogImage?: string
15  ogImageAlt?: string
16  ogUrl?: string
17  twitterCard?: string
18  twitterTitle?: string
19  twitterDescription?: string
20  twitterImage?: string
21  /** Tags whose value is an expression, so only the rendered page knows it. */
22  dynamic: string[]
23}
24
25/** What became of the og:image when the mod looked for it. */
26export type ImageCheck = {
27  status: 'ok' | 'missing' | 'broken' | 'unchecked'
28  note: string
29}
30
31export type Preview = {
32  source: PreviewSource
33  meta: PageMeta
34  image: ImageCheck
35  /** The host shown in the snippet, from canonical, og:url or the URL itself. */
36  host?: string
37  path?: string
38  /** Whether the source has an <html> tag, so a missing lang is worth saying. */
39  hasHtml: boolean
40  error?: string
41  at: number
42}
43
44declare module 'claude-code' {
45  interface PluginState {
46    'share-preview': {
47      preview: Preview | null
48      lastFile: string | null
49      tab: 'google' | 'social'
50    }
51  }
52}
53