@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/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forsvn/metaprev",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Preview, inspect, and repair Open Graph cards locally with actionable validation and copy-ready fixes.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Le Vinh Hung <levinhhungg@gmail.com>",
|
|
8
|
+
"homepage": "https://github.com/forsvn-labs/metaprev",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/forsvn-labs/metaprev.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/forsvn-labs/metaprev/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"opengraph",
|
|
18
|
+
"og-image",
|
|
19
|
+
"og-preview",
|
|
20
|
+
"social-preview",
|
|
21
|
+
"twitter-card",
|
|
22
|
+
"meta-tags",
|
|
23
|
+
"seo",
|
|
24
|
+
"cli"
|
|
25
|
+
],
|
|
26
|
+
"bin": {
|
|
27
|
+
"metaprev": "bin/metaprev.mjs"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin",
|
|
31
|
+
"src",
|
|
32
|
+
"skills",
|
|
33
|
+
"README.md",
|
|
34
|
+
"CHANGELOG.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"bun": ">=1.0.0"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"dev": "bun bin/metaprev.ts",
|
|
42
|
+
"test": "bun test",
|
|
43
|
+
"typecheck": "bunx tsc --noEmit"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"image-size": "^2"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/bun": "latest"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"typescript": "^5"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: metaprev
|
|
3
|
+
description: Preview, validate, and debug OpenGraph cards and social link previews locally via the metaprev CLI. Use whenever the user asks about how their site looks when shared on Facebook, X, LinkedIn, Discord, or Slack — including og:image, og:title, og:description, twitter:card, "broken share preview", "link preview not loading", "test my OG card", "OpenGraph validator", "social meta tags", or whenever they reference seeing issues from OpenGraph.xyz, metatags.io, Facebook Sharing Debugger, or similar third-party validators. Also use proactively when a Vercel/Next/Astro deploy is being checked for share-readiness, when og:image meta tags are added or modified, or when social-share thumbnails appear broken in chats. Prefer this over pointing the user at a third-party validator.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# metaprev — local OpenGraph preview
|
|
7
|
+
|
|
8
|
+
`metaprev` is a CLI that fetches a URL, parses Open Graph and X metadata, validates the selected image and fallbacks, then opens a local preview-and-repair workspace with representative Facebook, X, LinkedIn, and Discord cards. Slack classic unfurls use the same inspected Open Graph and X metadata, but metaprev does not present Discord UI as a Slack screenshot. It works against any `localhost` dev server and public URLs with no validator service.
|
|
9
|
+
|
|
10
|
+
- Repo: https://github.com/forsvn-labs/metaprev
|
|
11
|
+
- Package: `@forsvn/metaprev`
|
|
12
|
+
- Author intent: replace the workflow of "paste URL into OpenGraph.xyz / metatags.io / Facebook Debugger" with a local CLI you can run before shipping.
|
|
13
|
+
|
|
14
|
+
## When this skill applies
|
|
15
|
+
|
|
16
|
+
Use `metaprev` instead of pointing the user at a third-party debugger when the task involves:
|
|
17
|
+
|
|
18
|
+
- "How does this link look when shared?" "Why is my preview broken?"
|
|
19
|
+
- Adding or fixing `og:image`, `og:title`, `og:description`, `og:url`, `twitter:card`
|
|
20
|
+
- Validating image dimensions, file size, absolute-URL-ness
|
|
21
|
+
- Debugging Slack/Discord/iMessage embeds that don't render
|
|
22
|
+
- The user pasted a screenshot from OpenGraph.xyz, metatags.io, Twitter Card Validator, or Facebook Sharing Debugger
|
|
23
|
+
- A deploy is being readied and someone wants to check share-card health
|
|
24
|
+
- A new OG image was generated and needs validation
|
|
25
|
+
|
|
26
|
+
Don't reach for it when the task is: favicon work, PWA manifests, OG image *generation* (different problem — this skill validates an existing image), pure SEO meta (search-result `description`/`keywords`), or schema.org / JSON-LD.
|
|
27
|
+
|
|
28
|
+
## How to invoke
|
|
29
|
+
|
|
30
|
+
The default invocation is `npx` so no install is needed. Bun is required on `PATH` because the package ships TypeScript and runs it via Bun.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Any URL — deployed or local dev (any framework, any port)
|
|
34
|
+
npx @forsvn/metaprev https://example.com
|
|
35
|
+
npx @forsvn/metaprev http://localhost:3000 # Next, Vite, Bun.serve, Rails…
|
|
36
|
+
npx @forsvn/metaprev http://localhost:4321 # Astro
|
|
37
|
+
npx @forsvn/metaprev http://localhost:5173 # Vite default
|
|
38
|
+
# (no URL → prints help)
|
|
39
|
+
|
|
40
|
+
# Subcommands — scoped text/JSON output, no browser
|
|
41
|
+
npx @forsvn/metaprev issues https://example.com # just the issue list
|
|
42
|
+
npx @forsvn/metaprev facts https://example.com # just the parsed meta facts
|
|
43
|
+
npx @forsvn/metaprev facts https://example.com --json # pipe into another tool
|
|
44
|
+
|
|
45
|
+
# CI / scripting — JSON to stdout, no browser
|
|
46
|
+
npx @forsvn/metaprev https://example.com --json
|
|
47
|
+
|
|
48
|
+
# Don't auto-open the browser
|
|
49
|
+
npx @forsvn/metaprev https://example.com --no-open
|
|
50
|
+
|
|
51
|
+
# Write the preview HTML to a specific file
|
|
52
|
+
npx @forsvn/metaprev https://example.com -o ./og-preview.html
|
|
53
|
+
|
|
54
|
+
# Local self-signed TLS (auto-on for *.localhost / *.test / 127.0.0.1; otherwise pass explicitly)
|
|
55
|
+
npx @forsvn/metaprev https://staging.internal --insecure
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Exit codes: `0` clean, `1` at least one error-level issue, `2` fetch failure. Use exit code `1` to fail a CI check.
|
|
59
|
+
|
|
60
|
+
## Reading the output
|
|
61
|
+
|
|
62
|
+
Three issue levels:
|
|
63
|
+
|
|
64
|
+
- **error** — share is visibly broken. No `og:image`, image returns 404, `og:image` is a relative URL like `/og.png` (most validators fetch the URL standalone and fail), or the URL returns a non-image response that can't be decoded (points at an HTML/error page).
|
|
65
|
+
- **warn** — real compatibility or presentation risk. Examples: missing `og:title`, off-ratio or low-resolution image, image above LinkedIn's documented 5 MB limit, SVG image, or declared dimensions that differ from the decoded asset.
|
|
66
|
+
- **info** — standards, accessibility, or resilience improvement. Examples: missing `og:image:alt`, `og:type`, dimensions, canonical URL, or `twitter:card`.
|
|
67
|
+
|
|
68
|
+
Address errors first. Use each finding's impact and evidence to judge warnings. Info findings do not fail CI, but accessibility and standards notes can still be worth fixing.
|
|
69
|
+
|
|
70
|
+
Every issue includes a stable code, impact, observed evidence, and a concrete fix. The HTML workspace also shows Open Graph versus X inputs, source fallbacks, cover-versus-fit crop evidence, and copy-ready metadata, repair brief, and guarded coding-agent prompt.
|
|
71
|
+
|
|
72
|
+
## Common fixes (in order of leverage)
|
|
73
|
+
|
|
74
|
+
1. **`og:image` must be an absolute URL.** Many template engines emit `/og-default.png`, but Open Graph defines the property as a URL. Fix: produce `https://yourdomain.com/og-default.png`.
|
|
75
|
+
- Astro: `new URL(image, Astro.site).toString()` (requires `site` in `astro.config`)
|
|
76
|
+
- Next.js: build with `process.env.NEXT_PUBLIC_SITE_URL` or `metadata.metadataBase`
|
|
77
|
+
- SvelteKit: `${$page.url.origin}${image}`
|
|
78
|
+
- Plain HTML: hardcode the full URL
|
|
79
|
+
2. **Use a deliberate 1.91:1 asset.** The workspace target is 1200×630. LinkedIn documents 1200×627 for its sharing module. Use the crop inspection instead of assuming every platform will frame it identically.
|
|
80
|
+
3. **Add accurate `og:image:width`, `og:image:height`, and `og:image:alt`.** The dimensions must match the decoded file. Alt describes what is in the image, not a slogan.
|
|
81
|
+
4. **Choose the X treatment explicitly.** Use `summary_large_image` for a wide card or `summary` for the compact card.
|
|
82
|
+
5. **Set `og:url` or a `<link rel="canonical">`** so platforms dedupe shares from URLs with `?utm_*` query strings.
|
|
83
|
+
|
|
84
|
+
## Copy rule
|
|
85
|
+
|
|
86
|
+
metaprev intentionally emits no generic title-length or description-length warnings. Preserve concise, truthful copy. Do not add keywords, claims, calls to action, or padding just to resemble an SEO score. Treat the metadata snippet as a safe starting point: adapt it to the framework and review every value. A local or private-network URL remains a comment because it is not a valid public repair value.
|
|
87
|
+
|
|
88
|
+
## Workflow patterns
|
|
89
|
+
|
|
90
|
+
### Pattern A — User just changed OG meta and wants to verify
|
|
91
|
+
|
|
92
|
+
1. Run `npx @forsvn/metaprev <url>` (local or deployed).
|
|
93
|
+
2. Read the terminal output: title, description, image URL, image dims, issue list.
|
|
94
|
+
3. Surface errors first with the recommended fix.
|
|
95
|
+
4. Surface warnings with their evidence and concrete fix.
|
|
96
|
+
5. Skip info-level unless it fits the user's current pass.
|
|
97
|
+
|
|
98
|
+
### Pattern B — User says the link preview is broken on a specific platform
|
|
99
|
+
|
|
100
|
+
1. Run `metaprev` against the page they're sharing.
|
|
101
|
+
2. Diagnose from evidence in this order: (a) `og:image` missing or not absolute? (b) image request fails? (c) decoded bytes and response type disagree? (d) image too large or framed poorly?
|
|
102
|
+
3. If everything looks fine in `metaprev`, the platform may be serving cached metadata. Use an official refresh tool where one exists:
|
|
103
|
+
- Facebook: scrape again via the Sharing Debugger (https://developers.facebook.com/tools/debug/)
|
|
104
|
+
- LinkedIn: use the Post Inspector (https://www.linkedin.com/post-inspector/)
|
|
105
|
+
- X, Slack, and Discord cache behavior can change; do not promise a refresh time.
|
|
106
|
+
|
|
107
|
+
### Pattern C — Pre-deploy CI check
|
|
108
|
+
|
|
109
|
+
Add a smoke test to a pre-deploy script:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
npx @forsvn/metaprev https://staging.example.com --json > /dev/null || exit 1
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Exit code `1` fails the deploy when any error-level issue exists. The `--json` output is machine-readable for further checks (e.g., assert image content-type is `image/png`).
|
|
116
|
+
|
|
117
|
+
### Pattern D — User pastes a screenshot from OpenGraph.xyz or similar
|
|
118
|
+
|
|
119
|
+
Third-party validators may overlap with metaprev and may also add generic heuristics such as "missing CTA in image" or "title 50–60 chars." Run metaprev against the same URL to confirm the source evidence, then review each claim:
|
|
120
|
+
|
|
121
|
+
- "Image is 2400×1260" → the ratio is already correct; do not resize only to hit an exact pixel count.
|
|
122
|
+
- "Image is broken in preview" → inspect the resolved URL, HTTP result, response type, and decoded bytes before choosing a fix.
|
|
123
|
+
- "Missing CTA in image" → push back. Editorial OG cards (clean typography, brand name, tagline) don't need "Visit example.com →" buttons. The buttons make the card look like an ad. The tagline IS the CTA.
|
|
124
|
+
- "Title is short, description is short" → user's call. Recommend keeping if intentional.
|
|
125
|
+
|
|
126
|
+
## Output reading reference
|
|
127
|
+
|
|
128
|
+
Terminal:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
metaprev — https://example.com/
|
|
132
|
+
HTTP 200 · fetched 2026-05-10T16:45:13Z
|
|
133
|
+
|
|
134
|
+
title Example Inc. (12 chars)
|
|
135
|
+
description We build... (58 chars)
|
|
136
|
+
og:image https://example.com/og.png
|
|
137
|
+
image dims 1200×630px
|
|
138
|
+
|
|
139
|
+
WRN og:image The image does not match the 1.91:1 share frame.
|
|
140
|
+
impact Depending on the platform and viewport, the asset can be cropped or padded.
|
|
141
|
+
evidence The decoded asset is 1200×1200px (1.00:1); the workspace frame is 1.91:1.
|
|
142
|
+
fix Export a 1200×630px version and keep important content away from the edges.
|
|
143
|
+
...
|
|
144
|
+
preview → /var/folders/.../preview.html
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`--json` output has parsed metadata, the image probe, and a typed `issues[]` array. Existing `level`, `field`, and `message` keys remain; `code`, `impact`, `evidence`, and `fix` add repair context.
|
|
148
|
+
|
|
149
|
+
The HTML report is a responsive local workspace with four representative card mocks, light/dark treatments, explicit source fallbacks, crop-versus-fit image inspection, prioritized validation, parsed facts, and reviewed repair outputs. Platform experiments, viewport differences, and cached unfurls can differ from the mocks.
|
|
150
|
+
|
|
151
|
+
## Limitations to know
|
|
152
|
+
|
|
153
|
+
- Bun must be on `PATH`; users without Bun get a clear error pointing to https://bun.sh/install.
|
|
154
|
+
- Currently parses meta tags via regex on the `<head>` substring. Handles standard cases; pages that use `<base href>` or that emit meta tags outside `<head>` may parse imperfectly. If a page has weird structure, fall back to viewing raw HTML.
|
|
155
|
+
- The platform cards are representative and can differ from live UI experiments, viewport treatments, and cached unfurls. The report makes that uncertainty explicit.
|
|
156
|
+
- No JavaScript rendering. If the page sets meta tags via client-side JS (rare; bad practice for shared content), `metaprev` won't see them. Recommend the user emit meta tags server-side / at build.
|
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { imageSize } from 'image-size'
|
|
2
|
+
import { classifyUrlHost } from './host.ts'
|
|
3
|
+
import type { ImageProbe } from './types.ts'
|
|
4
|
+
|
|
5
|
+
const UA = 'metaprev (+https://github.com/forsvn-labs/metaprev)'
|
|
6
|
+
|
|
7
|
+
// Fetch policy: local dev targets stay fetchable — that is the product's job.
|
|
8
|
+
export function isLocalUrl(url: string): boolean {
|
|
9
|
+
return classifyUrlHost(url).isLocalDevHost
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type FetchOpts = { insecure?: boolean }
|
|
13
|
+
type ProbeOpts = FetchOpts & { withDataUri?: boolean }
|
|
14
|
+
|
|
15
|
+
// Cap how much HTML we pull into memory. The <head> sits at the top of the document,
|
|
16
|
+
// so a few MB is plenty to find every meta tag while staying immune to multi-hundred-MB
|
|
17
|
+
// or never-ending responses.
|
|
18
|
+
const MAX_HTML_BYTES = 4 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
// Cap image downloads too. This is a memory-safety ceiling, not a platform limit;
|
|
21
|
+
// validation applies the current platform-specific threshold separately.
|
|
22
|
+
const MAX_IMAGE_BYTES = 32 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
function tlsOpt(url: string, opts: FetchOpts): { rejectUnauthorized: false } | undefined {
|
|
25
|
+
return opts.insecure || isLocalUrl(url) ? { rejectUnauthorized: false } : undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function timeoutError(err: unknown, ctrl: AbortController, timeoutMs: number): Error {
|
|
29
|
+
if (ctrl.signal.aborted || (err as Error)?.name === 'AbortError') {
|
|
30
|
+
return new Error(`timed out after ${Math.round(timeoutMs / 1000)}s`)
|
|
31
|
+
}
|
|
32
|
+
return err as Error
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Read a response body up to a byte cap, cancelling the stream once exceeded. Returns the
|
|
36
|
+
// bytes (sliced to the cap) plus whether more data was left unread.
|
|
37
|
+
export async function readCappedBytes(res: Response, maxBytes: number): Promise<{ bytes: Buffer; truncated: boolean }> {
|
|
38
|
+
const body = res.body
|
|
39
|
+
if (!body) {
|
|
40
|
+
// Null-body path: there is no stream to cap mid-flight, so the only safe bound
|
|
41
|
+
// is the declared length. A missing or oversized Content-Length is rejected
|
|
42
|
+
// rather than read into memory unbounded.
|
|
43
|
+
const declaredLen = Number(res.headers.get('content-length'))
|
|
44
|
+
if (res.headers.get('content-length') === null || !Number.isFinite(declaredLen) || declaredLen < 0 || declaredLen > maxBytes) {
|
|
45
|
+
throw new Error(`Response body has no readable stream and ${res.headers.get('content-length') !== null ? 'declares more than' : 'does not declare'} the readable size limit`)
|
|
46
|
+
}
|
|
47
|
+
const all = Buffer.from(await res.arrayBuffer())
|
|
48
|
+
return { bytes: all.subarray(0, maxBytes), truncated: all.byteLength > maxBytes }
|
|
49
|
+
}
|
|
50
|
+
const reader = body.getReader()
|
|
51
|
+
const chunks: Uint8Array[] = []
|
|
52
|
+
let total = 0
|
|
53
|
+
let truncated = false
|
|
54
|
+
try {
|
|
55
|
+
for (;;) {
|
|
56
|
+
const { done, value } = await reader.read()
|
|
57
|
+
if (done) break
|
|
58
|
+
if (!value) continue
|
|
59
|
+
chunks.push(value)
|
|
60
|
+
total += value.byteLength
|
|
61
|
+
if (total > maxBytes) {
|
|
62
|
+
// Past the cap; we have more than enough. A cancel() rejection must not discard it.
|
|
63
|
+
truncated = true
|
|
64
|
+
await reader.cancel().catch(() => {})
|
|
65
|
+
break
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
reader.releaseLock?.()
|
|
70
|
+
}
|
|
71
|
+
const buf = Buffer.concat(chunks)
|
|
72
|
+
return { bytes: truncated ? buf.subarray(0, maxBytes) : buf, truncated }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Read a response body up to a byte cap. Returns decoded text.
|
|
76
|
+
async function readCapped(res: Response, maxBytes: number): Promise<string> {
|
|
77
|
+
const { bytes } = await readCappedBytes(res, maxBytes)
|
|
78
|
+
return new TextDecoder('utf-8').decode(bytes)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function guessMime(url: string): string | undefined {
|
|
82
|
+
const ext = url.split('?')[0]?.split('#')[0]?.split('.').pop()?.toLowerCase()
|
|
83
|
+
switch (ext) {
|
|
84
|
+
case 'png': return 'image/png'
|
|
85
|
+
case 'jpg':
|
|
86
|
+
case 'jpeg': return 'image/jpeg'
|
|
87
|
+
case 'webp': return 'image/webp'
|
|
88
|
+
case 'gif': return 'image/gif'
|
|
89
|
+
case 'svg': return 'image/svg+xml'
|
|
90
|
+
case 'avif': return 'image/avif'
|
|
91
|
+
default: return undefined
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type PageResult = {
|
|
96
|
+
finalUrl: string
|
|
97
|
+
status: number
|
|
98
|
+
html: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function fetchPage(url: string, opts: FetchOpts = {}, timeoutMs = 10_000): Promise<PageResult> {
|
|
102
|
+
let parsed: URL
|
|
103
|
+
try {
|
|
104
|
+
parsed = new URL(url)
|
|
105
|
+
} catch {
|
|
106
|
+
throw new Error('Invalid page URL')
|
|
107
|
+
}
|
|
108
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
109
|
+
throw new Error('Page URL must use HTTP or HTTPS')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const ctrl = new AbortController()
|
|
113
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
|
|
114
|
+
try {
|
|
115
|
+
const res = await fetch(url, {
|
|
116
|
+
headers: { 'user-agent': UA, accept: 'text/html,*/*', 'cache-control': 'no-cache', pragma: 'no-cache' },
|
|
117
|
+
redirect: 'follow',
|
|
118
|
+
cache: 'no-store',
|
|
119
|
+
signal: ctrl.signal,
|
|
120
|
+
tls: tlsOpt(url, opts),
|
|
121
|
+
})
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
await res.body?.cancel().catch(() => {})
|
|
124
|
+
throw new Error(`page returned HTTP ${res.status}`)
|
|
125
|
+
}
|
|
126
|
+
const contentType = (res.headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase()
|
|
127
|
+
if (contentType && contentType !== 'text/html' && contentType !== 'application/xhtml+xml') {
|
|
128
|
+
await res.body?.cancel().catch(() => {})
|
|
129
|
+
throw new Error(`page returned ${contentType}, not HTML`)
|
|
130
|
+
}
|
|
131
|
+
const html = await readCapped(res, MAX_HTML_BYTES)
|
|
132
|
+
return { finalUrl: res.url || url, status: res.status, html }
|
|
133
|
+
} catch (err) {
|
|
134
|
+
throw timeoutError(err, ctrl, timeoutMs)
|
|
135
|
+
} finally {
|
|
136
|
+
clearTimeout(timer)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function probeImage(url: string, base: string, opts: ProbeOpts = {}, timeoutMs = 10_000): Promise<ImageProbe> {
|
|
141
|
+
let resolved = url
|
|
142
|
+
try {
|
|
143
|
+
const parsed = new URL(url, base)
|
|
144
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
145
|
+
return { url, resolved: parsed.toString(), status: 0, ok: false, error: 'Image URL must use HTTP or HTTPS' }
|
|
146
|
+
}
|
|
147
|
+
resolved = parsed.toString()
|
|
148
|
+
} catch {
|
|
149
|
+
return { url, resolved: url, status: 0, ok: false, error: 'Invalid image URL' }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const ctrl = new AbortController()
|
|
153
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
|
|
154
|
+
try {
|
|
155
|
+
const res = await fetch(resolved, {
|
|
156
|
+
headers: { 'user-agent': UA, accept: 'image/*,*/*', 'cache-control': 'no-cache', pragma: 'no-cache' },
|
|
157
|
+
redirect: 'follow',
|
|
158
|
+
cache: 'no-store',
|
|
159
|
+
signal: ctrl.signal,
|
|
160
|
+
tls: tlsOpt(resolved, opts),
|
|
161
|
+
})
|
|
162
|
+
const probe: ImageProbe = {
|
|
163
|
+
url,
|
|
164
|
+
resolved: res.url || resolved,
|
|
165
|
+
status: res.status,
|
|
166
|
+
ok: res.ok,
|
|
167
|
+
contentType: res.headers.get('content-type') ?? undefined,
|
|
168
|
+
}
|
|
169
|
+
if (!res.ok) {
|
|
170
|
+
probe.error = `HTTP ${res.status}`
|
|
171
|
+
return probe
|
|
172
|
+
}
|
|
173
|
+
const { bytes: buf, truncated } = await readCappedBytes(res, MAX_IMAGE_BYTES)
|
|
174
|
+
// Trust Content-Length for the true size when present; otherwise fall back to what we
|
|
175
|
+
// read (which equals the cap when truncated — still enough to trip the "too big" warn).
|
|
176
|
+
const declaredLen = Number(res.headers.get('content-length'))
|
|
177
|
+
probe.byteLength = Number.isFinite(declaredLen) && declaredLen > 0 ? declaredLen : buf.byteLength
|
|
178
|
+
try {
|
|
179
|
+
const dims = imageSize(buf)
|
|
180
|
+
probe.width = dims.width
|
|
181
|
+
probe.height = dims.height
|
|
182
|
+
const detectedMime: Record<string, string> = {
|
|
183
|
+
avif: 'image/avif', gif: 'image/gif', jpg: 'image/jpeg', png: 'image/png',
|
|
184
|
+
svg: 'image/svg+xml', webp: 'image/webp',
|
|
185
|
+
}
|
|
186
|
+
probe.detectedContentType = dims.type ? detectedMime[dims.type] : undefined
|
|
187
|
+
} catch (err) {
|
|
188
|
+
probe.error = `Could not read image dimensions: ${(err as Error).message}`
|
|
189
|
+
}
|
|
190
|
+
// Only build the (potentially multi-MB) base64 data URI when the caller actually
|
|
191
|
+
// renders the HTML preview. issues / facts / --json never embed the image, so
|
|
192
|
+
// skipping the encode saves CPU and peak memory. Skip it too when the body was
|
|
193
|
+
// truncated — a partial buffer would embed a broken image.
|
|
194
|
+
if (opts.withDataUri && !truncated) {
|
|
195
|
+
// Validate the MIME against a strict pattern before embedding into HTML/CSS — a
|
|
196
|
+
// misbehaving server could otherwise propagate junk into the data: URI which then
|
|
197
|
+
// sits inside `style="background-image: url('...')"`.
|
|
198
|
+
const embeddable = new Set(['image/avif', 'image/gif', 'image/jpeg', 'image/png', 'image/webp'])
|
|
199
|
+
const rawMime = (probe.contentType?.split(';')[0] ?? '').trim().toLowerCase()
|
|
200
|
+
const mime = probe.detectedContentType ?? (embeddable.has(rawMime) ? rawMime : undefined) ?? guessMime(resolved)
|
|
201
|
+
if (mime && embeddable.has(mime)) probe.dataUri = `data:${mime};base64,${buf.toString('base64')}`
|
|
202
|
+
}
|
|
203
|
+
return probe
|
|
204
|
+
} catch (err) {
|
|
205
|
+
return {
|
|
206
|
+
url,
|
|
207
|
+
resolved,
|
|
208
|
+
status: 0,
|
|
209
|
+
ok: false,
|
|
210
|
+
error: timeoutError(err, ctrl, timeoutMs).message,
|
|
211
|
+
}
|
|
212
|
+
} finally {
|
|
213
|
+
clearTimeout(timer)
|
|
214
|
+
}
|
|
215
|
+
}
|
package/src/format.ts
ADDED
package/src/host.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Single source of truth for host scope classification.
|
|
2
|
+
//
|
|
3
|
+
// Two policies read from one classifier so fetch behavior and repair-snippet
|
|
4
|
+
// behavior can never drift apart by accident:
|
|
5
|
+
// - fetch keeps working for loopback/local dev targets (the product's job).
|
|
6
|
+
// - repair outputs require a genuinely public host before emitting copy-ready URLs.
|
|
7
|
+
|
|
8
|
+
export type HostScope =
|
|
9
|
+
| 'loopback' // this machine (127.0.0.0/8, ::1, 0.0.0.0)
|
|
10
|
+
| 'local' // development names (*.localhost, *.test, *.local)
|
|
11
|
+
| 'private' // RFC1918, CGNAT, link-local, ULA, and other non-routable ranges
|
|
12
|
+
| 'reserved' // documentation, multicast, benchmark, and other special-purpose ranges
|
|
13
|
+
| 'unspecified' // "::" or an empty host
|
|
14
|
+
| 'public'
|
|
15
|
+
|
|
16
|
+
const LOCAL_NAME_RE = /^(?:[^.]+\.)*(?:localhost|test|local)$/i
|
|
17
|
+
|
|
18
|
+
function parseIpv4(host: string): [number, number, number, number] | undefined {
|
|
19
|
+
if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return undefined
|
|
20
|
+
const parts = host.split('.').map(Number)
|
|
21
|
+
if (parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) return undefined
|
|
22
|
+
return parts as [number, number, number, number]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ipv4Scope([a, b]: [number, number, number, number]): HostScope {
|
|
26
|
+
if (a === 127) return 'loopback'
|
|
27
|
+
if (a === 0) return 'unspecified' // "this network" 0.0.0.0/8 never routes publicly
|
|
28
|
+
if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)) return 'private'
|
|
29
|
+
if (a === 100 && b >= 64 && b <= 127) return 'private' // CGNAT 100.64.0.0/10
|
|
30
|
+
if (a === 169 && b === 254) return 'private' // link-local
|
|
31
|
+
if ((a === 192 && (b === 0 || b === 88)) || (a === 198 && b === 51) || (a === 203 && b === 0)) return 'reserved'
|
|
32
|
+
if (a >= 224) return 'reserved' // multicast + reserved + broadcast
|
|
33
|
+
return 'public'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ipv6Scope(host: string): HostScope {
|
|
37
|
+
const h = host.toLowerCase()
|
|
38
|
+
if (h === '::') return 'unspecified'
|
|
39
|
+
if (h === '::1') return 'loopback'
|
|
40
|
+
// IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible addresses inherit the v4 scope.
|
|
41
|
+
const mapped = /^::ffff:(?:(\d{1,3}(?:\.\d{1,3}){3})|([0-9a-f]{1,4}):([0-9a-f]{1,4}))$/.exec(h)
|
|
42
|
+
if (mapped) {
|
|
43
|
+
if (mapped[1]) {
|
|
44
|
+
const v4 = parseIpv4(mapped[1])
|
|
45
|
+
if (v4) return ipv4Scope(v4)
|
|
46
|
+
} else {
|
|
47
|
+
const hi = parseInt(mapped[2]!, 16)
|
|
48
|
+
const lo = parseInt(mapped[3]!, 16)
|
|
49
|
+
return ipv4Scope([(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff])
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (/^f[cd][0-9a-f]{0,2}:/.test(h)) return 'private' // unique-local fc00::/7
|
|
53
|
+
if (/^fe[89ab][0-9a-f]:/.test(h)) return 'private' // link-local fe80::/10
|
|
54
|
+
if (/^ff[0-9a-f]{2}:/.test(h)) return 'reserved' // multicast ff00::/8
|
|
55
|
+
if (/^2001:db8:/.test(h)) return 'reserved' // documentation
|
|
56
|
+
if (/^100::/.test(h)) return 'reserved' // discard-only
|
|
57
|
+
if (/^64:ff9b:/.test(h)) return 'reserved' // NAT64 well-known prefix (translation infra)
|
|
58
|
+
return 'public'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Classify a URL hostname (brackets on IPv6 literals are tolerated). */
|
|
62
|
+
export function classifyHost(hostname: string): HostScope {
|
|
63
|
+
let host = hostname.trim().replace(/^\[|\]$/g, '').toLowerCase()
|
|
64
|
+
if (!host) return 'unspecified'
|
|
65
|
+
if (host.endsWith('.') && !host.endsWith('..')) host = host.slice(0, -1) // trailing dot FQDN form
|
|
66
|
+
if (LOCAL_NAME_RE.test(host)) return 'local'
|
|
67
|
+
if (host.includes(':')) return ipv6Scope(host)
|
|
68
|
+
const v4 = parseIpv4(host)
|
|
69
|
+
if (v4) return ipv4Scope(v4)
|
|
70
|
+
return 'public'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type HostPolicy = {
|
|
74
|
+
/** Loopback + dev names: the targets a developer runs against locally. */
|
|
75
|
+
isLocalDevHost: boolean
|
|
76
|
+
/** Safe to emit as a copy-ready share URL in repair output. */
|
|
77
|
+
isPublicHost: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One shared classifier; callers pick the policy predicate they need. */
|
|
81
|
+
export function classifyUrlHost(url: string): HostPolicy {
|
|
82
|
+
let hostname = ''
|
|
83
|
+
try {
|
|
84
|
+
hostname = new URL(url).hostname
|
|
85
|
+
} catch {
|
|
86
|
+
return { isLocalDevHost: false, isPublicHost: false }
|
|
87
|
+
}
|
|
88
|
+
const scope = classifyHost(hostname)
|
|
89
|
+
return {
|
|
90
|
+
isLocalDevHost: scope === 'loopback' || scope === 'local',
|
|
91
|
+
isPublicHost: scope === 'public',
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/inputs.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { MetaTags } from './types.ts'
|
|
2
|
+
|
|
3
|
+
// ── twitter:card vocabulary ───────────────────────────────────────────────
|
|
4
|
+
// Shared by the validator, the repair snippet, and the renderer so the set of
|
|
5
|
+
// known card treatments lives in exactly one place.
|
|
6
|
+
|
|
7
|
+
export const CARD_SUMMARY = 'summary'
|
|
8
|
+
export const CARD_SUMMARY_LARGE_IMAGE = 'summary_large_image'
|
|
9
|
+
/** Card types X documents for twitter:card. */
|
|
10
|
+
export const KNOWN_TWITTER_CARDS = [CARD_SUMMARY, CARD_SUMMARY_LARGE_IMAGE, 'app', 'player'] as const
|
|
11
|
+
/** Card treatments this workspace renders faithfully. */
|
|
12
|
+
export const RENDERED_TWITTER_CARDS: ReadonlySet<string> = new Set([CARD_SUMMARY, CARD_SUMMARY_LARGE_IMAGE])
|
|
13
|
+
|
|
14
|
+
export function isKnownTwitterCard(value: string): boolean {
|
|
15
|
+
return (KNOWN_TWITTER_CARDS as readonly string[]).includes(value)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── canonical input fallback policy ───────────────────────────────────────
|
|
19
|
+
// One precedence ladder per surface; CLI output, HTML render, and repair text
|
|
20
|
+
// all resolve through these helpers.
|
|
21
|
+
|
|
22
|
+
export type PlatformName = 'Open Graph' | 'X'
|
|
23
|
+
export type TextField = 'title' | 'description'
|
|
24
|
+
export type InputSource =
|
|
25
|
+
| 'og:title' | 'twitter:title' | '<title>' | 'none'
|
|
26
|
+
| 'og:description' | 'twitter:description' | 'meta description'
|
|
27
|
+
|
|
28
|
+
export type ResolvedInputValue = {
|
|
29
|
+
value?: string
|
|
30
|
+
source: InputSource
|
|
31
|
+
/** True when the winning source is a fallback rather than the platform's own tag. */
|
|
32
|
+
fallback: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const TITLE_SOURCES: Record<PlatformName, Array<{ key: keyof MetaTags; source: InputSource }>> = {
|
|
36
|
+
// Open Graph consumers do not read twitter:* copy as a fallback.
|
|
37
|
+
'Open Graph': [{ key: 'ogTitle', source: 'og:title' }, { key: 'title', source: '<title>' }],
|
|
38
|
+
X: [
|
|
39
|
+
{ key: 'twitterTitle', source: 'twitter:title' },
|
|
40
|
+
{ key: 'ogTitle', source: 'og:title' },
|
|
41
|
+
{ key: 'title', source: '<title>' },
|
|
42
|
+
],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DESCRIPTION_SOURCES: Record<PlatformName, Array<{ key: keyof MetaTags; source: InputSource }>> = {
|
|
46
|
+
'Open Graph': [{ key: 'ogDescription', source: 'og:description' }, { key: 'description', source: 'meta description' }],
|
|
47
|
+
X: [
|
|
48
|
+
{ key: 'twitterDescription', source: 'twitter:description' },
|
|
49
|
+
{ key: 'ogDescription', source: 'og:description' },
|
|
50
|
+
{ key: 'description', source: 'meta description' },
|
|
51
|
+
],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveFrom(m: MetaTags, ladder: Array<{ key: keyof MetaTags; source: InputSource }>): ResolvedInputValue {
|
|
55
|
+
for (const step of ladder) {
|
|
56
|
+
const value = m[step.key]
|
|
57
|
+
if (value) return { value, source: step.source, fallback: step.source !== ladder[0]!.source }
|
|
58
|
+
}
|
|
59
|
+
return { value: undefined, source: 'none', fallback: true }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resolvePlatformInput(m: MetaTags, platform: PlatformName, field: TextField): ResolvedInputValue {
|
|
63
|
+
return field === 'title'
|
|
64
|
+
? resolveFrom(m, TITLE_SOURCES[platform])
|
|
65
|
+
: resolveFrom(m, DESCRIPTION_SOURCES[platform])
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Primary share copy shown at the top of terminal/facts/report output and used
|
|
70
|
+
* as the starting value in the repair snippet: the protocol's own og:* value
|
|
71
|
+
* first, then X-specific copy, then the page-level tag.
|
|
72
|
+
*/
|
|
73
|
+
const PRIMARY_TITLE_LADDER: Array<{ key: keyof MetaTags; source: InputSource }> = [
|
|
74
|
+
{ key: 'ogTitle', source: 'og:title' },
|
|
75
|
+
{ key: 'twitterTitle', source: 'twitter:title' },
|
|
76
|
+
{ key: 'title', source: '<title>' },
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
const PRIMARY_DESCRIPTION_LADDER: Array<{ key: keyof MetaTags; source: InputSource }> = [
|
|
80
|
+
{ key: 'ogDescription', source: 'og:description' },
|
|
81
|
+
{ key: 'twitterDescription', source: 'twitter:description' },
|
|
82
|
+
{ key: 'description', source: 'meta description' },
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Primary share copy shown at the top of terminal/facts/report output and used
|
|
87
|
+
* as the starting value in the repair snippet: the protocol's own og:* value
|
|
88
|
+
* first, then X-specific copy, then the page-level tag.
|
|
89
|
+
*/
|
|
90
|
+
export function resolvePrimaryInput(m: MetaTags, field: TextField): ResolvedInputValue {
|
|
91
|
+
return field === 'title'
|
|
92
|
+
? resolveFrom(m, PRIMARY_TITLE_LADDER)
|
|
93
|
+
: resolveFrom(m, PRIMARY_DESCRIPTION_LADDER)
|
|
94
|
+
}
|