@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/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
@@ -0,0 +1,5 @@
1
+ export function formatBytes(n: number): string {
2
+ if (n < 1024) return `${n} B`
3
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
4
+ return `${(n / 1024 / 1024).toFixed(2)} MB`
5
+ }
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
+ }