@forsvn/metaprev 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,83 @@
1
+ # Changelog
2
+
3
+ ## 0.5.0 — 2026-08-23
4
+
5
+ ### Added
6
+ - Responsive local repair workspace with separate Open Graph and X input resolution, representative platform cards, source-fallback labels, and cover-versus-fit crop inspection.
7
+ - Actionable issue objects with stable `code`, `impact`, `evidence`, and `fix` fields. Existing `level`, `field`, and `message` JSON keys remain compatible.
8
+ - Reviewable, copy-ready metadata starting point, repair brief, and guarded coding-agent prompt. Missing facts remain explicit comments instead of invented values.
9
+ - `og:type`, `og:image:alt`, and `twitter:image:alt` parsing, plus validation for required Open Graph fields and image alternative text.
10
+ - Focused local HTTP, CLI exit-code, fallback, render, crop, and injection regression coverage.
11
+
12
+ ### Changed
13
+ - Removed generic title and description length advice. metaprev now reports observed breakage and compatibility risk instead of encouraging padded copy.
14
+ - Image guidance now cites first-party Open Graph and LinkedIn requirements. File-size warnings use LinkedIn's documented 5 MB sharing-module limit.
15
+ - A distinct `twitter:image` is fetched only for the interactive preview. Scoped and JSON commands keep the single-image, non-base64 path.
16
+ - Full-preview `--json` now follows the CI exit contract (`1` when the report contains errors), and JSON paths flush stdout before returning so large reports stay complete.
17
+ - Non-success and explicitly non-HTML page responses are runtime failures (exit `2`) instead of producing misleading missing-tag reports.
18
+ - Platform cards are labeled as representative because experiments, viewport differences, and caches can change the final unfurl.
19
+
20
+ ### Security
21
+ - Generated reports use a per-report Content Security Policy nonce, escape all page-controlled values, admit only strict raster data URIs into image styles, and never embed SVG.
22
+ - Image probes reject non-HTTP(S) protocols before fetching. Repair snippets leave localhost and private-network URLs as review comments instead of presenting them as public metadata.
23
+ - Multi-image parsing keeps structured width, height, and alt evidence attached to the selected first `og:image`.
24
+
25
+ ## 0.4.1 — 2026-05-29
26
+
27
+ ### Changed
28
+ - Upgraded `image-size` to v2 (ESM, smaller); the named `imageSize(buf)` import is unchanged.
29
+ - Bundled agent skill (`skills/metaprev/`) updated for 0.4.0 — light/dark toggle, content-type / SVG checks, 2026-accurate platform mocks.
30
+
31
+ ### Docs
32
+ - Rewrote the README around the tool's value propositions ("What you get"), with a numbered quick-start, a Troubleshooting section (bun-missing, self-signed TLS, timeouts), and a Contributing & release section.
33
+
34
+ ## 0.4.0 — 2026-05-29
35
+
36
+ ### Added
37
+ - **Light / dark preview toggle** in the HTML report — see how the card renders in each platform's actual light *and* dark theme (Facebook, X, LinkedIn, Discord).
38
+ - `og:image` content-type checks: warns when the image is an **SVG** (Facebook, X, and LinkedIn don't render SVG share images) and errors when a **non-image** response also fails to decode as an image (catches an `og:image` pointing at an HTML/error page that returns 200; a real image served as `application/octet-stream` still passes).
39
+ - "Copy" on the parsed-meta panel, alongside the existing issues copy.
40
+ - Test suite (`bun test`) covering parsing, validation, and rendering.
41
+
42
+ ### Fixed
43
+ - **Entity decoding** now handles numeric (`’`), hex (`❤`), and common named (`—`, `…`) references. Char-count validation was previously inflated by raw entities (e.g. `Ben & Jerry’s` measured 19 chars instead of 13) and cards/facts showed raw entity text.
44
+ - **Double-escaped** values in the parsed-meta panel (`&`) now render correctly.
45
+ - **Hardened the preview against CSS injection** — only the validated, locally-fetched image data URI is ever placed into a CSS `url()`; a failed image probe shows a placeholder instead of injecting the remote (page-controlled) URL into inline styles.
46
+
47
+ ### Changed
48
+ - Redesigned the HTML preview: OKLCH design tokens, sharper type hierarchy, platform-accurate card mocks updated for 2026 (X shows image + domain overlay with no body text; LinkedIn drops the in-feed description; Discord auto-embeds render without a color bar), full mobile responsiveness, and `prefers-reduced-motion` support.
49
+ - Faster non-preview runs: `issues` / `facts` / `--json` no longer base64-encode the image. Page (4 MB) and image (32 MB) downloads are now size-capped against hostile/misconfigured responses, and timeouts report a clear `timed out after Ns` message.
50
+
51
+ ## 0.3.2 — 2026-05-12
52
+
53
+ ### Fixed
54
+ - `metaprev --version` now matches the published package version.
55
+ - Shared byte formatting between terminal and HTML output so image byte display stays consistent.
56
+
57
+ ### Compared to 0.3.1
58
+ - Publish-ready maintenance release for the cleanup pass.
59
+
60
+ ## 0.3.0 — 2026-05-11
61
+
62
+ ### Fixed
63
+ - `og:image:width`/`og:image:height` now validates against actual image dimensions. Warns when declared dimensions don't match real file dimensions — Slack and Discord trust the declared values for first-paint and will mis-crop when they don't match.
64
+
65
+ ### Compared to 0.2.0
66
+ - `src/validate.ts`: +18 lines — new dimension consistency check
67
+
68
+ ## 0.2.0 — 2025-11-19
69
+
70
+ ### Added
71
+ - `issues` subcommand — CI-friendly, exits 1 on errors, no browser
72
+ - `facts` subcommand — raw parsed meta, plain or JSON
73
+ - `data-URI` cache-bust — og:image embedded as base64 so regenerating the asset always shows the fresh image
74
+ - Auto-relax TLS for `*.localhost` / `*.test` / `127.0.0.1` (`--insecure` still available)
75
+ - Framework-agnostic defaults (no hardcoded og:type)
76
+
77
+ ## 0.1.0 — 2025-09-27
78
+
79
+ ### Added
80
+ - Core preview: fetches URL, parses og:* / twitter:* meta, validates char counts and image dimensions, opens side-by-side mock for Facebook, X, LinkedIn, Discord/Slack
81
+ - `-o, --output` flag to write preview HTML to a specific path
82
+ - `--no-open` and `--json` flags
83
+ - `image-size` dep for PNG/JPEG/WebP dimension reading
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Le Vinh Hung
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # metaprev
2
+
3
+ [![skills.sh](https://skills.sh/b/forsvn-labs/metaprev)](https://skills.sh/forsvn-labs/metaprev)
4
+
5
+ ![metaprev demo](https://github.com/forsvn-labs/metaprev/raw/main/demo.gif)
6
+
7
+ **Inspect how your share metadata is likely to render — locally, before you ship.**
8
+
9
+ Broken share cards are invisible until they're live. The page looks fine; the link looks broken — a blank thumbnail, the wrong image, or a damaging crop — and you only find out after sharing it. metaprev fetches your URL, parses its Open Graph and X metadata, validates the selected asset, and opens a local repair workspace with representative **Facebook**, **X**, **LinkedIn**, and **Discord** cards. Its fallback inspection also covers the common Open Graph and X metadata used by [Slack classic unfurls](https://api.slack.com/reference/messaging/link-unfurling).
10
+
11
+ No account, no upload, no copy-pasting into a third-party debugger. Point it at your local dev server (any framework — Next, Astro, Vite, SvelteKit, Bun.serve, Rails, Django…) or a deployed page.
12
+
13
+ ```bash
14
+ npx @forsvn/metaprev https://your-site.com
15
+ npx @forsvn/metaprev http://localhost:3000 # your dev server, whatever the port
16
+ ```
17
+
18
+ ## What you get
19
+
20
+ - **Platform-aware, without fake certainty.** The workspace separates Open Graph values from X-specific overrides and labels every fallback it used. The mocks are representative: platform experiments, viewport changes, and cached unfurls can still differ.
21
+ - **Light and dark inspection.** A toggle lets you check the same inputs against representative light and dark card treatments.
22
+ - **Crop evidence, not guesswork.** Cover and fit views place the fetched asset in the same 1.91:1 frame, show decoded dimensions and byte size, and estimate which edges a centered cover crop hides.
23
+ - **What you validated is what you saw.** The `og:image` is fetched and embedded as a data URI in the preview, so regenerating your asset never shows a stale, browser-cached image.
24
+ - **Catches silent breaks.** A relative `og:image` such as `/og.png` is not a valid Open Graph URL and can fail when a crawler fetches the asset. metaprev flags that plus broken or missing images, harmful crops, oversized files, non-image or SVG responses, and declared dimensions that do not match the decoded file.
25
+ - **Runs where you work.** Against `localhost` on any framework and port, before you deploy — somewhere OpenGraph.xyz, metatags.io, and the Facebook Debugger can't reach.
26
+ - **CI-ready.** Exit codes (`0` clean / `1` error / `2` fetch failure) plus `--json` and scoped `issues` / `facts` subcommands let you fail a build the moment a share card breaks.
27
+ - **Actionable findings.** Every issue has a stable code plus impact, observed evidence, and a concrete fix. Errors come first; warnings and notes cannot bury them.
28
+ - **Copy-ready repair output.** Review and copy a safe metadata starting point, an evidence-led repair brief, or a guarded prompt for a coding agent. Missing facts and local-only URLs stay explicit comments instead of becoming invented public metadata.
29
+ - **Safer local reports.** Page values are escaped, image probes accept only HTTP(S), only validated raster data URIs enter image styles, SVG is never embedded, and a per-report CSP nonce limits script execution.
30
+ - **Tiny and fast.** One dependency, no network round-trip to a third party, instant open.
31
+ - **Teaches your AI agent.** Ships a [skill](#agent-skill) so coding agents reach for metaprev (not a third-party validator) and know which warnings to ignore.
32
+
33
+ ## Quick start
34
+
35
+ 1. **Run it against your page** — a local dev server or a deployed URL:
36
+ ```bash
37
+ npx @forsvn/metaprev http://localhost:3000
38
+ ```
39
+ Requires `bun` on PATH ([install](https://bun.sh/install)); the package ships TypeScript and runs it via Bun.
40
+ 2. **Read the terminal summary.** You get the resolved title, description, `og:image`, image dimensions, and the issues found — errors first, then warnings, then info.
41
+ 3. **Open the workspace.** Compare platform inputs, cover-versus-fit crop, prioritized validation, parsed facts, resolved fallbacks, and repair output in one responsive report.
42
+ 4. **Fix what's flagged and re-run.** The exit code stays `1` while any error remains and drops to `0` once the card is clean — so the same command works as a pre-ship check.
43
+
44
+ ## What it checks
45
+ <!-- synced: 2026-08-23 -->
46
+
47
+ | Check | Why |
48
+ |---|---|
49
+ | Share title and description | Errors only when no usable value exists; metaprev does not pad copy to generic SEO character targets |
50
+ | Required Open Graph fields | Flags a missing `og:title`, `og:type`, `og:image`, or canonical share URL with the exact fallback observed |
51
+ | `og:image` is absolute URL | Crawlers fetch the URL standalone and fail on relative paths |
52
+ | `og:image` returns a successful response | Catches stale or wrong URLs |
53
+ | `og:image` content-type | Errors when a non-image response cannot be decoded; warns on SVG and never embeds it in the report |
54
+ | Image dimensions and crop | Uses a 1200×630 (1.91:1) workspace target; LinkedIn documents 1200×627 for its sharing module |
55
+ | Image file size | Warns above LinkedIn's documented 5 MB sharing-module limit |
56
+ | `og:image:width` / `:height` | Shows whether declared dimensions match the decoded asset |
57
+ | `og:image:alt` | Notes when the image lacks the description recommended by the Open Graph protocol |
58
+ | `twitter:card` | Distinguishes `summary_large_image` from the compact `summary` treatment |
59
+ | `og:url` / canonical | Helps platforms dedupe shares |
60
+
61
+ Three severity levels: **error** (broken input), **warn** (real compatibility or presentation risk), and **info** (standards or resilience improvement). Each issue in text and JSON includes `level`, `code`, `field`, `message`, `impact`, `evidence`, and `fix`. The original `level` / `field` / `message` keys remain available for existing scripts.
62
+
63
+ The rules use the [Open Graph protocol](https://ogp.me/) and current [LinkedIn sharing requirements](https://www.linkedin.com/help/linkedin/answer/a521928) for factual requirements. The report says when a card is representative rather than presenting a mock as a guaranteed platform screenshot.
64
+
65
+ ## Install
66
+
67
+ Run via `npx` (no install):
68
+
69
+ ```bash
70
+ npx @forsvn/metaprev <url>
71
+ ```
72
+
73
+ Or install globally for instant repeat runs:
74
+
75
+ ```bash
76
+ bun add -g @forsvn/metaprev
77
+ # or
78
+ npm install -g @forsvn/metaprev
79
+ ```
80
+
81
+ Requires `bun` on PATH ([install](https://bun.sh/install)) — the package ships TypeScript source and runs it via Bun.
82
+
83
+ ## Usage
84
+
85
+ ```bash
86
+ metaprev # no URL → show help
87
+ metaprev http://localhost:3000 # check your local dev server (any port, any framework)
88
+ metaprev https://forsvn.com # check a deployed page
89
+ metaprev https://forsvn.com --json # CI-friendly JSON output
90
+ metaprev https://forsvn.com --no-open # don't auto-open the preview
91
+ metaprev https://forsvn.com -o ./og.html # write the preview to a specific path
92
+
93
+ # Subcommands — same data, no browser, scoped output
94
+ metaprev issues http://localhost:3000 # just the issue list (exits 1 on errors — CI-friendly)
95
+ metaprev facts https://forsvn.com # just the parsed meta facts (title, dims, bytes, etc.)
96
+ metaprev facts https://forsvn.com --json # pipe parsed meta into another tool
97
+ ```
98
+
99
+ ## Options
100
+
101
+ | Flag | Effect | Default |
102
+ |---|---|---|
103
+ | `-o, --output <file>` | Write preview HTML to `<file>` (preview command only) | a temp file |
104
+ | `--no-open` | Don't auto-open the preview in your browser | opens |
105
+ | `--json` | Print machine-readable JSON to stdout (implies `--no-open`) | off |
106
+ | `-k, --insecure` | Skip TLS verification | auto-on for `*.localhost` / `*.test` / `127.0.0.1` |
107
+ | `-v, --version` | Print version | — |
108
+ | `-h, --help` | Show help | — |
109
+
110
+ ## Exit codes
111
+ <!-- synced: 2026-08-23 -->
112
+
113
+ - `0` — no errors (warnings allowed)
114
+ - `1` — at least one error-level issue (broken image, missing `og:image`, etc.)
115
+ - `2` — fetch/runtime failure or usage error (unknown flags fail instead of being ignored)
116
+
117
+ Exception: `facts` always exits `0` once a report was produced. It is a diagnostic
118
+ dump for pipelines, not a CI gate — use `issues` or the default command when you
119
+ need the exit code to reflect findings.
120
+
121
+ Useful in CI — fail the build when a share card breaks:
122
+
123
+ ```bash
124
+ npx @forsvn/metaprev issues https://staging.example.com --json || exit 1
125
+ ```
126
+
127
+ ## Troubleshooting
128
+
129
+ - **`metaprev: requires bun on PATH` (exit 127).** The package runs its TypeScript via Bun. Install Bun: `curl -fsSL https://bun.sh/install | bash`, then re-run.
130
+ - **`failed to fetch … self-signed certificate` (exit 2).** A staging server with a self-signed cert. Re-run with `--insecure`. (It's auto-on for `*.localhost`, `*.test`, and `127.0.0.1`.)
131
+ - **`timed out after 10s` (exit 2).** The page or image didn't respond in time — check the server is up and the URL is reachable from your machine.
132
+ - **The card looks right in metaprev but stale on a platform.** metaprev validates the source response, not platform caches. Use the platform's own refresh or inspection tool where one exists, such as the [Facebook Sharing Debugger](https://developers.facebook.com/tools/debug/) or [LinkedIn Post Inspector](https://www.linkedin.com/post-inspector/).
133
+
134
+ ## Agent skill
135
+
136
+ This repo ships a skill at [`skills/metaprev/`](./skills/metaprev/) that teaches coding agents to run the local validator, interpret impact/evidence/fix findings, respect metadata fallbacks, and reject generic SEO padding or invented calls to action.
137
+
138
+ Install via the [skills](https://skills.sh) CLI:
139
+
140
+ ```bash
141
+ # Globally — available across all projects
142
+ npx skills add forsvn-labs/metaprev -g
143
+
144
+ # Or per-project — committed with your repo, shared with your team
145
+ npx skills add forsvn-labs/metaprev
146
+ ```
147
+
148
+ Works with Claude Code, Cursor, Codex, OpenCode, and other agents supported by the [skills CLI](https://github.com/vercel-labs/skills#supported-agents).
149
+
150
+ ## Contributing & release
151
+ <!-- synced: 2026-08-23 -->
152
+
153
+ ```bash
154
+ bun install
155
+ bun test # parse / validate / render regressions
156
+ bun run typecheck # bunx tsc --noEmit
157
+ bun run dev <url> # run the CLI from source
158
+ ```
159
+
160
+ Before publishing, sync `version` in `package.json` with `VERSION` in `bin/metaprev.ts`, then:
161
+
162
+ ```bash
163
+ bun run typecheck
164
+ npm pack --dry-run
165
+ npm publish --access public
166
+ ```
167
+
168
+ If npm 11 reports `Invalid time value` / `before=null`, check that any user-level `min-release-age` config is numeric seconds, not shorthand like `3d`.
169
+
170
+ ## License
171
+
172
+ MIT © Le Vinh Hung
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ // Thin shim: npm needs a recognized extension for `bin` entries, so this .mjs
3
+ // stub launches the actual Bun-based TypeScript entry (bin/metaprev.ts).
4
+ import { spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { dirname, resolve } from "node:path";
7
+
8
+ const here = dirname(fileURLToPath(import.meta.url));
9
+ const entry = resolve(here, "metaprev.ts");
10
+ const bun = process.env.METAPREV_BUN || "bun";
11
+
12
+ const child = spawn(bun, [entry, ...process.argv.slice(2)], { stdio: "inherit" });
13
+
14
+ child.on("error", (err) => {
15
+ if (err && err.code === "ENOENT") {
16
+ console.error("metaprev: requires `bun` on PATH (https://bun.sh/install).");
17
+ process.exit(127);
18
+ }
19
+ console.error("metaprev: failed to spawn bun:", err.message);
20
+ process.exit(1);
21
+ });
22
+ child.on("exit", (code, signal) => {
23
+ if (signal) process.kill(process.pid, signal);
24
+ else process.exit(code ?? 1);
25
+ });
@@ -0,0 +1,353 @@
1
+ #!/usr/bin/env bun
2
+ import { spawn } from 'node:child_process'
3
+ import { mkdtempSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { fetchPage, isLocalUrl, probeImage } from '../src/fetch.ts'
7
+ import { resolvePrimaryInput } from '../src/inputs.ts'
8
+ import { formatBytes } from '../src/format.ts'
9
+ import { parseMeta } from '../src/parse.ts'
10
+ import { renderHtml } from '../src/render.ts'
11
+ import type { ImageProbe, Report } from '../src/types.ts'
12
+ import { validate } from '../src/validate.ts'
13
+
14
+ type Cmd = 'preview' | 'issues' | 'facts'
15
+ type Issue = Report['issues'][number]
16
+ type IssueLevel = Issue['level']
17
+
18
+ type Opts = {
19
+ cmd: Cmd
20
+ url: string
21
+ output?: string
22
+ open: boolean
23
+ json: boolean
24
+ help: boolean
25
+ insecure: boolean
26
+ }
27
+
28
+ const VERSION = '0.5.0'
29
+ const SUBCOMMANDS = new Set<Cmd>(['issues', 'facts'])
30
+ const ISSUE_TAGS: Record<IssueLevel, string> = {
31
+ error: 'ERR',
32
+ warn: 'WRN',
33
+ info: 'INF',
34
+ }
35
+
36
+ function parseArgs(argv: string[]): Opts {
37
+ const opts: Opts = {
38
+ cmd: 'preview',
39
+ url: '',
40
+ open: true,
41
+ json: false,
42
+ help: false,
43
+ insecure: false,
44
+ }
45
+ const positional: string[] = []
46
+ for (let i = 0; i < argv.length; i++) {
47
+ const a = argv[i]
48
+ switch (a) {
49
+ case '-h':
50
+ case '--help':
51
+ opts.help = true
52
+ break
53
+ case '-v':
54
+ case '--version':
55
+ console.log(VERSION)
56
+ process.exit(0)
57
+ break
58
+ case '--no-open':
59
+ opts.open = false
60
+ break
61
+ case '--json':
62
+ opts.json = true
63
+ opts.open = false
64
+ break
65
+ case '-k':
66
+ case '--insecure':
67
+ opts.insecure = true
68
+ break
69
+ case '-o':
70
+ case '--output':
71
+ opts.output = argv[++i]
72
+ break
73
+ default:
74
+ if (a && !a.startsWith('-')) {
75
+ positional.push(a)
76
+ break
77
+ }
78
+ // Unknown flags are user errors — fail loudly instead of silently ignoring.
79
+ console.error(`metaprev: unknown option '${a}'`)
80
+ console.error(' run `metaprev --help` to see available options')
81
+ process.exit(2)
82
+ }
83
+ }
84
+ if (positional[0] && SUBCOMMANDS.has(positional[0] as Cmd)) {
85
+ opts.cmd = positional.shift() as Cmd
86
+ }
87
+ if (positional[0]) opts.url = positional[0]
88
+ if (opts.url && !/^https?:\/\//i.test(opts.url)) opts.url = `http://${opts.url}`
89
+ return opts
90
+ }
91
+
92
+ function help(): void {
93
+ console.log(`metaprev v${VERSION} — preview your OpenGraph cards locally
94
+
95
+ Usage:
96
+ metaprev <url> [options] # full preview + browser open
97
+ metaprev issues <url> [options] # print just the issues
98
+ metaprev facts <url> [options] # print just the parsed meta facts
99
+
100
+ Arguments:
101
+ url URL to fetch (e.g. https://example.com or http://localhost:3000)
102
+
103
+ Options:
104
+ -o, --output FILE Write the preview HTML to FILE (preview command only)
105
+ --no-open Don't auto-open the preview in your browser
106
+ --json Print machine-readable JSON to stdout (implies --no-open)
107
+ -k, --insecure Skip TLS cert verification (auto-on for *.localhost / *.test / 127.0.0.1)
108
+ -v, --version Print version and exit
109
+ -h, --help Show this help
110
+
111
+ Examples:
112
+ metaprev http://localhost:3000 # check your local dev server
113
+ metaprev https://hungv.io # check a deployed page
114
+ metaprev issues http://localhost:3000 # quick issue check, no browser
115
+ metaprev facts https://hungv.io --json # pipe parsed meta into another tool
116
+ `)
117
+ }
118
+
119
+ function reportColor(level: IssueLevel): string {
120
+ if (!process.stdout.isTTY) return ''
121
+ return level === 'error' ? '\x1b[31m' : level === 'warn' ? '\x1b[33m' : '\x1b[36m'
122
+ }
123
+
124
+ function reset(): string {
125
+ return process.stdout.isTTY ? '\x1b[0m' : ''
126
+ }
127
+
128
+ function bold(s: string): string {
129
+ return process.stdout.isTTY ? `\x1b[1m${s}\x1b[22m` : s
130
+ }
131
+
132
+ function dim(s: string): string {
133
+ return process.stdout.isTTY ? `\x1b[2m${s}\x1b[22m` : s
134
+ }
135
+
136
+ function green(s: string): string {
137
+ return process.stdout.isTTY ? `\x1b[32m${s}\x1b[0m` : s
138
+ }
139
+
140
+ function printTerminal(report: Report): void {
141
+ const m = report.meta
142
+ const title = resolvePrimaryInput(m, 'title').value ?? '(none)'
143
+ const desc = resolvePrimaryInput(m, 'description').value ?? '(none)'
144
+ console.log()
145
+ console.log(bold(`metaprev — ${report.finalUrl}`))
146
+ console.log(dim(`HTTP ${report.status} · fetched ${report.fetchedAt}`))
147
+ console.log()
148
+ console.log(` ${dim('title')} ${title} ${dim(`(${title.length} chars)`)}`)
149
+ console.log(` ${dim('description')} ${truncate(desc, 100)} ${dim(`(${desc.length} chars)`)}`)
150
+ console.log(` ${dim('og:image')} ${m.ogImage ?? '(none)'}`)
151
+ if (report.image?.width && report.image?.height) {
152
+ console.log(` ${dim('image dims')} ${report.image.width}×${report.image.height}px`)
153
+ } else if (report.image?.error) {
154
+ console.log(` ${dim('image dims')} ${reportColor('error')}failed: ${report.image.error}${reset()}`)
155
+ }
156
+ console.log()
157
+ if (report.issues.length === 0) {
158
+ console.log(` ${green('✓ no issues — your card is clean.')}`)
159
+ } else {
160
+ for (const i of report.issues) {
161
+ printIssue(i, ' ')
162
+ }
163
+ }
164
+ console.log()
165
+ }
166
+
167
+ function printIssuesOnly(report: Report): void {
168
+ if (report.issues.length === 0) {
169
+ console.log(green('✓ no issues — your card is clean.'))
170
+ return
171
+ }
172
+ for (const i of report.issues) {
173
+ printIssue(i)
174
+ }
175
+ }
176
+
177
+ function printIssue(issue: Issue, indent = ''): void {
178
+ const tag = ISSUE_TAGS[issue.level]
179
+ console.log(`${indent}${reportColor(issue.level)}${tag}${reset()} ${dim(issue.field.padEnd(12))} ${issue.message}`)
180
+ const detailIndent = `${indent} `
181
+ console.log(`${detailIndent}${dim('impact')} ${issue.impact}`)
182
+ console.log(`${detailIndent}${dim('evidence')} ${issue.evidence}`)
183
+ console.log(`${detailIndent}${dim('fix')} ${issue.fix}`)
184
+ }
185
+
186
+ function printFactsOnly(report: Report): void {
187
+ const m = report.meta
188
+ const title = resolvePrimaryInput(m, 'title').value
189
+ const desc = resolvePrimaryInput(m, 'description').value
190
+ const rows: Array<[string, string]> = [
191
+ ['source', report.source],
192
+ ['final url', report.finalUrl],
193
+ ['http', String(report.status)],
194
+ ['title', fmt(title, true)],
195
+ ['description', fmt(desc, true)],
196
+ ['og:image', m.ogImage ?? '(none)'],
197
+ ['image dims', report.image?.width && report.image?.height ? `${report.image.width}×${report.image.height}px` : report.image?.error ? `failed: ${report.image.error}` : '(unknown)'],
198
+ ['image bytes', report.image?.byteLength != null ? formatBytes(report.image.byteLength) : '(unknown)'],
199
+ ['twitter:card', m.twitterCard ?? '(none)'],
200
+ ['og:site_name', m.ogSiteName ?? '(none)'],
201
+ ['canonical', m.canonical ?? m.ogUrl ?? '(none)'],
202
+ ]
203
+ const labelWidth = Math.max(...rows.map((r) => r[0].length))
204
+ for (const [label, value] of rows) {
205
+ console.log(`${dim(label.padEnd(labelWidth))} ${value}`)
206
+ }
207
+ }
208
+
209
+ function fmt(s: string | undefined, withCount = false): string {
210
+ if (!s) return '(none)'
211
+ return withCount ? `${s} ${dim(`(${s.length} chars)`)}` : s
212
+ }
213
+
214
+ function truncate(s: string, n: number): string {
215
+ return s.length > n ? `${s.slice(0, n - 1)}…` : s
216
+ }
217
+
218
+ function openInBrowser(file: string): void {
219
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer.exe' : 'xdg-open'
220
+ try {
221
+ spawn(cmd, [file], { stdio: 'ignore', detached: true }).unref()
222
+ } catch {}
223
+ }
224
+
225
+ // Strip the embedded image bytes from --json output. They're only useful for the
226
+ // HTML preview and would otherwise bloat piped output by ~100KB+.
227
+ function stripDataUri(report: Report): Report {
228
+ const clean = (probe: ImageProbe | undefined): ImageProbe | undefined => {
229
+ if (!probe?.dataUri) return probe
230
+ const image = { ...probe }
231
+ delete image.dataUri
232
+ return image
233
+ }
234
+ return { ...report, image: clean(report.image), twitterImage: clean(report.twitterImage) }
235
+ }
236
+
237
+ async function buildReport(opts: Opts): Promise<Report> {
238
+ const fetchedAt = new Date().toISOString()
239
+ const page = await fetchPage(opts.url, { insecure: opts.insecure })
240
+ const meta = parseMeta(page.html)
241
+ const imageRef = meta.ogImage
242
+ // The base64 data URI is only embedded in the HTML preview. Skip building it for
243
+ // issues / facts / --json, where it would be stripped or unused anyway.
244
+ const withDataUri = opts.cmd === 'preview' && !opts.json
245
+ const imagePromise = imageRef
246
+ ? probeImage(imageRef, page.finalUrl, { insecure: opts.insecure, withDataUri })
247
+ : Promise.resolve(undefined)
248
+ // A distinct twitter:image affects only the visual X preview. Probe it only for the
249
+ // browser workspace; scoped and JSON commands keep their original single request.
250
+ const twitterImagePromise = withDataUri && meta.twitterImage && meta.twitterImage !== meta.ogImage
251
+ ? probeImage(meta.twitterImage, page.finalUrl, { insecure: opts.insecure, withDataUri: true })
252
+ : Promise.resolve(undefined)
253
+ const [image, twitterImage] = await Promise.all([imagePromise, twitterImagePromise])
254
+ const issues = validate(meta, image)
255
+ return {
256
+ source: opts.url,
257
+ fetchedAt,
258
+ finalUrl: page.finalUrl,
259
+ status: page.status,
260
+ meta,
261
+ image,
262
+ twitterImage,
263
+ issues,
264
+ }
265
+ }
266
+
267
+ async function main(): Promise<void> {
268
+ const opts = parseArgs(process.argv.slice(2))
269
+ if (opts.help) {
270
+ help()
271
+ return
272
+ }
273
+ if (!opts.url) {
274
+ // Subcommand with no URL is a user error — scripts that forget to interpolate $URL
275
+ // should fail loudly, not silently exit 0.
276
+ if (opts.cmd !== 'preview') {
277
+ console.error(`metaprev: '${opts.cmd}' needs a URL argument`)
278
+ console.error(` example: metaprev ${opts.cmd} https://example.com`)
279
+ process.exitCode = 2
280
+ return
281
+ }
282
+ help()
283
+ return
284
+ }
285
+ if (opts.output && opts.cmd !== 'preview') {
286
+ console.error(`metaprev: --output is ignored for '${opts.cmd}' (no preview HTML is generated)`)
287
+ }
288
+
289
+ let report: Report
290
+ try {
291
+ report = await buildReport(opts)
292
+ } catch (err) {
293
+ const msg = (err as Error).message
294
+ console.error(`metaprev: failed to fetch ${opts.url}`)
295
+ console.error(` ${msg}`)
296
+ if (/self[ -]?signed|certificate|unable to verify/i.test(msg) && !opts.insecure && !isLocalUrl(opts.url)) {
297
+ console.error(` hint: rerun with --insecure to skip TLS verification (auto-on for *.localhost / *.test / 127.0.0.1)`)
298
+ }
299
+ process.exitCode = 2
300
+ return
301
+ }
302
+
303
+ const hasError = report.issues.some((i) => i.level === 'error')
304
+
305
+ if (opts.cmd === 'issues') {
306
+ if (opts.json) {
307
+ process.stdout.write(JSON.stringify(report.issues, null, 2) + '\n')
308
+ } else {
309
+ printIssuesOnly(report)
310
+ }
311
+ process.exitCode = hasError ? 1 : 0
312
+ return
313
+ }
314
+
315
+ if (opts.cmd === 'facts') {
316
+ if (opts.json) {
317
+ const facts = {
318
+ source: report.source,
319
+ finalUrl: report.finalUrl,
320
+ status: report.status,
321
+ meta: report.meta,
322
+ image: stripDataUri(report).image,
323
+ }
324
+ process.stdout.write(JSON.stringify(facts, null, 2) + '\n')
325
+ } else {
326
+ printFactsOnly(report)
327
+ }
328
+ return
329
+ }
330
+
331
+ // default: full preview
332
+ if (opts.json) {
333
+ process.stdout.write(JSON.stringify(stripDataUri(report), null, 2) + '\n')
334
+ process.exitCode = hasError ? 1 : 0
335
+ return
336
+ }
337
+
338
+ printTerminal(report)
339
+
340
+ const html = renderHtml(report)
341
+ const outFile = opts.output ?? join(mkdtempSync(join(tmpdir(), 'metaprev-')), 'preview.html')
342
+ writeFileSync(outFile, html, 'utf8')
343
+ console.log(` ${dim('preview →')} ${outFile}`)
344
+ if (opts.open) openInBrowser(outFile)
345
+
346
+ process.exitCode = hasError ? 1 : 0
347
+ }
348
+
349
+ main().catch((err) => {
350
+ console.error('metaprev: unexpected error')
351
+ console.error(err)
352
+ process.exitCode = 2
353
+ })