@getvitops/utils 6.0.0 → 8.0.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 +248 -13
- package/dist/adc-BFrzpMHq.d.mts +25 -0
- package/dist/adc-BFrzpMHq.d.mts.map +1 -0
- package/dist/adc-D6aw2qgq.mjs +45 -0
- package/dist/adc-D6aw2qgq.mjs.map +1 -0
- package/dist/ads/index.d.mts +0 -1
- package/dist/ads/index.d.mts.map +1 -1
- package/dist/ads/index.mjs +4 -13
- package/dist/ads/index.mjs.map +1 -1
- package/dist/{cloudflare-CD8vwrgj.mjs → cloudflare-CgAsXiqe.mjs} +24 -2
- package/dist/cloudflare-CgAsXiqe.mjs.map +1 -0
- package/dist/color/index.d.mts +0 -3
- package/dist/color/index.d.mts.map +1 -1
- package/dist/color/index.mjs +0 -3
- package/dist/color/index.mjs.map +1 -1
- package/dist/index.d.mts +5 -101
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +5 -145
- package/dist/index.mjs.map +1 -1
- package/dist/indexing/index.d.mts +5 -6
- package/dist/indexing/index.d.mts.map +1 -1
- package/dist/indexing/index.mjs +12 -34
- package/dist/indexing/index.mjs.map +1 -1
- package/dist/json-store-30CFCbZZ.mjs +38 -0
- package/dist/json-store-30CFCbZZ.mjs.map +1 -0
- package/dist/media/index.d.mts +0 -2
- package/dist/media/index.d.mts.map +1 -1
- package/dist/media/index.mjs +4 -20
- package/dist/media/index.mjs.map +1 -1
- package/dist/notify/index.d.mts +1 -2
- package/dist/notify/index.d.mts.map +1 -1
- package/dist/onboarding/index.d.mts +7 -7
- package/dist/onboarding/index.d.mts.map +1 -1
- package/dist/onboarding/index.mjs +19 -38
- package/dist/onboarding/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +154 -0
- package/dist/reviews/index.d.mts.map +1 -0
- package/dist/reviews/index.mjs +218 -0
- package/dist/reviews/index.mjs.map +1 -0
- package/dist/schema/index.d.mts +191 -0
- package/dist/schema/index.d.mts.map +1 -0
- package/dist/schema/index.mjs +241 -0
- package/dist/schema/index.mjs.map +1 -0
- package/dist/security/index.d.mts +48 -0
- package/dist/security/index.d.mts.map +1 -0
- package/dist/security/index.mjs +80 -0
- package/dist/security/index.mjs.map +1 -0
- package/dist/token-Bz_-igw-.d.mts +74 -0
- package/dist/{token--18wN9UR.d.mts.map → token-Bz_-igw-.d.mts.map} +1 -1
- package/dist/{token-BV7tPHh2.mjs → token-DkKC5m23.mjs} +25 -3
- package/dist/{token-BV7tPHh2.mjs.map → token-DkKC5m23.mjs.map} +1 -1
- package/dist/tracking/index.d.mts +1 -1
- package/dist/{types-C3lHaSrU.d.mts → types-CHHjzRrv.d.mts} +1 -2
- package/dist/types-CHHjzRrv.d.mts.map +1 -0
- package/package.json +25 -2
- package/dist/cloudflare-CD8vwrgj.mjs.map +0 -1
- package/dist/token--18wN9UR.d.mts +0 -46
- package/dist/types-C3lHaSrU.d.mts.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/reviews/plan.ts","../../src/reviews/cache.ts","../../src/reviews/gbp.ts"],"sourcesContent":["/**\n * Every decision `vitops reviews` makes, as pure functions.\n *\n * Same split as `../indexing/plan.ts`: no `fetch`, no `fs`, no clock. Which\n * locations to fetch (and why one is blocked), and how a location's reviews are\n * filtered down to what a site actually displays, are decided here — `gbp.ts`\n * and `cache.ts` do what they're told and nothing else. That is what lets Astro's\n * `<ReviewCard>` and a future Bricks renderer both call `selectReviews` instead\n * of each re-implementing \"top 5 by rating, minus the ones already featured\".\n */\nimport type {\n LocationReviews,\n Review,\n ReviewCache,\n ReviewLocationConfig,\n ReviewsConfig,\n} from './types.ts';\n\n/** One location's fetch plan, or the reason it was skipped. */\nexport interface LocationPlan {\n key: string;\n accountId?: string;\n locationId?: string;\n /** Present iff this location will not be fetched. */\n skip?: string;\n}\n\nexport interface ReviewsPlan {\n /** Set when the whole run is refused — nothing is fetched. */\n blocked?: string;\n locations: LocationPlan[];\n notes: string[];\n}\n\nexport interface PlanInput {\n config: ReviewsConfig | undefined;\n /** Location keys with a Google listing configured (`organization.locations[].listings.google`). */\n knownLocations: Set<string>;\n /** `--location`: fetch exactly this one, skipping the rest. */\n onlyLocation?: string | undefined;\n /** Whether a Google credential was resolved. */\n hasCredential: boolean;\n}\n\n/** Decide which locations to fetch, and why any are skipped. */\nexport function plan(input: PlanInput): ReviewsPlan {\n const { config, knownLocations, onlyLocation, hasCredential } = input;\n const notes: string[] = [];\n\n if (!config || Object.keys(config.locations).length === 0) {\n return {\n blocked: 'no site.reviews.locations configured — nothing to fetch',\n locations: [],\n notes,\n };\n }\n\n if (!hasCredential) {\n return {\n blocked:\n 'no Google credential — set VITOPS_GOOGLE_CLIENT_ID / _CLIENT_SECRET / _REFRESH_TOKEN, or run `gcloud auth application-default login`',\n locations: [],\n notes,\n };\n }\n\n const entries = onlyLocation\n ? Object.entries(config.locations).filter(([key]) => key === onlyLocation)\n : Object.entries(config.locations);\n\n if (onlyLocation && entries.length === 0) {\n return {\n blocked: `--location \"${onlyLocation}\" is not in site.reviews.locations`,\n locations: [],\n notes,\n };\n }\n\n const locations: LocationPlan[] = entries.map(([key, loc]) => {\n if (!knownLocations.has(key))\n return {\n key,\n skip: `organization.locations has no entry keyed \"${key}\" with a listings.google id`,\n };\n const accountId = loc.accountId ?? config.accountId;\n if (!accountId)\n return {\n key,\n skip: `no accountId — set site.reviews.accountId or locations.${key}.accountId`,\n };\n return { key, accountId, locationId: loc.locationId };\n });\n\n const blocked = locations.filter((l) => l.skip);\n if (blocked.length) for (const l of blocked) notes.push(`${l.key}: skipped — ${l.skip}`);\n\n return { locations, notes };\n}\n\n/** Render a plan as the human-readable block `--dry` prints. */\nexport function formatPlan(p: ReviewsPlan): string {\n if (p.blocked) return `✗ blocked — ${p.blocked}`;\n const lines: string[] = [];\n const active = p.locations.filter((l) => !l.skip);\n lines.push(`locations ${active.length} of ${p.locations.length} will be fetched`);\n for (const l of active) lines.push(` ${l.key} ${l.accountId}/${l.locationId}`);\n for (const n of p.notes) lines.push(` ! ${n}`);\n return lines.join('\\n');\n}\n\n/**\n * Filter and order one location's reviews for display.\n *\n * Featured ids always win and keep the author's order — an explicit choice\n * overrides any automatic ranking. The remainder is filtered by `minRating`\n * (default 4 — a star-rating widget showing a 1-star review it did not have to\n * is a design choice a config field should not have to spell out every time),\n * sorted, then capped by `limit`.\n */\nexport function selectReviews(reviews: Review[], config: ReviewLocationConfig): Review[] {\n const featuredIds = config.featured ?? [];\n const byId = new Map(reviews.map((r) => [r.id, r]));\n const featured = featuredIds.map((id) => byId.get(id)).filter((r): r is Review => !!r);\n const featuredSet = new Set(featured.map((r) => r.id));\n\n const minRating = config.minRating ?? 4;\n const rest = reviews\n .filter((r) => !featuredSet.has(r.id) && r.rating >= minRating)\n .sort((a, b) =>\n config.sort === 'rating'\n ? b.rating - a.rating\n : new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(),\n );\n\n const combined = [...featured, ...rest];\n return config.limit !== undefined ? combined.slice(0, config.limit) : combined;\n}\n\n/**\n * Merge a fresh fetch into the existing cache.\n *\n * Only the locations actually fetched this run are replaced; every other\n * location's prior reviews pass through untouched. This is what makes a partial\n * failure (one location's fetch errors) non-destructive — `cmdReviews` merges\n * only the locations that succeeded and leaves the rest of the cache alone.\n */\nexport function mergeCache(\n existing: ReviewCache | undefined,\n fetched: Record<string, LocationReviews>,\n): ReviewCache {\n return {\n version: 1,\n locations: { ...existing?.locations, ...fetched },\n };\n}\n","/**\n * The cache — what makes a re-render free and offline.\n *\n * Committed, like `../media/manifest.ts` and for the same reason: reviews change\n * slowly, and a build should not need a live Google credential to render them. The\n * one field that keeps it from being byte-identical across runs is `fetchedAt` per\n * location — acceptable because `vitops reviews` is invoked deliberately\n * (scheduled or manual), not rewritten on every build the way `media-manifest.json`\n * is.\n */\nimport type { LocationReviews, ReviewCache } from './types.ts';\nimport { readVersioned, writeVersioned } from '../json-store.ts';\n\n/** Default location, relative to the consumer's project root. */\nexport const REVIEWS_CACHE_PATH = '.vitops/reviews.json';\n\n/**\n * Read the cache, or `undefined` when there isn't a usable one.\n *\n * A corrupt or wrong-version file reads as absent, exactly `readManifest`'s rule:\n * the alternative — reading it as an empty cache — would be indistinguishable\n * from \"this business genuinely has no reviews\" and silently ship an empty\n * carousel.\n */\nexport function readCache(path: string): ReviewCache | undefined {\n return readVersioned<ReviewCache, 'locations'>(path, 'locations');\n}\n\n/** Build a cache with its keys and each location's reviews in a stable order. */\nexport function toCache(locations: Record<string, LocationReviews>): ReviewCache {\n const sorted: Record<string, LocationReviews> = {};\n for (const key of Object.keys(locations).sort()) {\n const entry = locations[key] as LocationReviews;\n sorted[key] = {\n ...entry,\n reviews: [...entry.reviews].sort((a, b) => a.id.localeCompare(b.id)),\n };\n }\n return { version: 1, locations: sorted };\n}\n\n/** Write the cache, creating its directory. */\nexport function writeCache(path: string, cache: ReviewCache): void {\n writeVersioned(path, cache);\n}\n","/**\n * Google Business Profile — fetching one location's reviews.\n *\n * Reviews live only on the legacy v4 Business Information API\n * (`mybusiness.googleapis.com/v4`); the newer split APIs (Account Management,\n * Business Information v1) never grew a reviews resource. **The calling GCP\n * project must be allowlisted by Google before any call here succeeds** — a\n * one-time access-request form, turnaround measured in days to weeks — so a 403\n * is treated as that known state rather than surfaced as a raw failure. Scope:\n * `SCOPES.businessProfile` (`../google/token.ts`).\n *\n * TODO — re-check when Google migrates the Reviews endpoint off v4 onto a\n * successor API. Endpoint, auth, pagination and the review object's fields may\n * all change; this module is the one place that assumption lives.\n *\n * Auth is the shared exchange in `../google/token.ts`, same as `../indexing/gsc.ts`\n * — no `googleapis` dependency, one REST endpoint.\n */\nimport { googleHeaders, type GoogleAuthLike } from '../google/token.ts';\nimport type { Review } from './types.ts';\n\nconst STAR_RATING: Record<string, number> = {\n ONE: 1,\n TWO: 2,\n THREE: 3,\n FOUR: 4,\n FIVE: 5,\n};\n\ninterface RawReview {\n reviewId: string;\n reviewer?: { displayName?: string; profilePhotoUrl?: string; isAnonymous?: boolean };\n starRating?: string;\n comment?: string;\n createTime: string;\n updateTime: string;\n reviewReply?: { comment: string; updateTime: string };\n}\n\nfunction toReview(raw: RawReview, location: string): Review {\n return {\n id: raw.reviewId,\n location,\n rating: STAR_RATING[raw.starRating ?? ''] ?? 0,\n author: raw.reviewer?.isAnonymous\n ? 'A Google user'\n : (raw.reviewer?.displayName ?? 'A Google user'),\n ...(raw.reviewer?.profilePhotoUrl ? { authorPhoto: raw.reviewer.profilePhotoUrl } : {}),\n ...(raw.comment ? { text: raw.comment } : {}),\n publishedAt: raw.createTime,\n updatedAt: raw.updateTime,\n ...(raw.reviewReply\n ? { reply: { text: raw.reviewReply.comment, at: raw.reviewReply.updateTime } }\n : {}),\n // v4 carries no public review URL on the resource itself. The stable link is\n // `organization.locations[].listings.google` / `sameAs` — the whole profile,\n // not the single review — which is what a \"read more on Google\" affordance\n // should point at instead.\n };\n}\n\n/** One page (or, having paginated, the whole set) of a location's reviews. */\nexport interface FetchReviewsResult {\n ok: boolean;\n reviews: Review[];\n averageRating?: number;\n totalReviewCount?: number;\n status?: number;\n message?: string;\n}\n\n/**\n * Fetch every review for one location, paginating to completion.\n *\n * Never throws — a per-location failure is a structured result so a run across\n * several locations can fetch what it can and report the rest, the same contract\n * `../indexing/gsc.ts`'s `inspectUrl` keeps.\n */\nexport async function fetchReviews(\n auth: GoogleAuthLike,\n accountId: string,\n locationId: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<FetchReviewsResult> {\n const location = locationId.startsWith('locations/') ? locationId : `locations/${locationId}`;\n const account = accountId.startsWith('accounts/') ? accountId : `accounts/${accountId}`;\n const reviews: Review[] = [];\n let averageRating: number | undefined;\n let totalReviewCount: number | undefined;\n let pageToken: string | undefined;\n\n do {\n const url = new URL(`https://mybusiness.googleapis.com/v4/${account}/${location}/reviews`);\n url.searchParams.set('pageSize', '50');\n if (pageToken) url.searchParams.set('pageToken', pageToken);\n\n const res = await fetchImpl(url, { headers: googleHeaders(auth) });\n if (!res.ok) {\n const message =\n res.status === 403\n ? `403 — this GCP project is most likely not allowlisted for the Business Profile API yet ` +\n `(a one-time Google approval, requested separately from every other API here), or the ` +\n `credential is not an owner/manager of ${account}/${location}`\n : `${res.status}: ${await res.text()}`;\n return { ok: false, reviews, status: res.status, message };\n }\n\n const body = (await res.json()) as {\n reviews?: RawReview[];\n averageRating?: number;\n totalReviewCount?: number;\n nextPageToken?: string;\n };\n for (const r of body.reviews ?? []) reviews.push(toReview(r, locationId));\n averageRating = body.averageRating ?? averageRating;\n totalReviewCount = body.totalReviewCount ?? totalReviewCount;\n pageToken = body.nextPageToken;\n } while (pageToken);\n\n return {\n ok: true,\n reviews,\n ...(averageRating !== undefined ? { averageRating } : {}),\n ...(totalReviewCount !== undefined ? { totalReviewCount } : {}),\n };\n}\n"],"mappings":";;;;;;AA6CA,SAAgB,KAAK,OAA+B;CAClD,MAAM,EAAE,QAAQ,gBAAgB,cAAc,kBAAkB;CAChE,MAAM,QAAkB,CAAC;CAEzB,IAAI,CAAC,UAAU,OAAO,KAAK,OAAO,SAAS,CAAC,CAAC,WAAW,GACtD,OAAO;EACL,SAAS;EACT,WAAW,CAAC;EACZ;CACF;CAGF,IAAI,CAAC,eACH,OAAO;EACL,SACE;EACF,WAAW,CAAC;EACZ;CACF;CAGF,MAAM,UAAU,eACZ,OAAO,QAAQ,OAAO,SAAS,CAAC,CAAC,QAAQ,CAAC,SAAS,QAAQ,YAAY,IACvE,OAAO,QAAQ,OAAO,SAAS;CAEnC,IAAI,gBAAgB,QAAQ,WAAW,GACrC,OAAO;EACL,SAAS,eAAe,aAAa;EACrC,WAAW,CAAC;EACZ;CACF;CAGF,MAAM,YAA4B,QAAQ,KAAK,CAAC,KAAK,SAAS;EAC5D,IAAI,CAAC,eAAe,IAAI,GAAG,GACzB,OAAO;GACL;GACA,MAAM,8CAA8C,IAAI;EAC1D;EACF,MAAM,YAAY,IAAI,aAAa,OAAO;EAC1C,IAAI,CAAC,WACH,OAAO;GACL;GACA,MAAM,0DAA0D,IAAI;EACtE;EACF,OAAO;GAAE;GAAK;GAAW,YAAY,IAAI;EAAW;CACtD,CAAC;CAED,MAAM,UAAU,UAAU,QAAQ,MAAM,EAAE,IAAI;CAC9C,IAAI,QAAQ,QAAQ,KAAK,MAAM,KAAK,SAAS,MAAM,KAAK,GAAG,EAAE,IAAI,cAAc,EAAE,MAAM;CAEvF,OAAO;EAAE;EAAW;CAAM;AAC5B;;AAGA,SAAgB,WAAW,GAAwB;CACjD,IAAI,EAAE,SAAS,OAAO,eAAe,EAAE;CACvC,MAAM,QAAkB,CAAC;CACzB,MAAM,SAAS,EAAE,UAAU,QAAQ,MAAM,CAAC,EAAE,IAAI;CAChD,MAAM,KAAK,cAAc,OAAO,OAAO,MAAM,EAAE,UAAU,OAAO,iBAAiB;CACjF,KAAK,MAAM,KAAK,QAAQ,MAAM,KAAK,KAAK,EAAE,IAAI,IAAI,EAAE,UAAU,GAAG,EAAE,YAAY;CAC/E,KAAK,MAAM,KAAK,EAAE,OAAO,MAAM,KAAK,OAAO,GAAG;CAC9C,OAAO,MAAM,KAAK,IAAI;AACxB;;;;;;;;;;AAWA,SAAgB,cAAc,SAAmB,QAAwC;CACvF,MAAM,cAAc,OAAO,YAAY,CAAC;CACxC,MAAM,OAAO,IAAI,IAAI,QAAQ,KAAK,MAAM,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC;CAClD,MAAM,WAAW,YAAY,KAAK,OAAO,KAAK,IAAI,EAAE,CAAC,CAAC,CAAC,QAAQ,MAAmB,CAAC,CAAC,CAAC;CACrF,MAAM,cAAc,IAAI,IAAI,SAAS,KAAK,MAAM,EAAE,EAAE,CAAC;CAErD,MAAM,YAAY,OAAO,aAAa;CACtC,MAAM,OAAO,QACV,QAAQ,MAAM,CAAC,YAAY,IAAI,EAAE,EAAE,KAAK,EAAE,UAAU,SAAS,CAAC,CAC9D,MAAM,GAAG,MACR,OAAO,SAAS,WACZ,EAAE,SAAS,EAAE,SACb,IAAI,KAAK,EAAE,WAAW,CAAC,CAAC,QAAQ,IAAI,IAAI,KAAK,EAAE,WAAW,CAAC,CAAC,QAAQ,CAC1E;CAEF,MAAM,WAAW,CAAC,GAAG,UAAU,GAAG,IAAI;CACtC,OAAO,OAAO,UAAU,SAAY,SAAS,MAAM,GAAG,OAAO,KAAK,IAAI;AACxE;;;;;;;;;AAUA,SAAgB,WACd,UACA,SACa;CACb,OAAO;EACL,SAAS;EACT,WAAW;GAAE,GAAG,UAAU;GAAW,GAAG;EAAQ;CAClD;AACF;;;;;AC5IA,MAAa,qBAAqB;;;;;;;;;AAUlC,SAAgB,UAAU,MAAuC;CAC/D,OAAO,cAAwC,MAAM,WAAW;AAClE;;AAGA,SAAgB,QAAQ,WAAyD;CAC/E,MAAM,SAA0C,CAAC;CACjD,KAAK,MAAM,OAAO,OAAO,KAAK,SAAS,CAAC,CAAC,KAAK,GAAG;EAC/C,MAAM,QAAQ,UAAU;EACxB,OAAO,OAAO;GACZ,GAAG;GACH,SAAS,CAAC,GAAG,MAAM,OAAO,CAAC,CAAC,MAAM,GAAG,MAAM,EAAE,GAAG,cAAc,EAAE,EAAE,CAAC;EACrE;CACF;CACA,OAAO;EAAE,SAAS;EAAG,WAAW;CAAO;AACzC;;AAGA,SAAgB,WAAW,MAAc,OAA0B;CACjE,eAAe,MAAM,KAAK;AAC5B;;;;;;;;;;;;;;;;;;;;;;ACvBA,MAAM,cAAsC;CAC1C,KAAK;CACL,KAAK;CACL,OAAO;CACP,MAAM;CACN,MAAM;AACR;AAYA,SAAS,SAAS,KAAgB,UAA0B;CAC1D,OAAO;EACL,IAAI,IAAI;EACR;EACA,QAAQ,YAAY,IAAI,cAAc,OAAO;EAC7C,QAAQ,IAAI,UAAU,cAClB,kBACC,IAAI,UAAU,eAAe;EAClC,GAAI,IAAI,UAAU,kBAAkB,EAAE,aAAa,IAAI,SAAS,gBAAgB,IAAI,CAAC;EACrF,GAAI,IAAI,UAAU,EAAE,MAAM,IAAI,QAAQ,IAAI,CAAC;EAC3C,aAAa,IAAI;EACjB,WAAW,IAAI;EACf,GAAI,IAAI,cACJ,EAAE,OAAO;GAAE,MAAM,IAAI,YAAY;GAAS,IAAI,IAAI,YAAY;EAAW,EAAE,IAC3E,CAAC;CAKP;AACF;;;;;;;;AAmBA,eAAsB,aACpB,MACA,WACA,YACA,YAA0B,OACG;CAC7B,MAAM,WAAW,WAAW,WAAW,YAAY,IAAI,aAAa,aAAa;CACjF,MAAM,UAAU,UAAU,WAAW,WAAW,IAAI,YAAY,YAAY;CAC5E,MAAM,UAAoB,CAAC;CAC3B,IAAI;CACJ,IAAI;CACJ,IAAI;CAEJ,GAAG;EACD,MAAM,MAAM,IAAI,IAAI,wCAAwC,QAAQ,GAAG,SAAS,SAAS;EACzF,IAAI,aAAa,IAAI,YAAY,IAAI;EACrC,IAAI,WAAW,IAAI,aAAa,IAAI,aAAa,SAAS;EAE1D,MAAM,MAAM,MAAM,UAAU,KAAK,EAAE,SAAS,cAAc,IAAI,EAAE,CAAC;EACjE,IAAI,CAAC,IAAI,IAAI;GACX,MAAM,UACJ,IAAI,WAAW,MACX,qNAEyC,QAAQ,GAAG,aACpD,GAAG,IAAI,OAAO,IAAI,MAAM,IAAI,KAAK;GACvC,OAAO;IAAE,IAAI;IAAO;IAAS,QAAQ,IAAI;IAAQ;GAAQ;EAC3D;EAEA,MAAM,OAAQ,MAAM,IAAI,KAAK;EAM7B,KAAK,MAAM,KAAK,KAAK,WAAW,CAAC,GAAG,QAAQ,KAAK,SAAS,GAAG,UAAU,CAAC;EACxE,gBAAgB,KAAK,iBAAiB;EACtC,mBAAmB,KAAK,oBAAoB;EAC5C,YAAY,KAAK;CACnB,SAAS;CAET,OAAO;EACL,IAAI;EACJ;EACA,GAAI,kBAAkB,SAAY,EAAE,cAAc,IAAI,CAAC;EACvD,GAAI,qBAAqB,SAAY,EAAE,iBAAiB,IAAI,CAAC;CAC/D;AACF"}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
//#region src/schema/article.d.ts
|
|
2
|
+
interface ArticleAuthor {
|
|
3
|
+
name: string;
|
|
4
|
+
url?: string;
|
|
5
|
+
}
|
|
6
|
+
interface ArticleGraphOptions {
|
|
7
|
+
headline: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
image?: string | string[];
|
|
10
|
+
datePublished: string;
|
|
11
|
+
dateModified?: string;
|
|
12
|
+
author: ArticleAuthor | ArticleAuthor[];
|
|
13
|
+
publisher?: {
|
|
14
|
+
name: string;
|
|
15
|
+
logo?: string;
|
|
16
|
+
};
|
|
17
|
+
mainEntityOfPage?: string;
|
|
18
|
+
articleSection?: string;
|
|
19
|
+
wordCount?: number;
|
|
20
|
+
keywords?: string[];
|
|
21
|
+
}
|
|
22
|
+
/** Build a schema.org Article JSON-LD graph. */
|
|
23
|
+
declare function articleGraph(options: ArticleGraphOptions): Record<string, unknown>;
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/schema/breadcrumb.d.ts
|
|
26
|
+
interface BreadcrumbItem {
|
|
27
|
+
name: string;
|
|
28
|
+
url: string;
|
|
29
|
+
}
|
|
30
|
+
interface BreadcrumbGraphOptions {
|
|
31
|
+
items: BreadcrumbItem[];
|
|
32
|
+
}
|
|
33
|
+
/** Build a schema.org BreadcrumbList JSON-LD graph. */
|
|
34
|
+
declare function breadcrumbGraph(options: BreadcrumbGraphOptions): Record<string, unknown>;
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/schema/faq.d.ts
|
|
37
|
+
interface FAQItem {
|
|
38
|
+
question: string;
|
|
39
|
+
answer: string;
|
|
40
|
+
}
|
|
41
|
+
interface FAQGraphOptions {
|
|
42
|
+
items: FAQItem[];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Build a schema.org FAQPage JSON-LD graph.
|
|
46
|
+
*
|
|
47
|
+
* NOTE: Google recommends FAQ markup only for authoritative government
|
|
48
|
+
* and health sites. For other sites, consider using QAPage schema instead.
|
|
49
|
+
*/
|
|
50
|
+
declare function faqGraph(options: FAQGraphOptions): Record<string, unknown>;
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/schema/itemList.d.ts
|
|
53
|
+
type ItemListEntryType = 'Article' | 'Recipe' | 'Movie' | 'Course' | 'Restaurant' | 'Product' | (string & {});
|
|
54
|
+
interface ItemListEntry {
|
|
55
|
+
type: ItemListEntryType;
|
|
56
|
+
name: string;
|
|
57
|
+
/**
|
|
58
|
+
* Summary-page format: the item's own detail page (unique, same domain).
|
|
59
|
+
* All-in-one-page format: this page's URL plus an HTML anchor pointing at
|
|
60
|
+
* the item on it.
|
|
61
|
+
*/
|
|
62
|
+
url: string;
|
|
63
|
+
image?: string;
|
|
64
|
+
description?: string;
|
|
65
|
+
}
|
|
66
|
+
interface ItemListGraphOptions {
|
|
67
|
+
items: ItemListEntry[];
|
|
68
|
+
/** Recommended, not required by the spec — names the list itself. */
|
|
69
|
+
name?: string;
|
|
70
|
+
}
|
|
71
|
+
/** Build a schema.org ItemList JSON-LD graph (e.g. for a carousel rich result). */
|
|
72
|
+
declare function itemListGraph(options: ItemListGraphOptions): Record<string, unknown>;
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/schema/localBusiness.d.ts
|
|
75
|
+
interface OpeningHoursSpecification {
|
|
76
|
+
dayOfWeek: ('Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday' | 'Saturday' | 'Sunday')[];
|
|
77
|
+
opens: string;
|
|
78
|
+
closes: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* A dated deviation from the recurring `openingHoursSpecification` — holiday
|
|
82
|
+
* hours, a one-off closure, a temporary schedule. Same schema.org shape
|
|
83
|
+
* (`OpeningHoursSpecification`), distinguished by carrying `validFrom`. A full
|
|
84
|
+
* closure omits `opens`/`closes` rather than encoding it as an open interval.
|
|
85
|
+
*/
|
|
86
|
+
interface SpecialHoursSpecification {
|
|
87
|
+
validFrom: string;
|
|
88
|
+
validThrough?: string;
|
|
89
|
+
opens?: string;
|
|
90
|
+
closes?: string;
|
|
91
|
+
}
|
|
92
|
+
interface LocalBusinessGraphOptions {
|
|
93
|
+
/** schema.org `LocalBusiness` subtype, e.g. `'Restaurant'`, `'ProfessionalService'`. Defaults to `'LocalBusiness'`. */
|
|
94
|
+
type?: string;
|
|
95
|
+
name: string;
|
|
96
|
+
description?: string;
|
|
97
|
+
url?: string;
|
|
98
|
+
telephone?: string;
|
|
99
|
+
email?: string;
|
|
100
|
+
image?: string | string[];
|
|
101
|
+
priceRange?: string;
|
|
102
|
+
address?: {
|
|
103
|
+
streetAddress?: string;
|
|
104
|
+
addressLocality?: string;
|
|
105
|
+
addressRegion?: string;
|
|
106
|
+
postalCode?: string;
|
|
107
|
+
addressCountry?: string;
|
|
108
|
+
};
|
|
109
|
+
geo?: {
|
|
110
|
+
latitude: number;
|
|
111
|
+
longitude: number;
|
|
112
|
+
};
|
|
113
|
+
openingHoursSpecification?: OpeningHoursSpecification[];
|
|
114
|
+
specialOpeningHoursSpecification?: SpecialHoursSpecification[];
|
|
115
|
+
aggregateRating?: {
|
|
116
|
+
ratingValue: number;
|
|
117
|
+
reviewCount?: number;
|
|
118
|
+
ratingCount?: number;
|
|
119
|
+
bestRating?: number;
|
|
120
|
+
worstRating?: number;
|
|
121
|
+
};
|
|
122
|
+
review?: {
|
|
123
|
+
author: string;
|
|
124
|
+
datePublished: string;
|
|
125
|
+
reviewBody: string;
|
|
126
|
+
reviewRating?: {
|
|
127
|
+
ratingValue: number;
|
|
128
|
+
bestRating?: number;
|
|
129
|
+
worstRating?: number;
|
|
130
|
+
};
|
|
131
|
+
}[];
|
|
132
|
+
paymentAccepted?: string[];
|
|
133
|
+
currenciesAccepted?: string;
|
|
134
|
+
areaServed?: string[];
|
|
135
|
+
sameAs?: string[];
|
|
136
|
+
knowsLanguage?: string[];
|
|
137
|
+
}
|
|
138
|
+
/** Build a schema.org LocalBusiness (or subtype) JSON-LD graph. */
|
|
139
|
+
declare function localBusinessGraph(options: LocalBusinessGraphOptions): Record<string, unknown>;
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/schema/organization.d.ts
|
|
142
|
+
interface OrganizationGraphOptions {
|
|
143
|
+
name: string;
|
|
144
|
+
url?: string;
|
|
145
|
+
logo?: string;
|
|
146
|
+
description?: string;
|
|
147
|
+
email?: string;
|
|
148
|
+
telephone?: string;
|
|
149
|
+
address?: {
|
|
150
|
+
streetAddress?: string;
|
|
151
|
+
addressLocality?: string;
|
|
152
|
+
addressRegion?: string;
|
|
153
|
+
postalCode?: string;
|
|
154
|
+
addressCountry?: string;
|
|
155
|
+
};
|
|
156
|
+
sameAs?: string[];
|
|
157
|
+
foundingDate?: string;
|
|
158
|
+
founders?: string[];
|
|
159
|
+
numberOfEmployees?: number | {
|
|
160
|
+
minValue: number;
|
|
161
|
+
maxValue: number;
|
|
162
|
+
};
|
|
163
|
+
parentOrganization?: {
|
|
164
|
+
name: string;
|
|
165
|
+
url?: string;
|
|
166
|
+
};
|
|
167
|
+
subOrganization?: {
|
|
168
|
+
name: string;
|
|
169
|
+
url?: string;
|
|
170
|
+
}[];
|
|
171
|
+
contactPoint?: {
|
|
172
|
+
type?: 'customer service' | 'technical support' | 'sales' | 'billing support' | 'reservations';
|
|
173
|
+
telephone: string;
|
|
174
|
+
email?: string;
|
|
175
|
+
contactType: string;
|
|
176
|
+
availableLanguage?: string[];
|
|
177
|
+
areaServed?: string[];
|
|
178
|
+
}[];
|
|
179
|
+
areaServed?: string[];
|
|
180
|
+
brand?: string;
|
|
181
|
+
award?: string[];
|
|
182
|
+
memberOf?: {
|
|
183
|
+
name: string;
|
|
184
|
+
url?: string;
|
|
185
|
+
}[];
|
|
186
|
+
}
|
|
187
|
+
/** Build a schema.org Organization JSON-LD graph. */
|
|
188
|
+
declare function organizationGraph(options: OrganizationGraphOptions): Record<string, unknown>;
|
|
189
|
+
//#endregion
|
|
190
|
+
export { type ArticleAuthor, type ArticleGraphOptions, type BreadcrumbGraphOptions, type BreadcrumbItem, type FAQGraphOptions, type FAQItem, type ItemListEntry, type ItemListEntryType, type ItemListGraphOptions, type LocalBusinessGraphOptions, type OpeningHoursSpecification, type OrganizationGraphOptions, type SpecialHoursSpecification, articleGraph, breadcrumbGraph, faqGraph, itemListGraph, localBusinessGraph, organizationGraph };
|
|
191
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/schema/article.ts","../../src/schema/breadcrumb.ts","../../src/schema/faq.ts","../../src/schema/itemList.ts","../../src/schema/localBusiness.ts","../../src/schema/organization.ts"],"mappings":";UAAiB,aAAA;EACf,IAAA;EACA,GAAG;AAAA;AAAA,UAGY,mBAAA;EACf,QAAA;EACA,WAAA;EACA,KAAA;EACA,aAAA;EACA,YAAA;EACA,MAAA,EAAQ,aAAA,GAAgB,aAAa;EACrC,SAAA;IACE,IAAA;IACA,IAAA;EAAA;EAEF,gBAAA;EACA,cAAA;EACA,SAAA;EACA,QAAA;AAAA;;iBAIc,YAAA,CAAa,OAAA,EAAS,mBAAA,GAAsB,MAAM;;;UCvBjD,cAAA;EACf,IAAA;EACA,GAAG;AAAA;AAAA,UAGY,sBAAA;EACf,KAAA,EAAO,cAAc;AAAA;;iBAIP,eAAA,CAAgB,OAAA,EAAS,sBAAA,GAAyB,MAAM;;;UCVvD,OAAA;EACf,QAAA;EACA,MAAM;AAAA;AAAA,UAGS,eAAA;EACf,KAAA,EAAO,OAAO;AAAA;;;;;;;iBASA,QAAA,CAAS,OAAA,EAAS,eAAA,GAAkB,MAAM;;;KCf9C,iBAAA;AAAA,UAWK,aAAA;EACf,IAAA,EAAM,iBAAiB;EACvB,IAAA;EHZA;AACG;AAGL;;;EGcE,GAAA;EACA,KAAA;EACA,WAAA;AAAA;AAAA,UAGe,oBAAA;EACf,KAAA,EAAO,aAAa;EHdpB;EGgBA,IAAA;AAAA;;iBAIc,aAAA,CAAc,OAAA,EAAS,oBAAA,GAAuB,MAAM;;;UC/BnD,yBAAA;EACf,SAAA;EACA,KAAA;EACA,MAAA;AAAA;AJDG;AAGL;;;;;AAHK,UIUY,yBAAA;EACf,SAAA;EACA,YAAA;EACA,KAAA;EACA,MAAA;AAAA;AAAA,UAGe,yBAAA;EJPf;EISA,IAAA;EACA,IAAA;EACA,WAAA;EACA,GAAA;EACA,SAAA;EACA,KAAA;EACA,KAAA;EACA,UAAA;EACA,OAAA;IACE,aAAA;IACA,eAAA;IACA,aAAA;IACA,UAAA;IACA,cAAA;EAAA;EAEF,GAAA;IACE,QAAA;IACA,SAAA;EAAA;EAEF,yBAAA,GAA4B,yBAAA;EAC5B,gCAAA,GAAmC,yBAAyB;EAC5D,eAAA;IACE,WAAA;IACA,WAAA;IACA,WAAA;IACA,UAAA;IACA,WAAA;EAAA;EAEF,MAAA;IACE,MAAA;IACA,aAAA;IACA,UAAA;IACA,YAAA;MACE,WAAA;MACA,UAAA;MACA,WAAA;IAAA;EAAA;EAGJ,eAAA;EACA,kBAAA;EACA,UAAA;EACA,MAAA;EACA,aAAA;AAAA;;iBAIc,kBAAA,CAAmB,OAAA,EAAS,yBAAA,GAA4B,MAAM;;;UCnE7D,wBAAA;EACf,IAAA;EACA,GAAA;EACA,IAAA;EACA,WAAA;EACA,KAAA;EACA,SAAA;EACA,OAAA;IACE,aAAA;IACA,eAAA;IACA,aAAA;IACA,UAAA;IACA,cAAA;EAAA;EAEF,MAAA;EACA,YAAA;EACA,QAAA;EACA,iBAAA;IAA+B,QAAA;IAAkB,QAAA;EAAA;EACjD,kBAAA;IACE,IAAA;IACA,GAAA;EAAA;EAEF,eAAA;IACE,IAAA;IACA,GAAA;EAAA;EAEF,YAAA;IACE,IAAA;IACA,SAAA;IACA,KAAA;IACA,WAAA;IACA,iBAAA;IACA,UAAA;EAAA;EAEF,UAAA;EACA,KAAA;EACA,KAAA;EACA,QAAA;IACE,IAAA;IACA,GAAA;EAAA;AAAA;;iBAKY,iBAAA,CAAkB,OAAA,EAAS,wBAAA,GAA2B,MAAM"}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
//#region src/schema/article.ts
|
|
2
|
+
/** Build a schema.org Article JSON-LD graph. */
|
|
3
|
+
function articleGraph(options) {
|
|
4
|
+
const { headline, description, image, datePublished, dateModified, author, publisher, mainEntityOfPage, articleSection, wordCount, keywords } = options;
|
|
5
|
+
const authors = Array.isArray(author) ? author : [author];
|
|
6
|
+
return {
|
|
7
|
+
"@context": "https://schema.org",
|
|
8
|
+
"@type": "Article",
|
|
9
|
+
headline,
|
|
10
|
+
...description && { description },
|
|
11
|
+
...image && { image: Array.isArray(image) ? image : [image] },
|
|
12
|
+
datePublished,
|
|
13
|
+
...dateModified && { dateModified },
|
|
14
|
+
author: authors.map((a) => ({
|
|
15
|
+
"@type": "Person",
|
|
16
|
+
name: a.name,
|
|
17
|
+
...a.url && { url: a.url }
|
|
18
|
+
})),
|
|
19
|
+
...publisher && { publisher: {
|
|
20
|
+
"@type": "Organization",
|
|
21
|
+
name: publisher.name,
|
|
22
|
+
...publisher.logo && { logo: {
|
|
23
|
+
"@type": "ImageObject",
|
|
24
|
+
url: publisher.logo
|
|
25
|
+
} }
|
|
26
|
+
} },
|
|
27
|
+
...mainEntityOfPage && { mainEntityOfPage: {
|
|
28
|
+
"@type": "WebPage",
|
|
29
|
+
"@id": mainEntityOfPage
|
|
30
|
+
} },
|
|
31
|
+
...articleSection && { articleSection },
|
|
32
|
+
...wordCount && { wordCount },
|
|
33
|
+
...keywords && { keywords: keywords.join(", ") }
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/schema/breadcrumb.ts
|
|
39
|
+
/** Build a schema.org BreadcrumbList JSON-LD graph. */
|
|
40
|
+
function breadcrumbGraph(options) {
|
|
41
|
+
return {
|
|
42
|
+
"@context": "https://schema.org",
|
|
43
|
+
"@type": "BreadcrumbList",
|
|
44
|
+
itemListElement: options.items.map((item, index) => ({
|
|
45
|
+
"@type": "ListItem",
|
|
46
|
+
position: index + 1,
|
|
47
|
+
name: item.name,
|
|
48
|
+
item: item.url
|
|
49
|
+
}))
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/schema/faq.ts
|
|
55
|
+
/**
|
|
56
|
+
* Build a schema.org FAQPage JSON-LD graph.
|
|
57
|
+
*
|
|
58
|
+
* NOTE: Google recommends FAQ markup only for authoritative government
|
|
59
|
+
* and health sites. For other sites, consider using QAPage schema instead.
|
|
60
|
+
*/
|
|
61
|
+
function faqGraph(options) {
|
|
62
|
+
return {
|
|
63
|
+
"@context": "https://schema.org",
|
|
64
|
+
"@type": "FAQPage",
|
|
65
|
+
mainEntity: options.items.map((item) => ({
|
|
66
|
+
"@type": "Question",
|
|
67
|
+
name: item.question,
|
|
68
|
+
acceptedAnswer: {
|
|
69
|
+
"@type": "Answer",
|
|
70
|
+
text: item.answer
|
|
71
|
+
}
|
|
72
|
+
}))
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
//#endregion
|
|
77
|
+
//#region src/schema/itemList.ts
|
|
78
|
+
/** Build a schema.org ItemList JSON-LD graph (e.g. for a carousel rich result). */
|
|
79
|
+
function itemListGraph(options) {
|
|
80
|
+
const { items, name } = options;
|
|
81
|
+
return {
|
|
82
|
+
"@context": "https://schema.org",
|
|
83
|
+
"@type": "ItemList",
|
|
84
|
+
...name && { name },
|
|
85
|
+
itemListElement: items.map((item, index) => ({
|
|
86
|
+
"@type": "ListItem",
|
|
87
|
+
position: index + 1,
|
|
88
|
+
item: {
|
|
89
|
+
"@type": item.type,
|
|
90
|
+
name: item.name,
|
|
91
|
+
url: item.url,
|
|
92
|
+
...item.image && { image: item.image },
|
|
93
|
+
...item.description && { description: item.description }
|
|
94
|
+
}
|
|
95
|
+
}))
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/schema/localBusiness.ts
|
|
101
|
+
/** Build a schema.org LocalBusiness (or subtype) JSON-LD graph. */
|
|
102
|
+
function localBusinessGraph(options) {
|
|
103
|
+
const { type = "LocalBusiness", name, description, url, telephone, email, image, priceRange, address, geo, openingHoursSpecification, specialOpeningHoursSpecification, aggregateRating, review, paymentAccepted, currenciesAccepted, areaServed, sameAs, knowsLanguage } = options;
|
|
104
|
+
const hours = [...(openingHoursSpecification ?? []).map((spec) => ({
|
|
105
|
+
"@type": "OpeningHoursSpecification",
|
|
106
|
+
dayOfWeek: spec.dayOfWeek,
|
|
107
|
+
opens: spec.opens,
|
|
108
|
+
closes: spec.closes
|
|
109
|
+
})), ...(specialOpeningHoursSpecification ?? []).map((spec) => ({
|
|
110
|
+
"@type": "OpeningHoursSpecification",
|
|
111
|
+
validFrom: spec.validFrom,
|
|
112
|
+
...spec.validThrough && { validThrough: spec.validThrough },
|
|
113
|
+
...spec.opens && { opens: spec.opens },
|
|
114
|
+
...spec.closes && { closes: spec.closes }
|
|
115
|
+
}))];
|
|
116
|
+
return {
|
|
117
|
+
"@context": "https://schema.org",
|
|
118
|
+
"@type": type,
|
|
119
|
+
name,
|
|
120
|
+
...description && { description },
|
|
121
|
+
...url && { url },
|
|
122
|
+
...telephone && { telephone },
|
|
123
|
+
...email && { email },
|
|
124
|
+
...image && { image: Array.isArray(image) ? image : [image] },
|
|
125
|
+
...priceRange && { priceRange },
|
|
126
|
+
...address && { address: {
|
|
127
|
+
"@type": "PostalAddress",
|
|
128
|
+
...address.streetAddress && { streetAddress: address.streetAddress },
|
|
129
|
+
...address.addressLocality && { addressLocality: address.addressLocality },
|
|
130
|
+
...address.addressRegion && { addressRegion: address.addressRegion },
|
|
131
|
+
...address.postalCode && { postalCode: address.postalCode },
|
|
132
|
+
...address.addressCountry && { addressCountry: address.addressCountry }
|
|
133
|
+
} },
|
|
134
|
+
...geo && { geo: {
|
|
135
|
+
"@type": "GeoCoordinates",
|
|
136
|
+
latitude: geo.latitude,
|
|
137
|
+
longitude: geo.longitude
|
|
138
|
+
} },
|
|
139
|
+
...hours.length > 0 && { openingHoursSpecification: hours },
|
|
140
|
+
...aggregateRating && { aggregateRating: {
|
|
141
|
+
"@type": "AggregateRating",
|
|
142
|
+
ratingValue: aggregateRating.ratingValue,
|
|
143
|
+
...aggregateRating.reviewCount && { reviewCount: aggregateRating.reviewCount },
|
|
144
|
+
...aggregateRating.ratingCount && { ratingCount: aggregateRating.ratingCount },
|
|
145
|
+
bestRating: aggregateRating.bestRating ?? 5,
|
|
146
|
+
worstRating: aggregateRating.worstRating ?? 1
|
|
147
|
+
} },
|
|
148
|
+
...review && { review: review.map((r) => ({
|
|
149
|
+
"@type": "Review",
|
|
150
|
+
author: {
|
|
151
|
+
"@type": "Person",
|
|
152
|
+
name: r.author
|
|
153
|
+
},
|
|
154
|
+
datePublished: r.datePublished,
|
|
155
|
+
reviewBody: r.reviewBody,
|
|
156
|
+
...r.reviewRating && { reviewRating: {
|
|
157
|
+
"@type": "Rating",
|
|
158
|
+
ratingValue: r.reviewRating.ratingValue,
|
|
159
|
+
bestRating: r.reviewRating.bestRating ?? 5,
|
|
160
|
+
worstRating: r.reviewRating.worstRating ?? 1
|
|
161
|
+
} }
|
|
162
|
+
})) },
|
|
163
|
+
...paymentAccepted && { paymentAccepted },
|
|
164
|
+
...currenciesAccepted && { currenciesAccepted },
|
|
165
|
+
...areaServed && { areaServed },
|
|
166
|
+
...sameAs && { sameAs },
|
|
167
|
+
...knowsLanguage && { knowsLanguage }
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
//#endregion
|
|
172
|
+
//#region src/schema/organization.ts
|
|
173
|
+
/** Build a schema.org Organization JSON-LD graph. */
|
|
174
|
+
function organizationGraph(options) {
|
|
175
|
+
const { name, url, logo, description, email, telephone, address, sameAs, foundingDate, founders, numberOfEmployees, parentOrganization, subOrganization, contactPoint, areaServed, brand, award, memberOf } = options;
|
|
176
|
+
return {
|
|
177
|
+
"@context": "https://schema.org",
|
|
178
|
+
"@type": "Organization",
|
|
179
|
+
name,
|
|
180
|
+
...url && { url },
|
|
181
|
+
...logo && { logo },
|
|
182
|
+
...description && { description },
|
|
183
|
+
...email && { email },
|
|
184
|
+
...telephone && { telephone },
|
|
185
|
+
...address && { address: {
|
|
186
|
+
"@type": "PostalAddress",
|
|
187
|
+
...address.streetAddress && { streetAddress: address.streetAddress },
|
|
188
|
+
...address.addressLocality && { addressLocality: address.addressLocality },
|
|
189
|
+
...address.addressRegion && { addressRegion: address.addressRegion },
|
|
190
|
+
...address.postalCode && { postalCode: address.postalCode },
|
|
191
|
+
...address.addressCountry && { addressCountry: address.addressCountry }
|
|
192
|
+
} },
|
|
193
|
+
...sameAs && { sameAs },
|
|
194
|
+
...foundingDate && { foundingDate },
|
|
195
|
+
...founders && { founders: founders.map((f) => ({
|
|
196
|
+
"@type": "Person",
|
|
197
|
+
name: f
|
|
198
|
+
})) },
|
|
199
|
+
...numberOfEmployees && { numberOfEmployees: typeof numberOfEmployees === "number" ? {
|
|
200
|
+
"@type": "QuantitativeValue",
|
|
201
|
+
value: numberOfEmployees
|
|
202
|
+
} : {
|
|
203
|
+
"@type": "QuantitativeValue",
|
|
204
|
+
minValue: numberOfEmployees.minValue,
|
|
205
|
+
maxValue: numberOfEmployees.maxValue
|
|
206
|
+
} },
|
|
207
|
+
...parentOrganization && { parentOrganization: {
|
|
208
|
+
"@type": "Organization",
|
|
209
|
+
name: parentOrganization.name,
|
|
210
|
+
...parentOrganization.url && { url: parentOrganization.url }
|
|
211
|
+
} },
|
|
212
|
+
...subOrganization && { subOrganization: subOrganization.map((sub) => ({
|
|
213
|
+
"@type": "Organization",
|
|
214
|
+
name: sub.name,
|
|
215
|
+
...sub.url && { url: sub.url }
|
|
216
|
+
})) },
|
|
217
|
+
...contactPoint && { contactPoint: contactPoint.map((cp) => ({
|
|
218
|
+
"@type": "ContactPoint",
|
|
219
|
+
telephone: cp.telephone,
|
|
220
|
+
contactType: cp.contactType,
|
|
221
|
+
...cp.email && { email: cp.email },
|
|
222
|
+
...cp.availableLanguage && { availableLanguage: cp.availableLanguage },
|
|
223
|
+
...cp.areaServed && { areaServed: cp.areaServed }
|
|
224
|
+
})) },
|
|
225
|
+
...areaServed && { areaServed },
|
|
226
|
+
...brand && { brand: {
|
|
227
|
+
"@type": "Brand",
|
|
228
|
+
name: brand
|
|
229
|
+
} },
|
|
230
|
+
...award && { award },
|
|
231
|
+
...memberOf && { memberOf: memberOf.map((m) => ({
|
|
232
|
+
"@type": "Organization",
|
|
233
|
+
name: m.name,
|
|
234
|
+
...m.url && { url: m.url }
|
|
235
|
+
})) }
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
//#endregion
|
|
240
|
+
export { articleGraph, breadcrumbGraph, faqGraph, itemListGraph, localBusinessGraph, organizationGraph };
|
|
241
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/schema/article.ts","../../src/schema/breadcrumb.ts","../../src/schema/faq.ts","../../src/schema/itemList.ts","../../src/schema/localBusiness.ts","../../src/schema/organization.ts"],"sourcesContent":["export interface ArticleAuthor {\n name: string;\n url?: string;\n}\n\nexport interface ArticleGraphOptions {\n headline: string;\n description?: string;\n image?: string | string[];\n datePublished: string;\n dateModified?: string;\n author: ArticleAuthor | ArticleAuthor[];\n publisher?: {\n name: string;\n logo?: string;\n };\n mainEntityOfPage?: string;\n articleSection?: string;\n wordCount?: number;\n keywords?: string[];\n}\n\n/** Build a schema.org Article JSON-LD graph. */\nexport function articleGraph(options: ArticleGraphOptions): Record<string, unknown> {\n const {\n headline,\n description,\n image,\n datePublished,\n dateModified,\n author,\n publisher,\n mainEntityOfPage,\n articleSection,\n wordCount,\n keywords,\n } = options;\n\n const authors = Array.isArray(author) ? author : [author];\n\n return {\n '@context': 'https://schema.org',\n '@type': 'Article',\n headline,\n ...(description && { description }),\n ...(image && { image: Array.isArray(image) ? image : [image] }),\n datePublished,\n ...(dateModified && { dateModified }),\n author: authors.map((a) => ({\n '@type': 'Person',\n name: a.name,\n ...(a.url && { url: a.url }),\n })),\n ...(publisher && {\n publisher: {\n '@type': 'Organization',\n name: publisher.name,\n ...(publisher.logo && { logo: { '@type': 'ImageObject', url: publisher.logo } }),\n },\n }),\n ...(mainEntityOfPage && { mainEntityOfPage: { '@type': 'WebPage', '@id': mainEntityOfPage } }),\n ...(articleSection && { articleSection }),\n ...(wordCount && { wordCount }),\n ...(keywords && { keywords: keywords.join(', ') }),\n };\n}\n","export interface BreadcrumbItem {\n name: string;\n url: string;\n}\n\nexport interface BreadcrumbGraphOptions {\n items: BreadcrumbItem[];\n}\n\n/** Build a schema.org BreadcrumbList JSON-LD graph. */\nexport function breadcrumbGraph(options: BreadcrumbGraphOptions): Record<string, unknown> {\n return {\n '@context': 'https://schema.org',\n '@type': 'BreadcrumbList',\n itemListElement: options.items.map((item, index) => ({\n '@type': 'ListItem',\n position: index + 1,\n name: item.name,\n item: item.url,\n })),\n };\n}\n","export interface FAQItem {\n question: string;\n answer: string;\n}\n\nexport interface FAQGraphOptions {\n items: FAQItem[];\n}\n\n/**\n * Build a schema.org FAQPage JSON-LD graph.\n *\n * NOTE: Google recommends FAQ markup only for authoritative government\n * and health sites. For other sites, consider using QAPage schema instead.\n */\nexport function faqGraph(options: FAQGraphOptions): Record<string, unknown> {\n return {\n '@context': 'https://schema.org',\n '@type': 'FAQPage',\n mainEntity: options.items.map((item) => ({\n '@type': 'Question',\n name: item.question,\n acceptedAnswer: {\n '@type': 'Answer',\n text: item.answer,\n },\n })),\n };\n}\n","export type ItemListEntryType =\n | 'Article'\n | 'Recipe'\n | 'Movie'\n | 'Course'\n | 'Restaurant'\n | 'Product'\n // Anything else is still valid ItemList markup, just not a Google\n // carousel rich-result candidate — those six are the full supported set.\n | (string & {});\n\nexport interface ItemListEntry {\n type: ItemListEntryType;\n name: string;\n /**\n * Summary-page format: the item's own detail page (unique, same domain).\n * All-in-one-page format: this page's URL plus an HTML anchor pointing at\n * the item on it.\n */\n url: string;\n image?: string;\n description?: string;\n}\n\nexport interface ItemListGraphOptions {\n items: ItemListEntry[];\n /** Recommended, not required by the spec — names the list itself. */\n name?: string;\n}\n\n/** Build a schema.org ItemList JSON-LD graph (e.g. for a carousel rich result). */\nexport function itemListGraph(options: ItemListGraphOptions): Record<string, unknown> {\n const { items, name } = options;\n\n return {\n '@context': 'https://schema.org',\n '@type': 'ItemList',\n ...(name && { name }),\n itemListElement: items.map((item, index) => ({\n '@type': 'ListItem',\n position: index + 1,\n item: {\n '@type': item.type,\n name: item.name,\n url: item.url,\n ...(item.image && { image: item.image }),\n ...(item.description && { description: item.description }),\n },\n })),\n };\n}\n","export interface OpeningHoursSpecification {\n dayOfWeek: ('Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday' | 'Saturday' | 'Sunday')[];\n opens: string;\n closes: string;\n}\n\n/**\n * A dated deviation from the recurring `openingHoursSpecification` — holiday\n * hours, a one-off closure, a temporary schedule. Same schema.org shape\n * (`OpeningHoursSpecification`), distinguished by carrying `validFrom`. A full\n * closure omits `opens`/`closes` rather than encoding it as an open interval.\n */\nexport interface SpecialHoursSpecification {\n validFrom: string;\n validThrough?: string;\n opens?: string;\n closes?: string;\n}\n\nexport interface LocalBusinessGraphOptions {\n /** schema.org `LocalBusiness` subtype, e.g. `'Restaurant'`, `'ProfessionalService'`. Defaults to `'LocalBusiness'`. */\n type?: string;\n name: string;\n description?: string;\n url?: string;\n telephone?: string;\n email?: string;\n image?: string | string[];\n priceRange?: string;\n address?: {\n streetAddress?: string;\n addressLocality?: string;\n addressRegion?: string;\n postalCode?: string;\n addressCountry?: string;\n };\n geo?: {\n latitude: number;\n longitude: number;\n };\n openingHoursSpecification?: OpeningHoursSpecification[];\n specialOpeningHoursSpecification?: SpecialHoursSpecification[];\n aggregateRating?: {\n ratingValue: number;\n reviewCount?: number;\n ratingCount?: number;\n bestRating?: number;\n worstRating?: number;\n };\n review?: {\n author: string;\n datePublished: string;\n reviewBody: string;\n reviewRating?: {\n ratingValue: number;\n bestRating?: number;\n worstRating?: number;\n };\n }[];\n paymentAccepted?: string[];\n currenciesAccepted?: string;\n areaServed?: string[];\n sameAs?: string[];\n knowsLanguage?: string[];\n}\n\n/** Build a schema.org LocalBusiness (or subtype) JSON-LD graph. */\nexport function localBusinessGraph(options: LocalBusinessGraphOptions): Record<string, unknown> {\n const {\n type = 'LocalBusiness',\n name,\n description,\n url,\n telephone,\n email,\n image,\n priceRange,\n address,\n geo,\n openingHoursSpecification,\n specialOpeningHoursSpecification,\n aggregateRating,\n review,\n paymentAccepted,\n currenciesAccepted,\n areaServed,\n sameAs,\n knowsLanguage,\n } = options;\n\n const hours = [\n ...(openingHoursSpecification ?? []).map((spec) => ({\n '@type': 'OpeningHoursSpecification',\n dayOfWeek: spec.dayOfWeek,\n opens: spec.opens,\n closes: spec.closes,\n })),\n ...(specialOpeningHoursSpecification ?? []).map((spec) => ({\n '@type': 'OpeningHoursSpecification',\n validFrom: spec.validFrom,\n ...(spec.validThrough && { validThrough: spec.validThrough }),\n ...(spec.opens && { opens: spec.opens }),\n ...(spec.closes && { closes: spec.closes }),\n })),\n ];\n\n return {\n '@context': 'https://schema.org',\n '@type': type,\n name,\n ...(description && { description }),\n ...(url && { url }),\n ...(telephone && { telephone }),\n ...(email && { email }),\n ...(image && { image: Array.isArray(image) ? image : [image] }),\n ...(priceRange && { priceRange }),\n ...(address && {\n address: {\n '@type': 'PostalAddress',\n ...(address.streetAddress && { streetAddress: address.streetAddress }),\n ...(address.addressLocality && { addressLocality: address.addressLocality }),\n ...(address.addressRegion && { addressRegion: address.addressRegion }),\n ...(address.postalCode && { postalCode: address.postalCode }),\n ...(address.addressCountry && { addressCountry: address.addressCountry }),\n },\n }),\n ...(geo && {\n geo: {\n '@type': 'GeoCoordinates',\n latitude: geo.latitude,\n longitude: geo.longitude,\n },\n }),\n ...(hours.length > 0 && { openingHoursSpecification: hours }),\n ...(aggregateRating && {\n aggregateRating: {\n '@type': 'AggregateRating',\n ratingValue: aggregateRating.ratingValue,\n ...(aggregateRating.reviewCount && { reviewCount: aggregateRating.reviewCount }),\n ...(aggregateRating.ratingCount && { ratingCount: aggregateRating.ratingCount }),\n bestRating: aggregateRating.bestRating ?? 5,\n worstRating: aggregateRating.worstRating ?? 1,\n },\n }),\n ...(review && {\n review: review.map((r) => ({\n '@type': 'Review',\n author: { '@type': 'Person', name: r.author },\n datePublished: r.datePublished,\n reviewBody: r.reviewBody,\n ...(r.reviewRating && {\n reviewRating: {\n '@type': 'Rating',\n ratingValue: r.reviewRating.ratingValue,\n bestRating: r.reviewRating.bestRating ?? 5,\n worstRating: r.reviewRating.worstRating ?? 1,\n },\n }),\n })),\n }),\n ...(paymentAccepted && { paymentAccepted }),\n ...(currenciesAccepted && { currenciesAccepted }),\n ...(areaServed && { areaServed }),\n ...(sameAs && { sameAs }),\n ...(knowsLanguage && { knowsLanguage }),\n };\n}\n","export interface OrganizationGraphOptions {\n name: string;\n url?: string;\n logo?: string;\n description?: string;\n email?: string;\n telephone?: string;\n address?: {\n streetAddress?: string;\n addressLocality?: string;\n addressRegion?: string;\n postalCode?: string;\n addressCountry?: string;\n };\n sameAs?: string[];\n foundingDate?: string;\n founders?: string[];\n numberOfEmployees?: number | { minValue: number; maxValue: number };\n parentOrganization?: {\n name: string;\n url?: string;\n };\n subOrganization?: {\n name: string;\n url?: string;\n }[];\n contactPoint?: {\n type?: 'customer service' | 'technical support' | 'sales' | 'billing support' | 'reservations';\n telephone: string;\n email?: string;\n contactType: string;\n availableLanguage?: string[];\n areaServed?: string[];\n }[];\n areaServed?: string[];\n brand?: string;\n award?: string[];\n memberOf?: {\n name: string;\n url?: string;\n }[];\n}\n\n/** Build a schema.org Organization JSON-LD graph. */\nexport function organizationGraph(options: OrganizationGraphOptions): Record<string, unknown> {\n const {\n name,\n url,\n logo,\n description,\n email,\n telephone,\n address,\n sameAs,\n foundingDate,\n founders,\n numberOfEmployees,\n parentOrganization,\n subOrganization,\n contactPoint,\n areaServed,\n brand,\n award,\n memberOf,\n } = options;\n\n return {\n '@context': 'https://schema.org',\n '@type': 'Organization',\n name,\n ...(url && { url }),\n ...(logo && { logo }),\n ...(description && { description }),\n ...(email && { email }),\n ...(telephone && { telephone }),\n ...(address && {\n address: {\n '@type': 'PostalAddress',\n ...(address.streetAddress && { streetAddress: address.streetAddress }),\n ...(address.addressLocality && { addressLocality: address.addressLocality }),\n ...(address.addressRegion && { addressRegion: address.addressRegion }),\n ...(address.postalCode && { postalCode: address.postalCode }),\n ...(address.addressCountry && { addressCountry: address.addressCountry }),\n },\n }),\n ...(sameAs && { sameAs }),\n ...(foundingDate && { foundingDate }),\n ...(founders && {\n founders: founders.map((f) => ({\n '@type': 'Person',\n name: f,\n })),\n }),\n ...(numberOfEmployees && {\n numberOfEmployees:\n typeof numberOfEmployees === 'number'\n ? { '@type': 'QuantitativeValue', value: numberOfEmployees }\n : {\n '@type': 'QuantitativeValue',\n minValue: numberOfEmployees.minValue,\n maxValue: numberOfEmployees.maxValue,\n },\n }),\n ...(parentOrganization && {\n parentOrganization: {\n '@type': 'Organization',\n name: parentOrganization.name,\n ...(parentOrganization.url && { url: parentOrganization.url }),\n },\n }),\n ...(subOrganization && {\n subOrganization: subOrganization.map((sub) => ({\n '@type': 'Organization',\n name: sub.name,\n ...(sub.url && { url: sub.url }),\n })),\n }),\n ...(contactPoint && {\n contactPoint: contactPoint.map((cp) => ({\n '@type': 'ContactPoint',\n telephone: cp.telephone,\n contactType: cp.contactType,\n ...(cp.email && { email: cp.email }),\n ...(cp.availableLanguage && { availableLanguage: cp.availableLanguage }),\n ...(cp.areaServed && { areaServed: cp.areaServed }),\n })),\n }),\n ...(areaServed && { areaServed }),\n ...(brand && { brand: { '@type': 'Brand', name: brand } }),\n ...(award && { award }),\n ...(memberOf && {\n memberOf: memberOf.map((m) => ({\n '@type': 'Organization',\n name: m.name,\n ...(m.url && { url: m.url }),\n })),\n }),\n };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
//#region src/security/turnstile.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Cloudflare Turnstile server-side verification.
|
|
4
|
+
*
|
|
5
|
+
* `site.security.turnstile.siteKey` is public and lives in the config; the
|
|
6
|
+
* matching **secret** key never does — same rule as every other credential in
|
|
7
|
+
* this toolchain (`CLOUDFLARE_API_TOKEN`, the Google OAuth/service-account
|
|
8
|
+
* grants) and the same reason: an env var, injected by the caller, never
|
|
9
|
+
* imported here. Before this module existed, `siteKey` was read by the legal
|
|
10
|
+
* templates (`legal/providers.ts`, `legal/derive.ts`) to *name* Turnstile in the
|
|
11
|
+
* generated privacy policy, with nothing in the toolchain that verified a token
|
|
12
|
+
* — a config field asserting a fact the site didn't actually implement, which is
|
|
13
|
+
* exactly what the legal system's own rule ("the config records facts; the
|
|
14
|
+
* template owns prose") argues against. `verifyTurnstileToken` plus
|
|
15
|
+
* `createConversionRoute()`'s `turnstile` option (`@getvitops/astro/routes`) is
|
|
16
|
+
* what lets that field describe something real.
|
|
17
|
+
*
|
|
18
|
+
* A separate `@getvitops/utils/security` subpath for the same reason
|
|
19
|
+
* `notify`/`tracking` are: this runs in a **Worker**, on every submission, and
|
|
20
|
+
* must not pull in anything build-time.
|
|
21
|
+
*
|
|
22
|
+
* The widget itself — `<script src="https://challenges.cloudflare.com/turnstile/
|
|
23
|
+
* v0/api.js" async defer></script>` plus a `<div class="cf-turnstile"
|
|
24
|
+
* data-sitekey="…">` — is the consumer's own markup, on their own hand-written
|
|
25
|
+
* form. There is no `FormRenderer` (removed 2026-07-27, see `AGENTS.md`) for this
|
|
26
|
+
* to hook into generically, so wiring the widget follows the same pattern a
|
|
27
|
+
* consumer already uses for the rest of their form: write the HTML, submit it to
|
|
28
|
+
* `createConversionRoute()`.
|
|
29
|
+
*/
|
|
30
|
+
interface TurnstileVerifyResult {
|
|
31
|
+
success: boolean;
|
|
32
|
+
/** Cloudflare's own codes (e.g. `timeout-or-duplicate`), present iff not successful. */
|
|
33
|
+
errorCodes?: string[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Verify a submitted `cf-turnstile-response` token against Cloudflare's
|
|
37
|
+
* siteverify endpoint.
|
|
38
|
+
*
|
|
39
|
+
* Never throws — a network failure or a malformed response both come back as
|
|
40
|
+
* `{ success: false }` with an error code the caller can log, the same
|
|
41
|
+
* "structured result, not an exception" rule `indexing`/`notify` follow. A
|
|
42
|
+
* failed verification is not this module's business to act on; the caller
|
|
43
|
+
* (`createConversionRoute`) decides what a failure means for the request.
|
|
44
|
+
*/
|
|
45
|
+
declare function verifyTurnstileToken(token: string, secretKey: string, remoteIp?: string, fetchImpl?: typeof fetch): Promise<TurnstileVerifyResult>;
|
|
46
|
+
//#endregion
|
|
47
|
+
export { type TurnstileVerifyResult, verifyTurnstileToken };
|
|
48
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/security/turnstile.ts"],"mappings":";;AA+BA;;;;AAGY;AAaZ;;;;;;;;;;;;;;;;AAKgC;;;;;;UArBf,qBAAA;EACf,OAAA;;EAEA,UAAU;AAAA;;;;;;;;;;;iBAaU,oBAAA,CACpB,KAAA,UACA,SAAA,UACA,QAAA,WACA,SAAA,UAAkB,KAAA,GACjB,OAAA,CAAQ,qBAAA"}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
//#region src/security/turnstile.ts
|
|
2
|
+
/**
|
|
3
|
+
* Cloudflare Turnstile server-side verification.
|
|
4
|
+
*
|
|
5
|
+
* `site.security.turnstile.siteKey` is public and lives in the config; the
|
|
6
|
+
* matching **secret** key never does — same rule as every other credential in
|
|
7
|
+
* this toolchain (`CLOUDFLARE_API_TOKEN`, the Google OAuth/service-account
|
|
8
|
+
* grants) and the same reason: an env var, injected by the caller, never
|
|
9
|
+
* imported here. Before this module existed, `siteKey` was read by the legal
|
|
10
|
+
* templates (`legal/providers.ts`, `legal/derive.ts`) to *name* Turnstile in the
|
|
11
|
+
* generated privacy policy, with nothing in the toolchain that verified a token
|
|
12
|
+
* — a config field asserting a fact the site didn't actually implement, which is
|
|
13
|
+
* exactly what the legal system's own rule ("the config records facts; the
|
|
14
|
+
* template owns prose") argues against. `verifyTurnstileToken` plus
|
|
15
|
+
* `createConversionRoute()`'s `turnstile` option (`@getvitops/astro/routes`) is
|
|
16
|
+
* what lets that field describe something real.
|
|
17
|
+
*
|
|
18
|
+
* A separate `@getvitops/utils/security` subpath for the same reason
|
|
19
|
+
* `notify`/`tracking` are: this runs in a **Worker**, on every submission, and
|
|
20
|
+
* must not pull in anything build-time.
|
|
21
|
+
*
|
|
22
|
+
* The widget itself — `<script src="https://challenges.cloudflare.com/turnstile/
|
|
23
|
+
* v0/api.js" async defer><\/script>` plus a `<div class="cf-turnstile"
|
|
24
|
+
* data-sitekey="…">` — is the consumer's own markup, on their own hand-written
|
|
25
|
+
* form. There is no `FormRenderer` (removed 2026-07-27, see `AGENTS.md`) for this
|
|
26
|
+
* to hook into generically, so wiring the widget follows the same pattern a
|
|
27
|
+
* consumer already uses for the rest of their form: write the HTML, submit it to
|
|
28
|
+
* `createConversionRoute()`.
|
|
29
|
+
*/
|
|
30
|
+
const VERIFY_ENDPOINT = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
|
31
|
+
/**
|
|
32
|
+
* Verify a submitted `cf-turnstile-response` token against Cloudflare's
|
|
33
|
+
* siteverify endpoint.
|
|
34
|
+
*
|
|
35
|
+
* Never throws — a network failure or a malformed response both come back as
|
|
36
|
+
* `{ success: false }` with an error code the caller can log, the same
|
|
37
|
+
* "structured result, not an exception" rule `indexing`/`notify` follow. A
|
|
38
|
+
* failed verification is not this module's business to act on; the caller
|
|
39
|
+
* (`createConversionRoute`) decides what a failure means for the request.
|
|
40
|
+
*/
|
|
41
|
+
async function verifyTurnstileToken(token, secretKey, remoteIp, fetchImpl = fetch) {
|
|
42
|
+
const body = new URLSearchParams({
|
|
43
|
+
secret: secretKey,
|
|
44
|
+
response: token
|
|
45
|
+
});
|
|
46
|
+
if (remoteIp) body.set("remoteip", remoteIp);
|
|
47
|
+
let res;
|
|
48
|
+
try {
|
|
49
|
+
res = await fetchImpl(VERIFY_ENDPOINT, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
body
|
|
52
|
+
});
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return {
|
|
55
|
+
success: false,
|
|
56
|
+
errorCodes: [`network-error: ${e.message}`]
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (!res.ok) return {
|
|
60
|
+
success: false,
|
|
61
|
+
errorCodes: [`http-${res.status}`]
|
|
62
|
+
};
|
|
63
|
+
let data;
|
|
64
|
+
try {
|
|
65
|
+
data = await res.json();
|
|
66
|
+
} catch {
|
|
67
|
+
return {
|
|
68
|
+
success: false,
|
|
69
|
+
errorCodes: ["malformed-response"]
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return data.success === true ? { success: true } : {
|
|
73
|
+
success: false,
|
|
74
|
+
errorCodes: data["error-codes"] ?? ["unknown-error"]
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
//#endregion
|
|
79
|
+
export { verifyTurnstileToken };
|
|
80
|
+
//# sourceMappingURL=index.mjs.map
|