@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 +83 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/bin/metaprev.mjs +25 -0
- package/bin/metaprev.ts +353 -0
- package/package.json +57 -0
- package/skills/metaprev/SKILL.md +156 -0
- package/src/fetch.ts +215 -0
- package/src/format.ts +5 -0
- package/src/host.ts +93 -0
- package/src/inputs.ts +94 -0
- package/src/parse.ts +184 -0
- package/src/render.ts +980 -0
- package/src/repair.ts +171 -0
- package/src/types.ts +60 -0
- package/src/validate.ts +206 -0
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
|
+
[](https://skills.sh/forsvn-labs/metaprev)
|
|
4
|
+
|
|
5
|
+

|
|
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
|
package/bin/metaprev.mjs
ADDED
|
@@ -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
|
+
});
|
package/bin/metaprev.ts
ADDED
|
@@ -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
|
+
})
|