@burdenoff/website-sdk 2026.1026.1 → 2026.1028.1
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/README.md +104 -1
- package/dist/comparison/index.d.mts +83 -0
- package/dist/comparison/index.d.ts +83 -0
- package/dist/comparison/index.js +116 -0
- package/dist/comparison/index.js.map +1 -0
- package/dist/comparison/index.mjs +109 -0
- package/dist/comparison/index.mjs.map +1 -0
- package/dist/components/explore.d.mts +1 -1
- package/dist/components/explore.d.ts +1 -1
- package/dist/components/explore.js +276 -37
- package/dist/components/explore.js.map +1 -1
- package/dist/components/explore.mjs +276 -37
- package/dist/components/explore.mjs.map +1 -1
- package/dist/components/partners.js.map +1 -1
- package/dist/components/partners.mjs.map +1 -1
- package/dist/content/index.d.mts +1 -1
- package/dist/content/index.d.ts +1 -1
- package/dist/content/index.js +32 -4
- package/dist/content/index.js.map +1 -1
- package/dist/content/index.mjs +32 -5
- package/dist/content/index.mjs.map +1 -1
- package/dist/{explore-BRfKFfvI.d.mts → explore-BEgq1_0O.d.mts} +44 -1
- package/dist/{explore-DkfcKgp6.d.ts → explore-CHiP9u-4.d.ts} +44 -1
- package/dist/{index-CB0Uw1xD.d.ts → index-87BLB-Hj.d.ts} +26 -1
- package/dist/{index-Ctws7kGs.d.mts → index-Chw0pDXS.d.mts} +26 -1
- package/dist/index.d.mts +63 -7
- package/dist/index.d.ts +63 -7
- package/dist/index.js +463 -44
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +457 -45
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -159,7 +159,9 @@ function Header() {
|
|
|
159
159
|
|---|---|---|
|
|
160
160
|
| `seo` | – | Renders `PageHead` with these overrides. Omit to manage `<head>` yourself. |
|
|
161
161
|
| `productSlug` | – | The site this page is on. A soft hint to the assistant, not a hard filter. |
|
|
162
|
-
| `examplePrompts` | – | Prompt chips used only when the backend catalog supplies none. |
|
|
162
|
+
| `examplePrompts` | – | Prompt chips used only when the backend catalog supplies none. With `scope="product"` they are the only prompts shown, whatever the catalog has. |
|
|
163
|
+
| `scope` | `'portfolio'` | `'product'` (with `productSlug`) makes the assistant one product's own. See [One product's own assistant](#one-products-own-assistant). |
|
|
164
|
+
| `productName` | catalog name | The product's display name in the `product` scope. Default: the catalog's name for `productSlug`. |
|
|
163
165
|
| `className` | – | Extra classes on the page root. |
|
|
164
166
|
| `heightMode` | `'viewport'` | `viewport` pins the chat to the screen with an internally scrolling transcript, and is the only mode in which the component manages the host page's scroll position; `auto` lets the page grow and never touches its host's scrolling (for embedding). |
|
|
165
167
|
| `viewportOffset` | `0` | Chrome to subtract in `viewport` mode, **before the first measurement only**. A number is pixels; a string is any CSS length (`'4rem'`). Once mounted the pane measures its own top and this value stops being used — so an approximate header height is fine, and the on-screen keyboard is handled for you. |
|
|
@@ -167,6 +169,80 @@ function Header() {
|
|
|
167
169
|
| `onSend` | – | Fired with the message text each time a send is accepted (analytics). |
|
|
168
170
|
| `welcomeTitle` / `welcomeBody` | catalog values | Override the welcome copy. `welcomeTitle` **is** the page `<h1>`. |
|
|
169
171
|
|
|
172
|
+
#### One product's own assistant
|
|
173
|
+
|
|
174
|
+
By default the assistant speaks for the whole portfolio: a "Focus on a product"
|
|
175
|
+
picker lists every product, concept cards come from every product, and the
|
|
176
|
+
catalog's copy invites comparing products. A product site that wants its
|
|
177
|
+
assistant to be about that product alone passes `scope="product"` with its
|
|
178
|
+
`productSlug`, on `ExplorePage`, on the site-wide `ExplorePanel`, or on both.
|
|
179
|
+
`ExplorePanel` takes the same `scope`, `productName` and `examplePrompts` props
|
|
180
|
+
and passes them to its chat.
|
|
181
|
+
|
|
182
|
+
```tsx
|
|
183
|
+
<ExplorePanel
|
|
184
|
+
open={open}
|
|
185
|
+
onOpenChange={setOpen}
|
|
186
|
+
productSlug="healthybowl"
|
|
187
|
+
scope="product"
|
|
188
|
+
productName="Healthy Bowl" // the panel is titled "Healthy Bowl assistant"
|
|
189
|
+
examplePrompts={[
|
|
190
|
+
"Our herd's health records are spread across three notebooks.",
|
|
191
|
+
'We only find out a field needs water once the crop is stressed.',
|
|
192
|
+
]}
|
|
193
|
+
/>
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
In that scope:
|
|
197
|
+
|
|
198
|
+
- The panel's title is "{product} assistant", in the visitor's language and its
|
|
199
|
+
word order, named from `productName`, else from the catalog. Where a phone
|
|
200
|
+
leaves it too little room beside the tabs, it wraps to two lines, and the
|
|
201
|
+
header keeps its height. In English and most locales two lines hold the whole
|
|
202
|
+
title at 320px and wider; a narrower title box, such as Tamil's at 320px (its
|
|
203
|
+
tabs are wider), can still end the second line with an ellipsis.
|
|
204
|
+
- There is no product picker. Every question is asked about this product, also
|
|
205
|
+
in a conversation that was stored, reopened or shared with another focus; the
|
|
206
|
+
request carries `productSlug` as it always has.
|
|
207
|
+
- Only this product's concept cards show, and none at all when it has none.
|
|
208
|
+
- The example prompts are your `examplePrompts` and nothing else (each once,
|
|
209
|
+
blank ones dropped), whatever the catalog has. Only when you pass none does it
|
|
210
|
+
fall back to the catalog's, without any that name another product and without
|
|
211
|
+
the built-in "Which of your products fits a problem like mine?". Pass your
|
|
212
|
+
own: the catalog's are written for every site at once, and even the ones that
|
|
213
|
+
name no product describe other sites' problems. Pass them to the
|
|
214
|
+
product-scoped view itself (`ExplorePanel` or `ExplorePage`): an
|
|
215
|
+
`ExploreChatProvider`'s own `examplePrompts` only stand in until the catalog
|
|
216
|
+
loads.
|
|
217
|
+
- Unless you pass `welcomeBody`, the welcome invites the visitor to describe a
|
|
218
|
+
problem or ask what the product does, instead of the catalog's invitation to
|
|
219
|
+
compare products. A catalog title that names another product is replaced by
|
|
220
|
+
the built-in one. The welcome's "Compare across the ecosystem" tile is left
|
|
221
|
+
out.
|
|
222
|
+
- The composer is the published one, unchanged: the same layout, the same size
|
|
223
|
+
and the same fit to its draft. Only its placeholder's text is shorter, "Ask
|
|
224
|
+
about {product}…" instead of "Describe a problem, or ask about {product}…",
|
|
225
|
+
because on a phone the field stays one line tall and cuts off a placeholder
|
|
226
|
+
that wraps. The English one fits that line at 320px and wider. Some longer
|
|
227
|
+
locales, such as Spanish and Tamil, may still wrap at 320px and narrower; that
|
|
228
|
+
is left for a follow-up, and HealthyBowl, the site this was made for, is
|
|
229
|
+
English-only.
|
|
230
|
+
- The page's meta description (and its `og:` and `twitter:` copies) is the
|
|
231
|
+
product's own, "Answers about {product}, drawn from {product}'s
|
|
232
|
+
documentation…", never "and every other product we build". The title and
|
|
233
|
+
`og:site_name` name the product already.
|
|
234
|
+
- A question that needs more searches than one answer can make (`TOOL_BUDGET`)
|
|
235
|
+
is asked to be narrowed to one part of it, in the visitor's language, instead
|
|
236
|
+
of to "two or three specific products".
|
|
237
|
+
|
|
238
|
+
Answers still come from the backend unchanged: when a visitor asks about
|
|
239
|
+
another product, the answer and its sources can mention it. Each view applies
|
|
240
|
+
its own scope, so under an `ExploreChatProvider` a portfolio `/explore` page and
|
|
241
|
+
a product-scoped panel still share one conversation. Without a `productSlug`,
|
|
242
|
+
`scope="product"` has no product to pin to and behaves like the default (a
|
|
243
|
+
development build logs a warning). The default, `scope="portfolio"`, renders
|
|
244
|
+
exactly what it always has.
|
|
245
|
+
|
|
170
246
|
**`ExploreCta` props**: `variant` (`'header' \| 'mobile' \| 'floating'`, required),
|
|
171
247
|
`href` (default `/explore`), `label` (default `Ask AI`), `className`,
|
|
172
248
|
`onNavigate`, `onClick`, `currentPath`. The `floating` variant hides itself on
|
|
@@ -429,6 +505,33 @@ import { cn } from '@burdenoff/website-sdk/utils'
|
|
|
429
505
|
const className = cn('px-4 py-2', isActive && 'bg-blue-500', 'rounded-lg')
|
|
430
506
|
```
|
|
431
507
|
|
|
508
|
+
## Case studies need a customer sign-off
|
|
509
|
+
|
|
510
|
+
`CaseStudiesListPage` and `CaseStudyPage` render a story only when its CMS page
|
|
511
|
+
(tag `case-study`) carries a `customerSignOff`: the customer's written approval
|
|
512
|
+
of the story and its numbers. global-tenant-svc refuses to serve an unapproved
|
|
513
|
+
case study on the public queries, and the SDK filters again with
|
|
514
|
+
`hasCustomerSignOff`, so no product website can show one. Set the sign-off with
|
|
515
|
+
`updateContentPage(id, { customerSignOff: { signedBy, signedAt, documentRef } })`
|
|
516
|
+
as a super-admin; `null` withdraws it. Public visitors only ever see the
|
|
517
|
+
approval date. Never invent a sign-off or publish a composite story.
|
|
518
|
+
|
|
519
|
+
## Comparison (/vs) page sources and freshness
|
|
520
|
+
|
|
521
|
+
`@burdenoff/website-sdk/comparison` gives every website with a /vs page the same
|
|
522
|
+
contract for claims about other vendors:
|
|
523
|
+
|
|
524
|
+
- `Sourced` / `NonEmptySources`: each claim carries at least one
|
|
525
|
+
`{ url, verifiedAt }` (ISO date), enforced by the type.
|
|
526
|
+
- `checkComparisonFreshness(claims, { maxAgeDays })` returns an issue for a
|
|
527
|
+
claim without a source, a non-http(s) URL, a malformed or future date, or a
|
|
528
|
+
newest verification older than 180 days (default).
|
|
529
|
+
- `formatFreshnessReport(issues)` prints them for CI.
|
|
530
|
+
|
|
531
|
+
Run it as a PR Check step (see fluidgrids-website `scripts/check-vs-freshness.ts`)
|
|
532
|
+
so a stale comparison blocks the next change until someone re-reads the
|
|
533
|
+
vendor's current page.
|
|
534
|
+
|
|
432
535
|
## API Integration
|
|
433
536
|
|
|
434
537
|
All components that require API integration expect the following endpoints:
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @burdenoff/website-sdk - Comparison page sources and freshness (BOFF-9971)
|
|
3
|
+
*
|
|
4
|
+
* Every product website with a /vs (competitor comparison) page makes claims
|
|
5
|
+
* about other vendors. Those claims age fast: a vendor ships the feature we
|
|
6
|
+
* said it lacked, or changes its prices. This module gives every site the same
|
|
7
|
+
* contract and the same check:
|
|
8
|
+
*
|
|
9
|
+
* - each claim carries at least one source `{ url, verifiedAt }` (enforced by
|
|
10
|
+
* the `Sourced` type at compile time), and
|
|
11
|
+
* - `checkComparisonFreshness` fails when a claim has no source, a source is
|
|
12
|
+
* not an http(s) URL, a date is malformed or in the future, or the newest
|
|
13
|
+
* verification is older than `maxAgeDays` (180 by default).
|
|
14
|
+
*
|
|
15
|
+
* Sites run the check in their PR Check workflow, so a stale or unsourced
|
|
16
|
+
* comparison blocks the next change until someone re-reads the vendor's page.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* import { checkComparisonFreshness, formatFreshnessReport } from '@burdenoff/website-sdk/comparison'
|
|
21
|
+
*
|
|
22
|
+
* const issues = checkComparisonFreshness(collectClaims(COMPETITORS))
|
|
23
|
+
* if (issues.length) {
|
|
24
|
+
* console.error(formatFreshnessReport(issues))
|
|
25
|
+
* process.exit(1)
|
|
26
|
+
* }
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
/** Where a comparison claim was checked, and when (ISO date, YYYY-MM-DD). */
|
|
30
|
+
interface ComparisonSource {
|
|
31
|
+
/** The vendor page (or docs page) the claim was checked against. */
|
|
32
|
+
url: string;
|
|
33
|
+
/** The day a person last read that page and confirmed the claim. */
|
|
34
|
+
verifiedAt: string;
|
|
35
|
+
/** Optional short label shown with the link, e.g. "Pricing". */
|
|
36
|
+
label?: string;
|
|
37
|
+
}
|
|
38
|
+
/** A non-empty list: a sourced claim always has at least one source. */
|
|
39
|
+
type NonEmptySources = readonly [
|
|
40
|
+
ComparisonSource,
|
|
41
|
+
...ComparisonSource[]
|
|
42
|
+
];
|
|
43
|
+
/** Mix-in for any comparison claim (table row, profile, pricing row). */
|
|
44
|
+
interface Sourced {
|
|
45
|
+
sources: NonEmptySources;
|
|
46
|
+
}
|
|
47
|
+
/** One claim to check, located by a human-readable path for the report. */
|
|
48
|
+
interface ComparisonClaim {
|
|
49
|
+
/** Where the claim lives, e.g. "zapier.featureRows[Workflow versioning]". */
|
|
50
|
+
path: string;
|
|
51
|
+
/** Its sources; missing or empty fails the check. */
|
|
52
|
+
sources?: readonly ComparisonSource[] | null;
|
|
53
|
+
}
|
|
54
|
+
type FreshnessProblem = "missing-source" | "invalid-url" | "invalid-date" | "future-date" | "stale";
|
|
55
|
+
interface FreshnessIssue {
|
|
56
|
+
path: string;
|
|
57
|
+
problem: FreshnessProblem;
|
|
58
|
+
detail: string;
|
|
59
|
+
}
|
|
60
|
+
interface FreshnessOptions {
|
|
61
|
+
/** Oldest acceptable verification, in days. Default 180. */
|
|
62
|
+
maxAgeDays?: number;
|
|
63
|
+
/** The clock to check against. Default: now. */
|
|
64
|
+
now?: Date;
|
|
65
|
+
}
|
|
66
|
+
/** Default staleness threshold for comparison claims. */
|
|
67
|
+
declare const DEFAULT_COMPARISON_MAX_AGE_DAYS = 180;
|
|
68
|
+
/** Parse a strict YYYY-MM-DD date as UTC midnight, or null when invalid. */
|
|
69
|
+
declare function parseVerifiedAt(value: string): Date | null;
|
|
70
|
+
/**
|
|
71
|
+
* Check every claim and return the problems found (empty when all is well).
|
|
72
|
+
* A claim passes on its newest valid source, so adding a fresh source to an
|
|
73
|
+
* old claim is enough to clear a "stale" issue.
|
|
74
|
+
*/
|
|
75
|
+
declare function checkComparisonFreshness(claims: readonly ComparisonClaim[], options?: FreshnessOptions): FreshnessIssue[];
|
|
76
|
+
/** Newest valid verification date across all claims, or null. */
|
|
77
|
+
declare function latestVerification(claims: readonly ComparisonClaim[]): string | null;
|
|
78
|
+
/** Oldest valid verification date across all claims, or null. */
|
|
79
|
+
declare function oldestVerification(claims: readonly ComparisonClaim[]): string | null;
|
|
80
|
+
/** A plain-text report for CI logs, one line per issue. */
|
|
81
|
+
declare function formatFreshnessReport(issues: readonly FreshnessIssue[]): string;
|
|
82
|
+
|
|
83
|
+
export { type ComparisonClaim, type ComparisonSource, DEFAULT_COMPARISON_MAX_AGE_DAYS, type FreshnessIssue, type FreshnessOptions, type FreshnessProblem, type NonEmptySources, type Sourced, checkComparisonFreshness, formatFreshnessReport, latestVerification, oldestVerification, parseVerifiedAt };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @burdenoff/website-sdk - Comparison page sources and freshness (BOFF-9971)
|
|
3
|
+
*
|
|
4
|
+
* Every product website with a /vs (competitor comparison) page makes claims
|
|
5
|
+
* about other vendors. Those claims age fast: a vendor ships the feature we
|
|
6
|
+
* said it lacked, or changes its prices. This module gives every site the same
|
|
7
|
+
* contract and the same check:
|
|
8
|
+
*
|
|
9
|
+
* - each claim carries at least one source `{ url, verifiedAt }` (enforced by
|
|
10
|
+
* the `Sourced` type at compile time), and
|
|
11
|
+
* - `checkComparisonFreshness` fails when a claim has no source, a source is
|
|
12
|
+
* not an http(s) URL, a date is malformed or in the future, or the newest
|
|
13
|
+
* verification is older than `maxAgeDays` (180 by default).
|
|
14
|
+
*
|
|
15
|
+
* Sites run the check in their PR Check workflow, so a stale or unsourced
|
|
16
|
+
* comparison blocks the next change until someone re-reads the vendor's page.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* import { checkComparisonFreshness, formatFreshnessReport } from '@burdenoff/website-sdk/comparison'
|
|
21
|
+
*
|
|
22
|
+
* const issues = checkComparisonFreshness(collectClaims(COMPETITORS))
|
|
23
|
+
* if (issues.length) {
|
|
24
|
+
* console.error(formatFreshnessReport(issues))
|
|
25
|
+
* process.exit(1)
|
|
26
|
+
* }
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
/** Where a comparison claim was checked, and when (ISO date, YYYY-MM-DD). */
|
|
30
|
+
interface ComparisonSource {
|
|
31
|
+
/** The vendor page (or docs page) the claim was checked against. */
|
|
32
|
+
url: string;
|
|
33
|
+
/** The day a person last read that page and confirmed the claim. */
|
|
34
|
+
verifiedAt: string;
|
|
35
|
+
/** Optional short label shown with the link, e.g. "Pricing". */
|
|
36
|
+
label?: string;
|
|
37
|
+
}
|
|
38
|
+
/** A non-empty list: a sourced claim always has at least one source. */
|
|
39
|
+
type NonEmptySources = readonly [
|
|
40
|
+
ComparisonSource,
|
|
41
|
+
...ComparisonSource[]
|
|
42
|
+
];
|
|
43
|
+
/** Mix-in for any comparison claim (table row, profile, pricing row). */
|
|
44
|
+
interface Sourced {
|
|
45
|
+
sources: NonEmptySources;
|
|
46
|
+
}
|
|
47
|
+
/** One claim to check, located by a human-readable path for the report. */
|
|
48
|
+
interface ComparisonClaim {
|
|
49
|
+
/** Where the claim lives, e.g. "zapier.featureRows[Workflow versioning]". */
|
|
50
|
+
path: string;
|
|
51
|
+
/** Its sources; missing or empty fails the check. */
|
|
52
|
+
sources?: readonly ComparisonSource[] | null;
|
|
53
|
+
}
|
|
54
|
+
type FreshnessProblem = "missing-source" | "invalid-url" | "invalid-date" | "future-date" | "stale";
|
|
55
|
+
interface FreshnessIssue {
|
|
56
|
+
path: string;
|
|
57
|
+
problem: FreshnessProblem;
|
|
58
|
+
detail: string;
|
|
59
|
+
}
|
|
60
|
+
interface FreshnessOptions {
|
|
61
|
+
/** Oldest acceptable verification, in days. Default 180. */
|
|
62
|
+
maxAgeDays?: number;
|
|
63
|
+
/** The clock to check against. Default: now. */
|
|
64
|
+
now?: Date;
|
|
65
|
+
}
|
|
66
|
+
/** Default staleness threshold for comparison claims. */
|
|
67
|
+
declare const DEFAULT_COMPARISON_MAX_AGE_DAYS = 180;
|
|
68
|
+
/** Parse a strict YYYY-MM-DD date as UTC midnight, or null when invalid. */
|
|
69
|
+
declare function parseVerifiedAt(value: string): Date | null;
|
|
70
|
+
/**
|
|
71
|
+
* Check every claim and return the problems found (empty when all is well).
|
|
72
|
+
* A claim passes on its newest valid source, so adding a fresh source to an
|
|
73
|
+
* old claim is enough to clear a "stale" issue.
|
|
74
|
+
*/
|
|
75
|
+
declare function checkComparisonFreshness(claims: readonly ComparisonClaim[], options?: FreshnessOptions): FreshnessIssue[];
|
|
76
|
+
/** Newest valid verification date across all claims, or null. */
|
|
77
|
+
declare function latestVerification(claims: readonly ComparisonClaim[]): string | null;
|
|
78
|
+
/** Oldest valid verification date across all claims, or null. */
|
|
79
|
+
declare function oldestVerification(claims: readonly ComparisonClaim[]): string | null;
|
|
80
|
+
/** A plain-text report for CI logs, one line per issue. */
|
|
81
|
+
declare function formatFreshnessReport(issues: readonly FreshnessIssue[]): string;
|
|
82
|
+
|
|
83
|
+
export { type ComparisonClaim, type ComparisonSource, DEFAULT_COMPARISON_MAX_AGE_DAYS, type FreshnessIssue, type FreshnessOptions, type FreshnessProblem, type NonEmptySources, type Sourced, checkComparisonFreshness, formatFreshnessReport, latestVerification, oldestVerification, parseVerifiedAt };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/comparison/index.ts
|
|
4
|
+
var DEFAULT_COMPARISON_MAX_AGE_DAYS = 180;
|
|
5
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
6
|
+
var ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
7
|
+
function parseVerifiedAt(value) {
|
|
8
|
+
if (typeof value !== "string" || !ISO_DATE.test(value)) return null;
|
|
9
|
+
const date = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
10
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
11
|
+
return date.toISOString().slice(0, 10) === value ? date : null;
|
|
12
|
+
}
|
|
13
|
+
function isHttpUrl(value) {
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(value);
|
|
16
|
+
return url.protocol === "https:" || url.protocol === "http:";
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function checkComparisonFreshness(claims, options = {}) {
|
|
22
|
+
const maxAgeDays = options.maxAgeDays ?? DEFAULT_COMPARISON_MAX_AGE_DAYS;
|
|
23
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
24
|
+
const issues = [];
|
|
25
|
+
for (const claim of claims) {
|
|
26
|
+
const sources = claim.sources ?? [];
|
|
27
|
+
if (sources.length === 0) {
|
|
28
|
+
issues.push({
|
|
29
|
+
path: claim.path,
|
|
30
|
+
problem: "missing-source",
|
|
31
|
+
detail: "no source: add { url, verifiedAt } for the vendor page this claim was checked against"
|
|
32
|
+
});
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
let newest = null;
|
|
36
|
+
for (const source of sources) {
|
|
37
|
+
if (!isHttpUrl(source.url)) {
|
|
38
|
+
issues.push({
|
|
39
|
+
path: claim.path,
|
|
40
|
+
problem: "invalid-url",
|
|
41
|
+
detail: `source URL is not an http(s) URL: ${JSON.stringify(source.url)}`
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const verified = parseVerifiedAt(source.verifiedAt);
|
|
45
|
+
if (!verified) {
|
|
46
|
+
issues.push({
|
|
47
|
+
path: claim.path,
|
|
48
|
+
problem: "invalid-date",
|
|
49
|
+
detail: `verifiedAt must be YYYY-MM-DD, got ${JSON.stringify(source.verifiedAt)}`
|
|
50
|
+
});
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (verified.getTime() > now.getTime() + DAY_MS) {
|
|
54
|
+
issues.push({
|
|
55
|
+
path: claim.path,
|
|
56
|
+
problem: "future-date",
|
|
57
|
+
detail: `verifiedAt ${source.verifiedAt} is in the future`
|
|
58
|
+
});
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!newest || verified.getTime() > newest.getTime()) newest = verified;
|
|
62
|
+
}
|
|
63
|
+
if (newest) {
|
|
64
|
+
const ageDays = Math.floor((now.getTime() - newest.getTime()) / DAY_MS);
|
|
65
|
+
if (ageDays > maxAgeDays) {
|
|
66
|
+
issues.push({
|
|
67
|
+
path: claim.path,
|
|
68
|
+
problem: "stale",
|
|
69
|
+
detail: `last verified ${newest.toISOString().slice(0, 10)} (${ageDays} days ago, limit ${maxAgeDays}): re-read the vendor page and update verifiedAt or the claim`
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return issues;
|
|
75
|
+
}
|
|
76
|
+
function latestVerification(claims) {
|
|
77
|
+
let latest = null;
|
|
78
|
+
for (const claim of claims) {
|
|
79
|
+
for (const source of claim.sources ?? []) {
|
|
80
|
+
if (parseVerifiedAt(source.verifiedAt) && (!latest || source.verifiedAt > latest)) {
|
|
81
|
+
latest = source.verifiedAt;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return latest;
|
|
86
|
+
}
|
|
87
|
+
function oldestVerification(claims) {
|
|
88
|
+
let oldest = null;
|
|
89
|
+
for (const claim of claims) {
|
|
90
|
+
for (const source of claim.sources ?? []) {
|
|
91
|
+
if (parseVerifiedAt(source.verifiedAt) && (!oldest || source.verifiedAt < oldest)) {
|
|
92
|
+
oldest = source.verifiedAt;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return oldest;
|
|
97
|
+
}
|
|
98
|
+
function formatFreshnessReport(issues) {
|
|
99
|
+
if (issues.length === 0)
|
|
100
|
+
return "Comparison sources: all claims sourced and fresh.";
|
|
101
|
+
const lines = issues.map((i) => ` - [${i.problem}] ${i.path}: ${i.detail}`);
|
|
102
|
+
return [
|
|
103
|
+
`Comparison sources: ${issues.length} problem(s) found.`,
|
|
104
|
+
...lines,
|
|
105
|
+
"Re-check each claim against the vendor's current page, then update the claim and its verifiedAt."
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
exports.DEFAULT_COMPARISON_MAX_AGE_DAYS = DEFAULT_COMPARISON_MAX_AGE_DAYS;
|
|
110
|
+
exports.checkComparisonFreshness = checkComparisonFreshness;
|
|
111
|
+
exports.formatFreshnessReport = formatFreshnessReport;
|
|
112
|
+
exports.latestVerification = latestVerification;
|
|
113
|
+
exports.oldestVerification = oldestVerification;
|
|
114
|
+
exports.parseVerifiedAt = parseVerifiedAt;
|
|
115
|
+
//# sourceMappingURL=index.js.map
|
|
116
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/comparison/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @burdenoff/website-sdk - Comparison page sources and freshness (BOFF-9971)\n *\n * Every product website with a /vs (competitor comparison) page makes claims\n * about other vendors. Those claims age fast: a vendor ships the feature we\n * said it lacked, or changes its prices. This module gives every site the same\n * contract and the same check:\n *\n * - each claim carries at least one source `{ url, verifiedAt }` (enforced by\n * the `Sourced` type at compile time), and\n * - `checkComparisonFreshness` fails when a claim has no source, a source is\n * not an http(s) URL, a date is malformed or in the future, or the newest\n * verification is older than `maxAgeDays` (180 by default).\n *\n * Sites run the check in their PR Check workflow, so a stale or unsourced\n * comparison blocks the next change until someone re-reads the vendor's page.\n *\n * @example\n * ```ts\n * import { checkComparisonFreshness, formatFreshnessReport } from '@burdenoff/website-sdk/comparison'\n *\n * const issues = checkComparisonFreshness(collectClaims(COMPETITORS))\n * if (issues.length) {\n * console.error(formatFreshnessReport(issues))\n * process.exit(1)\n * }\n * ```\n */\n\n/** Where a comparison claim was checked, and when (ISO date, YYYY-MM-DD). */\nexport interface ComparisonSource {\n /** The vendor page (or docs page) the claim was checked against. */\n url: string;\n /** The day a person last read that page and confirmed the claim. */\n verifiedAt: string;\n /** Optional short label shown with the link, e.g. \"Pricing\". */\n label?: string;\n}\n\n/** A non-empty list: a sourced claim always has at least one source. */\nexport type NonEmptySources = readonly [\n ComparisonSource,\n ...ComparisonSource[],\n];\n\n/** Mix-in for any comparison claim (table row, profile, pricing row). */\nexport interface Sourced {\n sources: NonEmptySources;\n}\n\n/** One claim to check, located by a human-readable path for the report. */\nexport interface ComparisonClaim {\n /** Where the claim lives, e.g. \"zapier.featureRows[Workflow versioning]\". */\n path: string;\n /** Its sources; missing or empty fails the check. */\n sources?: readonly ComparisonSource[] | null;\n}\n\nexport type FreshnessProblem =\n \"missing-source\" | \"invalid-url\" | \"invalid-date\" | \"future-date\" | \"stale\";\n\nexport interface FreshnessIssue {\n path: string;\n problem: FreshnessProblem;\n detail: string;\n}\n\nexport interface FreshnessOptions {\n /** Oldest acceptable verification, in days. Default 180. */\n maxAgeDays?: number;\n /** The clock to check against. Default: now. */\n now?: Date;\n}\n\n/** Default staleness threshold for comparison claims. */\nexport const DEFAULT_COMPARISON_MAX_AGE_DAYS = 180;\n\nconst DAY_MS = 24 * 60 * 60 * 1000;\nconst ISO_DATE = /^\\d{4}-\\d{2}-\\d{2}$/;\n\n/** Parse a strict YYYY-MM-DD date as UTC midnight, or null when invalid. */\nexport function parseVerifiedAt(value: string): Date | null {\n if (typeof value !== \"string\" || !ISO_DATE.test(value)) return null;\n const date = new Date(`${value}T00:00:00.000Z`);\n if (Number.isNaN(date.getTime())) return null;\n // Reject rollovers such as 2026-02-31 → 2026-03-03.\n return date.toISOString().slice(0, 10) === value ? date : null;\n}\n\nfunction isHttpUrl(value: string): boolean {\n try {\n const url = new URL(value);\n return url.protocol === \"https:\" || url.protocol === \"http:\";\n } catch {\n return false;\n }\n}\n\n/**\n * Check every claim and return the problems found (empty when all is well).\n * A claim passes on its newest valid source, so adding a fresh source to an\n * old claim is enough to clear a \"stale\" issue.\n */\nexport function checkComparisonFreshness(\n claims: readonly ComparisonClaim[],\n options: FreshnessOptions = {},\n): FreshnessIssue[] {\n const maxAgeDays = options.maxAgeDays ?? DEFAULT_COMPARISON_MAX_AGE_DAYS;\n const now = options.now ?? new Date();\n const issues: FreshnessIssue[] = [];\n\n for (const claim of claims) {\n const sources = claim.sources ?? [];\n if (sources.length === 0) {\n issues.push({\n path: claim.path,\n problem: \"missing-source\",\n detail:\n \"no source: add { url, verifiedAt } for the vendor page this claim was checked against\",\n });\n continue;\n }\n\n let newest: Date | null = null;\n for (const source of sources) {\n if (!isHttpUrl(source.url)) {\n issues.push({\n path: claim.path,\n problem: \"invalid-url\",\n detail: `source URL is not an http(s) URL: ${JSON.stringify(source.url)}`,\n });\n }\n const verified = parseVerifiedAt(source.verifiedAt);\n if (!verified) {\n issues.push({\n path: claim.path,\n problem: \"invalid-date\",\n detail: `verifiedAt must be YYYY-MM-DD, got ${JSON.stringify(source.verifiedAt)}`,\n });\n continue;\n }\n if (verified.getTime() > now.getTime() + DAY_MS) {\n issues.push({\n path: claim.path,\n problem: \"future-date\",\n detail: `verifiedAt ${source.verifiedAt} is in the future`,\n });\n continue;\n }\n if (!newest || verified.getTime() > newest.getTime()) newest = verified;\n }\n\n if (newest) {\n const ageDays = Math.floor((now.getTime() - newest.getTime()) / DAY_MS);\n if (ageDays > maxAgeDays) {\n issues.push({\n path: claim.path,\n problem: \"stale\",\n detail: `last verified ${newest.toISOString().slice(0, 10)} (${ageDays} days ago, limit ${maxAgeDays}): re-read the vendor page and update verifiedAt or the claim`,\n });\n }\n }\n }\n\n return issues;\n}\n\n/** Newest valid verification date across all claims, or null. */\nexport function latestVerification(\n claims: readonly ComparisonClaim[],\n): string | null {\n let latest: string | null = null;\n for (const claim of claims) {\n for (const source of claim.sources ?? []) {\n if (\n parseVerifiedAt(source.verifiedAt) &&\n (!latest || source.verifiedAt > latest)\n ) {\n latest = source.verifiedAt;\n }\n }\n }\n return latest;\n}\n\n/** Oldest valid verification date across all claims, or null. */\nexport function oldestVerification(\n claims: readonly ComparisonClaim[],\n): string | null {\n let oldest: string | null = null;\n for (const claim of claims) {\n for (const source of claim.sources ?? []) {\n if (\n parseVerifiedAt(source.verifiedAt) &&\n (!oldest || source.verifiedAt < oldest)\n ) {\n oldest = source.verifiedAt;\n }\n }\n }\n return oldest;\n}\n\n/** A plain-text report for CI logs, one line per issue. */\nexport function formatFreshnessReport(\n issues: readonly FreshnessIssue[],\n): string {\n if (issues.length === 0)\n return \"Comparison sources: all claims sourced and fresh.\";\n const lines = issues.map((i) => ` - [${i.problem}] ${i.path}: ${i.detail}`);\n return [\n `Comparison sources: ${issues.length} problem(s) found.`,\n ...lines,\n \"Re-check each claim against the vendor's current page, then update the claim and its verifiedAt.\",\n ].join(\"\\n\");\n}\n"]}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// src/comparison/index.ts
|
|
2
|
+
var DEFAULT_COMPARISON_MAX_AGE_DAYS = 180;
|
|
3
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
4
|
+
var ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
5
|
+
function parseVerifiedAt(value) {
|
|
6
|
+
if (typeof value !== "string" || !ISO_DATE.test(value)) return null;
|
|
7
|
+
const date = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
8
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
9
|
+
return date.toISOString().slice(0, 10) === value ? date : null;
|
|
10
|
+
}
|
|
11
|
+
function isHttpUrl(value) {
|
|
12
|
+
try {
|
|
13
|
+
const url = new URL(value);
|
|
14
|
+
return url.protocol === "https:" || url.protocol === "http:";
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function checkComparisonFreshness(claims, options = {}) {
|
|
20
|
+
const maxAgeDays = options.maxAgeDays ?? DEFAULT_COMPARISON_MAX_AGE_DAYS;
|
|
21
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
22
|
+
const issues = [];
|
|
23
|
+
for (const claim of claims) {
|
|
24
|
+
const sources = claim.sources ?? [];
|
|
25
|
+
if (sources.length === 0) {
|
|
26
|
+
issues.push({
|
|
27
|
+
path: claim.path,
|
|
28
|
+
problem: "missing-source",
|
|
29
|
+
detail: "no source: add { url, verifiedAt } for the vendor page this claim was checked against"
|
|
30
|
+
});
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
let newest = null;
|
|
34
|
+
for (const source of sources) {
|
|
35
|
+
if (!isHttpUrl(source.url)) {
|
|
36
|
+
issues.push({
|
|
37
|
+
path: claim.path,
|
|
38
|
+
problem: "invalid-url",
|
|
39
|
+
detail: `source URL is not an http(s) URL: ${JSON.stringify(source.url)}`
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const verified = parseVerifiedAt(source.verifiedAt);
|
|
43
|
+
if (!verified) {
|
|
44
|
+
issues.push({
|
|
45
|
+
path: claim.path,
|
|
46
|
+
problem: "invalid-date",
|
|
47
|
+
detail: `verifiedAt must be YYYY-MM-DD, got ${JSON.stringify(source.verifiedAt)}`
|
|
48
|
+
});
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (verified.getTime() > now.getTime() + DAY_MS) {
|
|
52
|
+
issues.push({
|
|
53
|
+
path: claim.path,
|
|
54
|
+
problem: "future-date",
|
|
55
|
+
detail: `verifiedAt ${source.verifiedAt} is in the future`
|
|
56
|
+
});
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!newest || verified.getTime() > newest.getTime()) newest = verified;
|
|
60
|
+
}
|
|
61
|
+
if (newest) {
|
|
62
|
+
const ageDays = Math.floor((now.getTime() - newest.getTime()) / DAY_MS);
|
|
63
|
+
if (ageDays > maxAgeDays) {
|
|
64
|
+
issues.push({
|
|
65
|
+
path: claim.path,
|
|
66
|
+
problem: "stale",
|
|
67
|
+
detail: `last verified ${newest.toISOString().slice(0, 10)} (${ageDays} days ago, limit ${maxAgeDays}): re-read the vendor page and update verifiedAt or the claim`
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return issues;
|
|
73
|
+
}
|
|
74
|
+
function latestVerification(claims) {
|
|
75
|
+
let latest = null;
|
|
76
|
+
for (const claim of claims) {
|
|
77
|
+
for (const source of claim.sources ?? []) {
|
|
78
|
+
if (parseVerifiedAt(source.verifiedAt) && (!latest || source.verifiedAt > latest)) {
|
|
79
|
+
latest = source.verifiedAt;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return latest;
|
|
84
|
+
}
|
|
85
|
+
function oldestVerification(claims) {
|
|
86
|
+
let oldest = null;
|
|
87
|
+
for (const claim of claims) {
|
|
88
|
+
for (const source of claim.sources ?? []) {
|
|
89
|
+
if (parseVerifiedAt(source.verifiedAt) && (!oldest || source.verifiedAt < oldest)) {
|
|
90
|
+
oldest = source.verifiedAt;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return oldest;
|
|
95
|
+
}
|
|
96
|
+
function formatFreshnessReport(issues) {
|
|
97
|
+
if (issues.length === 0)
|
|
98
|
+
return "Comparison sources: all claims sourced and fresh.";
|
|
99
|
+
const lines = issues.map((i) => ` - [${i.problem}] ${i.path}: ${i.detail}`);
|
|
100
|
+
return [
|
|
101
|
+
`Comparison sources: ${issues.length} problem(s) found.`,
|
|
102
|
+
...lines,
|
|
103
|
+
"Re-check each claim against the vendor's current page, then update the claim and its verifiedAt."
|
|
104
|
+
].join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export { DEFAULT_COMPARISON_MAX_AGE_DAYS, checkComparisonFreshness, formatFreshnessReport, latestVerification, oldestVerification, parseVerifiedAt };
|
|
108
|
+
//# sourceMappingURL=index.mjs.map
|
|
109
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/comparison/index.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["/**\n * @burdenoff/website-sdk - Comparison page sources and freshness (BOFF-9971)\n *\n * Every product website with a /vs (competitor comparison) page makes claims\n * about other vendors. Those claims age fast: a vendor ships the feature we\n * said it lacked, or changes its prices. This module gives every site the same\n * contract and the same check:\n *\n * - each claim carries at least one source `{ url, verifiedAt }` (enforced by\n * the `Sourced` type at compile time), and\n * - `checkComparisonFreshness` fails when a claim has no source, a source is\n * not an http(s) URL, a date is malformed or in the future, or the newest\n * verification is older than `maxAgeDays` (180 by default).\n *\n * Sites run the check in their PR Check workflow, so a stale or unsourced\n * comparison blocks the next change until someone re-reads the vendor's page.\n *\n * @example\n * ```ts\n * import { checkComparisonFreshness, formatFreshnessReport } from '@burdenoff/website-sdk/comparison'\n *\n * const issues = checkComparisonFreshness(collectClaims(COMPETITORS))\n * if (issues.length) {\n * console.error(formatFreshnessReport(issues))\n * process.exit(1)\n * }\n * ```\n */\n\n/** Where a comparison claim was checked, and when (ISO date, YYYY-MM-DD). */\nexport interface ComparisonSource {\n /** The vendor page (or docs page) the claim was checked against. */\n url: string;\n /** The day a person last read that page and confirmed the claim. */\n verifiedAt: string;\n /** Optional short label shown with the link, e.g. \"Pricing\". */\n label?: string;\n}\n\n/** A non-empty list: a sourced claim always has at least one source. */\nexport type NonEmptySources = readonly [\n ComparisonSource,\n ...ComparisonSource[],\n];\n\n/** Mix-in for any comparison claim (table row, profile, pricing row). */\nexport interface Sourced {\n sources: NonEmptySources;\n}\n\n/** One claim to check, located by a human-readable path for the report. */\nexport interface ComparisonClaim {\n /** Where the claim lives, e.g. \"zapier.featureRows[Workflow versioning]\". */\n path: string;\n /** Its sources; missing or empty fails the check. */\n sources?: readonly ComparisonSource[] | null;\n}\n\nexport type FreshnessProblem =\n \"missing-source\" | \"invalid-url\" | \"invalid-date\" | \"future-date\" | \"stale\";\n\nexport interface FreshnessIssue {\n path: string;\n problem: FreshnessProblem;\n detail: string;\n}\n\nexport interface FreshnessOptions {\n /** Oldest acceptable verification, in days. Default 180. */\n maxAgeDays?: number;\n /** The clock to check against. Default: now. */\n now?: Date;\n}\n\n/** Default staleness threshold for comparison claims. */\nexport const DEFAULT_COMPARISON_MAX_AGE_DAYS = 180;\n\nconst DAY_MS = 24 * 60 * 60 * 1000;\nconst ISO_DATE = /^\\d{4}-\\d{2}-\\d{2}$/;\n\n/** Parse a strict YYYY-MM-DD date as UTC midnight, or null when invalid. */\nexport function parseVerifiedAt(value: string): Date | null {\n if (typeof value !== \"string\" || !ISO_DATE.test(value)) return null;\n const date = new Date(`${value}T00:00:00.000Z`);\n if (Number.isNaN(date.getTime())) return null;\n // Reject rollovers such as 2026-02-31 → 2026-03-03.\n return date.toISOString().slice(0, 10) === value ? date : null;\n}\n\nfunction isHttpUrl(value: string): boolean {\n try {\n const url = new URL(value);\n return url.protocol === \"https:\" || url.protocol === \"http:\";\n } catch {\n return false;\n }\n}\n\n/**\n * Check every claim and return the problems found (empty when all is well).\n * A claim passes on its newest valid source, so adding a fresh source to an\n * old claim is enough to clear a \"stale\" issue.\n */\nexport function checkComparisonFreshness(\n claims: readonly ComparisonClaim[],\n options: FreshnessOptions = {},\n): FreshnessIssue[] {\n const maxAgeDays = options.maxAgeDays ?? DEFAULT_COMPARISON_MAX_AGE_DAYS;\n const now = options.now ?? new Date();\n const issues: FreshnessIssue[] = [];\n\n for (const claim of claims) {\n const sources = claim.sources ?? [];\n if (sources.length === 0) {\n issues.push({\n path: claim.path,\n problem: \"missing-source\",\n detail:\n \"no source: add { url, verifiedAt } for the vendor page this claim was checked against\",\n });\n continue;\n }\n\n let newest: Date | null = null;\n for (const source of sources) {\n if (!isHttpUrl(source.url)) {\n issues.push({\n path: claim.path,\n problem: \"invalid-url\",\n detail: `source URL is not an http(s) URL: ${JSON.stringify(source.url)}`,\n });\n }\n const verified = parseVerifiedAt(source.verifiedAt);\n if (!verified) {\n issues.push({\n path: claim.path,\n problem: \"invalid-date\",\n detail: `verifiedAt must be YYYY-MM-DD, got ${JSON.stringify(source.verifiedAt)}`,\n });\n continue;\n }\n if (verified.getTime() > now.getTime() + DAY_MS) {\n issues.push({\n path: claim.path,\n problem: \"future-date\",\n detail: `verifiedAt ${source.verifiedAt} is in the future`,\n });\n continue;\n }\n if (!newest || verified.getTime() > newest.getTime()) newest = verified;\n }\n\n if (newest) {\n const ageDays = Math.floor((now.getTime() - newest.getTime()) / DAY_MS);\n if (ageDays > maxAgeDays) {\n issues.push({\n path: claim.path,\n problem: \"stale\",\n detail: `last verified ${newest.toISOString().slice(0, 10)} (${ageDays} days ago, limit ${maxAgeDays}): re-read the vendor page and update verifiedAt or the claim`,\n });\n }\n }\n }\n\n return issues;\n}\n\n/** Newest valid verification date across all claims, or null. */\nexport function latestVerification(\n claims: readonly ComparisonClaim[],\n): string | null {\n let latest: string | null = null;\n for (const claim of claims) {\n for (const source of claim.sources ?? []) {\n if (\n parseVerifiedAt(source.verifiedAt) &&\n (!latest || source.verifiedAt > latest)\n ) {\n latest = source.verifiedAt;\n }\n }\n }\n return latest;\n}\n\n/** Oldest valid verification date across all claims, or null. */\nexport function oldestVerification(\n claims: readonly ComparisonClaim[],\n): string | null {\n let oldest: string | null = null;\n for (const claim of claims) {\n for (const source of claim.sources ?? []) {\n if (\n parseVerifiedAt(source.verifiedAt) &&\n (!oldest || source.verifiedAt < oldest)\n ) {\n oldest = source.verifiedAt;\n }\n }\n }\n return oldest;\n}\n\n/** A plain-text report for CI logs, one line per issue. */\nexport function formatFreshnessReport(\n issues: readonly FreshnessIssue[],\n): string {\n if (issues.length === 0)\n return \"Comparison sources: all claims sourced and fresh.\";\n const lines = issues.map((i) => ` - [${i.problem}] ${i.path}: ${i.detail}`);\n return [\n `Comparison sources: ${issues.length} problem(s) found.`,\n ...lines,\n \"Re-check each claim against the vendor's current page, then update the claim and its verifiedAt.\",\n ].join(\"\\n\");\n}\n"]}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import 'react';
|
|
2
|
-
export {
|
|
2
|
+
export { a as EXPLORE_PREFILL_MAX_LENGTH, e as ExplorePage, f as ExplorePageProps, m as ExploreProvenance, n as ExploreReferenceKind, g as answerPlainText, h as answerSpeechText, i as exploreQuestionHref, j as inlineImageOrigins, k as isInlineImageAllowed, l as isInlineVideoAllowed, o as logoUrlOf, q as productNamesByOrigin, r as referenceKindOf, s as summariseReferences } from '../explore-BEgq1_0O.mjs';
|
|
3
3
|
import '../explore-types-_9UJu6EA.mjs';
|
|
4
4
|
import 'react-markdown';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import 'react';
|
|
2
|
-
export {
|
|
2
|
+
export { a as EXPLORE_PREFILL_MAX_LENGTH, e as ExplorePage, f as ExplorePageProps, m as ExploreProvenance, n as ExploreReferenceKind, g as answerPlainText, h as answerSpeechText, i as exploreQuestionHref, j as inlineImageOrigins, k as isInlineImageAllowed, l as isInlineVideoAllowed, o as logoUrlOf, q as productNamesByOrigin, r as referenceKindOf, s as summariseReferences } from '../explore-CHiP9u-4.js';
|
|
3
3
|
import '../explore-types-_9UJu6EA.js';
|
|
4
4
|
import 'react-markdown';
|