@wildo-ai/saas-website 1.1.5 → 1.1.6
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/dist/esm/astro/blog-post-bridge.d.ts +9 -0
- package/dist/esm/astro/blog-post-bridge.d.ts.map +1 -1
- package/dist/esm/astro/blog-post-bridge.js +10 -5
- package/dist/esm/astro/blog-post-bridge.js.map +1 -1
- package/dist/esm/astro/bridge-runtime.d.ts +25 -6
- package/dist/esm/astro/bridge-runtime.d.ts.map +1 -1
- package/dist/esm/astro/bridge-runtime.js +10 -6
- package/dist/esm/astro/bridge-runtime.js.map +1 -1
- package/dist/esm/astro/label-pack-loader.d.ts +4 -4
- package/dist/esm/astro/label-pack-loader.d.ts.map +1 -1
- package/dist/esm/astro/label-pack-loader.js +9 -4
- package/dist/esm/astro/label-pack-loader.js.map +1 -1
- package/dist/esm/astro/robots-renderer.d.ts +9 -8
- package/dist/esm/astro/robots-renderer.d.ts.map +1 -1
- package/dist/esm/astro/robots-renderer.js +15 -15
- package/dist/esm/astro/robots-renderer.js.map +1 -1
- package/dist/esm/astro/website-page-head.renderer.d.ts +1 -1
- package/dist/esm/astro/website-page-head.renderer.d.ts.map +1 -1
- package/dist/esm/astro/website-page-head.renderer.js +2 -1
- package/dist/esm/astro/website-page-head.renderer.js.map +1 -1
- package/dist/esm/astro/website-site-context.d.ts +5 -2
- package/dist/esm/astro/website-site-context.d.ts.map +1 -1
- package/dist/esm/astro/website-site-context.js.map +1 -1
- package/dist/esm/companion-exports.d.ts +8 -15
- package/dist/esm/companion-exports.d.ts.map +1 -1
- package/dist/esm/companion-exports.js +8 -15
- package/dist/esm/companion-exports.js.map +1 -1
- package/dist/esm/config/define-website-config.d.ts +3 -2
- package/dist/esm/config/define-website-config.d.ts.map +1 -1
- package/dist/esm/config/define-website-config.js +10 -10
- package/dist/esm/config/define-website-config.js.map +1 -1
- package/dist/esm/config/load-website-config.d.ts +6 -0
- package/dist/esm/config/load-website-config.d.ts.map +1 -1
- package/dist/esm/config/load-website-config.js +18 -9
- package/dist/esm/config/load-website-config.js.map +1 -1
- package/dist/esm/config/load-website-pricing-catalog.d.ts +13 -0
- package/dist/esm/config/load-website-pricing-catalog.d.ts.map +1 -0
- package/dist/esm/config/load-website-pricing-catalog.js +34 -0
- package/dist/esm/config/load-website-pricing-catalog.js.map +1 -0
- package/dist/esm/config/load-website-site-context.d.ts +53 -0
- package/dist/esm/config/load-website-site-context.d.ts.map +1 -0
- package/dist/esm/config/load-website-site-context.js +98 -0
- package/dist/esm/config/load-website-site-context.js.map +1 -0
- package/dist/esm/config-loader.d.ts +2 -0
- package/dist/esm/config-loader.d.ts.map +1 -1
- package/dist/esm/config-loader.js +2 -0
- package/dist/esm/config-loader.js.map +1 -1
- package/dist/esm/core/anonymous-session/InboundContactForm.d.ts +26 -6
- package/dist/esm/core/anonymous-session/InboundContactForm.d.ts.map +1 -1
- package/dist/esm/core/anonymous-session/InboundContactForm.js +51 -33
- package/dist/esm/core/anonymous-session/InboundContactForm.js.map +1 -1
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts +11 -0
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts.map +1 -1
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js +15 -4
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js.map +1 -1
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts +14 -2
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts.map +1 -1
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.js +34 -4
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.js.map +1 -1
- package/dist/esm/core/anonymous-session/website-submission-challenge.d.ts +36 -0
- package/dist/esm/core/anonymous-session/website-submission-challenge.d.ts.map +1 -0
- package/dist/esm/core/anonymous-session/website-submission-challenge.js +69 -0
- package/dist/esm/core/anonymous-session/website-submission-challenge.js.map +1 -0
- package/dist/esm/core/consent/WebsiteConsentBanner.d.ts.map +1 -1
- package/dist/esm/core/consent/WebsiteConsentBanner.js +11 -10
- package/dist/esm/core/consent/WebsiteConsentBanner.js.map +1 -1
- package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts +25 -5
- package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts.map +1 -1
- package/dist/esm/core/contexts/WebsiteRuntimeContext.js.map +1 -1
- package/dist/esm/core/contexts/useWebsiteRuntime.d.ts +18 -0
- package/dist/esm/core/contexts/useWebsiteRuntime.d.ts.map +1 -1
- package/dist/esm/core/contexts/useWebsiteRuntime.js +28 -2
- package/dist/esm/core/contexts/useWebsiteRuntime.js.map +1 -1
- package/dist/esm/core/external-providers/WebsiteProviderComponent.d.ts +57 -0
- package/dist/esm/core/external-providers/WebsiteProviderComponent.d.ts.map +1 -0
- package/dist/esm/core/external-providers/WebsiteProviderComponent.js +56 -0
- package/dist/esm/core/external-providers/WebsiteProviderComponent.js.map +1 -0
- package/dist/esm/core/external-providers/provider-component-registry.website.d.ts +63 -0
- package/dist/esm/core/external-providers/provider-component-registry.website.d.ts.map +1 -0
- package/dist/esm/core/external-providers/provider-component-registry.website.js +68 -0
- package/dist/esm/core/external-providers/provider-component-registry.website.js.map +1 -0
- package/dist/esm/core/external-providers/useWebsiteProviderComponent.d.ts +36 -0
- package/dist/esm/core/external-providers/useWebsiteProviderComponent.d.ts.map +1 -0
- package/dist/esm/core/external-providers/useWebsiteProviderComponent.js +59 -0
- package/dist/esm/core/external-providers/useWebsiteProviderComponent.js.map +1 -0
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts +15 -5
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts.map +1 -1
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.js +39 -24
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.js.map +1 -1
- package/dist/esm/core/external-providers/website-provider-sdk-activations.d.ts +11 -0
- package/dist/esm/core/external-providers/website-provider-sdk-activations.d.ts.map +1 -0
- package/dist/esm/core/external-providers/website-provider-sdk-activations.js +14 -0
- package/dist/esm/core/external-providers/website-provider-sdk-activations.js.map +1 -0
- package/dist/esm/core/factories/define-website-section.d.ts +3 -3
- package/dist/esm/core/factories/define-website-section.js +3 -3
- package/dist/esm/core/factories/define-website-section.js.map +1 -1
- package/dist/esm/core/layouts/WebsitePageLayout.d.ts.map +1 -1
- package/dist/esm/core/layouts/WebsitePageLayout.js +10 -7
- package/dist/esm/core/layouts/WebsitePageLayout.js.map +1 -1
- package/dist/esm/index.d.ts +30 -65
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +30 -65
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts +35 -6
- package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js +39 -5
- package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js.map +1 -1
- package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.d.ts +32 -0
- package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.d.ts.map +1 -0
- package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.js +36 -0
- package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.js.map +1 -0
- package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts +6 -5
- package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js +8 -7
- package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js.map +1 -1
- package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts +14 -7
- package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js +14 -7
- package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js.map +1 -1
- package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.d.ts +57 -0
- package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.d.ts.map +1 -0
- package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.js +76 -0
- package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.js.map +1 -0
- package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts +23 -0
- package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts.map +1 -1
- package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js +20 -2
- package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js.map +1 -1
- package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts +7 -5
- package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/__tests__/bundle-isolation.test.ts +19 -22
- package/src/astro/__tests__/blog-post-bridge.test.tsx +18 -2
- package/src/astro/__tests__/bridge-runtime.test.tsx +21 -3
- package/src/astro/__tests__/label-pack-loader.test.ts +7 -4
- package/src/astro/blog-post-bridge.tsx +19 -5
- package/src/astro/bridge-runtime.tsx +35 -11
- package/src/astro/label-pack-loader.ts +8 -4
- package/src/astro/robots-renderer.ts +15 -15
- package/src/astro/website-page-head.renderer.ts +2 -2
- package/src/astro/website-site-context.ts +5 -2
- package/src/companion-exports.ts +8 -15
- package/src/config/__tests__/website-build-origins.test.ts +52 -0
- package/src/config/define-website-config.ts +10 -10
- package/src/config/load-website-config.ts +25 -14
- package/src/config/load-website-pricing-catalog.ts +52 -0
- package/src/config/load-website-site-context.ts +145 -0
- package/src/config-loader.ts +2 -0
- package/src/core/__tests__/contexts.test.tsx +20 -1
- package/src/core/__tests__/website-consent.test.tsx +2 -1
- package/src/core/anonymous-session/InboundContactForm.tsx +100 -45
- package/src/core/anonymous-session/__tests__/InboundContactForm.labels.test.tsx +108 -0
- package/src/core/anonymous-session/__tests__/InboundContactForm.submission-challenge.test.tsx +139 -0
- package/src/core/anonymous-session/inbound-contact-form.schema.ts +16 -4
- package/src/core/anonymous-session/website-anonymous-session-client.ts +39 -4
- package/src/core/anonymous-session/website-submission-challenge.ts +99 -0
- package/src/core/consent/WebsiteConsentBanner.tsx +11 -10
- package/src/core/contexts/WebsiteRuntimeContext.tsx +25 -5
- package/src/core/contexts/useWebsiteRuntime.ts +33 -2
- package/src/core/external-providers/WebsiteProviderComponent.tsx +153 -0
- package/src/core/external-providers/__tests__/frontend-provider-registry.website.test.ts +4 -0
- package/src/core/external-providers/__tests__/website-provider-component.test.tsx +238 -0
- package/src/core/external-providers/__tests__/website-provider-sdks.test.tsx +72 -0
- package/src/core/external-providers/provider-component-registry.website.ts +115 -0
- package/src/core/external-providers/useWebsiteProviderComponent.ts +76 -0
- package/src/core/external-providers/useWebsiteProviderScripts.ts +56 -37
- package/src/core/external-providers/website-provider-sdk-activations.ts +23 -0
- package/src/core/factories/define-website-section.ts +3 -3
- package/src/core/layouts/WebsitePageLayout.tsx +11 -6
- package/src/index.ts +31 -65
- package/src/schemas/__tests__/design-token-css-property.test.ts +72 -0
- package/src/schemas/__tests__/pricing-offer-structured-data.test.ts +122 -0
- package/src/schemas/__tests__/structured-data-reconciliation.test.ts +35 -0
- package/src/schemas/design-tokens/website-design-tokens.shared.schemas.ts +45 -5
- package/src/schemas/label-keys/__tests__/scaffolded-consent-label-pack.parity.test.ts +12 -0
- package/src/schemas/label-keys/website-provider-component-label-keys.schemas.ts +40 -0
- package/src/schemas/manifests/website-root-config.shared.schemas.ts +10 -9
- package/src/schemas/sections/website-section-definition.shared.schemas.ts +14 -7
- package/src/schemas/structured-data/pricing-offer-structured-data.shared.ts +144 -0
- package/src/schemas/structured-data/structured-data-reconciliation.shared.ts +46 -1
- package/src/schemas/structured-data/website-structured-data.shared.schemas.ts +7 -5
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
ProductAvailability,
|
|
4
|
+
ProductPriceDisclosure,
|
|
5
|
+
PublicPricingAmountBasis,
|
|
6
|
+
PublicPricingRecurrenceUnit,
|
|
7
|
+
type PublicPricingPlan,
|
|
8
|
+
type PublicPricingPrice,
|
|
9
|
+
} from '@wildo-ai/saas-models/public-runtime';
|
|
10
|
+
|
|
11
|
+
import { buildWebsitePricingOfferStructuredData } from '../structured-data/pricing-offer-structured-data.shared';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pins for the pricing page's `AggregateOffer` (#1835). A search engine shows `price` as the price of
|
|
15
|
+
* the offer, so each refusal pinned here is a case where publishing the catalogue's number would be a
|
|
16
|
+
* false statement.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const monthly = (amountMinor: string, overrides: Partial<PublicPricingPrice> = {}): PublicPricingPrice => ({
|
|
20
|
+
amountMinor,
|
|
21
|
+
decimals: 2,
|
|
22
|
+
currency: 'USD',
|
|
23
|
+
recurrence: { unit: PublicPricingRecurrenceUnit.MONTH, count: 1 },
|
|
24
|
+
amountBasis: PublicPricingAmountBasis.PER_UNIT,
|
|
25
|
+
isDefault: true,
|
|
26
|
+
...overrides,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const plan = (key: string, overrides: Partial<PublicPricingPlan> = {}): PublicPricingPlan => ({
|
|
30
|
+
key,
|
|
31
|
+
sortOrder: 0,
|
|
32
|
+
targetScopes: ['organizations'],
|
|
33
|
+
isRecommended: false,
|
|
34
|
+
priceDisclosure: ProductPriceDisclosure.LISTED,
|
|
35
|
+
availability: ProductAvailability.AVAILABLE,
|
|
36
|
+
prices: [monthly('0')],
|
|
37
|
+
...overrides,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const build = (plans: PublicPricingPlan[], planName?: (key: string) => string | undefined, perUnitName: string | null = 'member') =>
|
|
41
|
+
buildWebsitePricingOfferStructuredData({
|
|
42
|
+
catalog: { plans },
|
|
43
|
+
itemOfferedId: 'https://example.test/#software-application',
|
|
44
|
+
aggregateOfferIdPrefix: 'https://example.test/pricing#plans',
|
|
45
|
+
url: 'https://example.test/pricing',
|
|
46
|
+
...(planName === undefined ? {} : { planName }),
|
|
47
|
+
...(perUnitName === null ? {} : { perUnitName }),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
type AggregateLike = { '@id': string; lowPrice: string; highPrice: string; offerCount: number; offers: Array<Record<string, unknown>> };
|
|
51
|
+
|
|
52
|
+
describe('buildWebsitePricingOfferStructuredData', () => {
|
|
53
|
+
it('publishes a per-unit price with what it is per — never as a whole price', () => {
|
|
54
|
+
const [aggregate] = build([plan('team', { prices: [monthly('400')] })], (key) => ({ team: 'Team' })[key]) as AggregateLike[];
|
|
55
|
+
expect(aggregate!.offers[0]).toEqual({
|
|
56
|
+
'@type': 'Offer',
|
|
57
|
+
name: 'Team',
|
|
58
|
+
price: '4',
|
|
59
|
+
priceCurrency: 'USD',
|
|
60
|
+
availability: 'https://schema.org/InStock',
|
|
61
|
+
url: 'https://example.test/pricing#plan-team',
|
|
62
|
+
priceSpecification: {
|
|
63
|
+
'@type': 'UnitPriceSpecification',
|
|
64
|
+
price: '4',
|
|
65
|
+
priceCurrency: 'USD',
|
|
66
|
+
billingDuration: 'P1M',
|
|
67
|
+
referenceQuantity: { '@type': 'QuantitativeValue', value: 1, unitText: 'member' },
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('refuses to publish a per-unit price when the page did not name the unit', () => {
|
|
73
|
+
expect(() => build([plan('team', { prices: [monthly('400')] })], undefined, null)).toThrow(/perUnitName/u);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('never folds a flat price and a per-member price, or monthly and yearly prices, into one range', () => {
|
|
77
|
+
const aggregates = build([
|
|
78
|
+
plan('free', { prices: [monthly('0', { amountBasis: PublicPricingAmountBasis.FLAT })] }),
|
|
79
|
+
plan('team', { prices: [monthly('400')] }),
|
|
80
|
+
plan('company', { prices: [monthly('600')] }),
|
|
81
|
+
plan('annual', { prices: [monthly('4000', { amountBasis: PublicPricingAmountBasis.FLAT, recurrence: { unit: PublicPricingRecurrenceUnit.YEAR, count: 1 } })] }),
|
|
82
|
+
]) as AggregateLike[];
|
|
83
|
+
expect(aggregates.map((aggregate) => [aggregate['@id'], aggregate.lowPrice, aggregate.highPrice, aggregate.offerCount])).toEqual([
|
|
84
|
+
['https://example.test/pricing#plans-flat-1-month-usd', '0', '0', 1],
|
|
85
|
+
['https://example.test/pricing#plans-per-unit-1-month-usd', '4', '6', 2],
|
|
86
|
+
['https://example.test/pricing#plans-flat-1-year-usd', '40', '40', 1],
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("names an aggregate by what it aggregates, so a plan added to ANOTHER group never moves it", () => {
|
|
91
|
+
const perUnitId = (plans: PublicPricingPlan[]) =>
|
|
92
|
+
(build(plans) as AggregateLike[]).find((aggregate) => aggregate.offers.some((offer) => offer.url === 'https://example.test/pricing#plan-team'))!['@id'];
|
|
93
|
+
const team = plan('team', { prices: [monthly('400')] });
|
|
94
|
+
const free = plan('free', { prices: [monthly('0', { amountBasis: PublicPricingAmountBasis.FLAT })] });
|
|
95
|
+
expect(perUnitId([team])).toBe('https://example.test/pricing#plans-per-unit-1-month-usd');
|
|
96
|
+
expect(perUnitId([free, team])).toBe(perUnitId([team]));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it.each([
|
|
100
|
+
['a starting price (only a floor)', plan('company', { priceDisclosure: ProductPriceDisclosure.STARTING_AT, prices: [monthly('600')] })],
|
|
101
|
+
['an on-request plan (no price)', plan('enterprise', { priceDisclosure: ProductPriceDisclosure.ON_REQUEST, prices: [monthly('99900')] })],
|
|
102
|
+
['an upcoming plan (cannot be bought)', plan('next', { availability: ProductAvailability.UPCOMING, prices: [monthly('900')] })],
|
|
103
|
+
['a tiered price (the amount is indicative)', plan('volume', { prices: [monthly('100', { amountBasis: PublicPricingAmountBasis.VARIABLE })] })],
|
|
104
|
+
])('never publishes %s as a price', (_label, excluded) => {
|
|
105
|
+
const aggregates = build([plan('team', { prices: [monthly('400')] }), excluded]) as AggregateLike[];
|
|
106
|
+
expect(aggregates.flatMap((aggregate) => aggregate.offers.map((offer) => offer.url))).toEqual(['https://example.test/pricing#plan-team']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('publishes nothing when nothing may be published — the true statement for an on-request-only catalogue', () => {
|
|
110
|
+
expect(build([plan('enterprise', { priceDisclosure: ProductPriceDisclosure.ON_REQUEST })])).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('never blends currencies into one aggregate', () => {
|
|
114
|
+
const aggregates = build([plan('us', { prices: [monthly('400')] }), plan('eu', { prices: [monthly('500', { currency: 'EUR' })] })]);
|
|
115
|
+
expect(aggregates.map((aggregate) => aggregate.priceCurrency)).toEqual(['USD', 'EUR']);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('omits a one-time offer\'s billing duration rather than inventing one', () => {
|
|
119
|
+
const [aggregate] = build([plan('lifetime', { prices: [monthly('9900', { amountBasis: PublicPricingAmountBasis.FLAT, recurrence: { unit: PublicPricingRecurrenceUnit.ONE_TIME, count: 1 } })] })]) as AggregateLike[];
|
|
120
|
+
expect(aggregate!.offers[0]!.priceSpecification).toEqual({ '@type': 'UnitPriceSpecification', price: '99', priceCurrency: 'USD' });
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -541,3 +541,38 @@ describe('reconcileStructuredDataAgainstCatalog', () => {
|
|
|
541
541
|
);
|
|
542
542
|
});
|
|
543
543
|
});
|
|
544
|
+
|
|
545
|
+
describe('optional-field recommendations — the catalog entry\'s recommendOptionals, in its own channel', () => {
|
|
546
|
+
const reconcile = (recommendOptionals: boolean, payload: Record<string, unknown>) =>
|
|
547
|
+
reconcileStructuredDataAgainstCatalog({
|
|
548
|
+
catalog: [
|
|
549
|
+
createCatalogEntry({
|
|
550
|
+
'@type': 'SoftwareApplication',
|
|
551
|
+
requiredFields: ['name'],
|
|
552
|
+
optionalFields: ['offers', 'aggregateRating', 'screenshot'],
|
|
553
|
+
recommendOptionals,
|
|
554
|
+
}),
|
|
555
|
+
],
|
|
556
|
+
pages: [{ ref: 'pricing', routePath: '/pricing', sectionRefs: ['pricing-plans'] }],
|
|
557
|
+
sections: [{ sectionRef: 'pricing-plans', getStructuredData: () => ({ '@type': 'SoftwareApplication', name: 'App', ...payload }) }],
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('recommends the optional fields a payload leaves out, when the entry opts in — and reports no finding', () => {
|
|
561
|
+
const result = reconcile(true, { offers: { '@type': 'Offer' } });
|
|
562
|
+
expect(result.findings).toEqual([]);
|
|
563
|
+
expect(result.recommendations).toHaveLength(1);
|
|
564
|
+
expect(result.recommendations[0]).toMatchObject({
|
|
565
|
+
pageRef: 'pricing',
|
|
566
|
+
sectionRef: 'pricing-plans',
|
|
567
|
+
entityType: 'SoftwareApplication',
|
|
568
|
+
missingOptionalFields: ['aggregateRating', 'screenshot'],
|
|
569
|
+
});
|
|
570
|
+
// A recommendation is not counted as a finding on the page either.
|
|
571
|
+
expect(result.pages[0]?.findingCount).toBe(0);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('stays quiet when the entry does not opt in (the default), and when nothing is missing', () => {
|
|
575
|
+
expect(reconcile(false, {}).recommendations).toEqual([]);
|
|
576
|
+
expect(reconcile(true, { offers: {}, aggregateRating: {}, screenshot: 'x.png' }).recommendations).toEqual([]);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
@@ -62,8 +62,10 @@ const cssSafeValue = (fieldName: string): z.ZodString =>
|
|
|
62
62
|
* @wildo_source:part:start saas.website.design-tokens.colors facet:layer:shared facet:family:website
|
|
63
63
|
*
|
|
64
64
|
* `WebsiteColorTokens` — minimal palette the marketing-site exposes as
|
|
65
|
-
* CSS custom properties (`--website-
|
|
66
|
-
*
|
|
65
|
+
* CSS custom properties (`--website-colors-<token>`, the key verbatim, so
|
|
66
|
+
* `surfaceForeground` → `--website-colors-surfaceForeground`) on `:root`.
|
|
67
|
+
* Reference one from code through `websiteDesignTokenCssVar('colors', …)`
|
|
68
|
+
* rather than spelling the name.
|
|
67
69
|
*
|
|
68
70
|
* **Why a dedicated marketing palette** (vs. reusing
|
|
69
71
|
* `presets-components-models/design-tokens-config`):
|
|
@@ -92,8 +94,9 @@ const cssSafeValue = (fieldName: string): z.ZodString =>
|
|
|
92
94
|
* `cssSafeValue` JSDoc above for the full threat model.
|
|
93
95
|
*
|
|
94
96
|
* **Adding a token**: append the field here using `cssSafeValue(...)`,
|
|
95
|
-
* document its semantic intent in the JSDoc
|
|
96
|
-
*
|
|
97
|
+
* document its semantic intent in the JSDoc. `renderWebsiteDesignTokensCss`
|
|
98
|
+
* (from `@wildo-ai/saas-website/astro`) emits every key generically, so no
|
|
99
|
+
* emitter needs editing.
|
|
97
100
|
*/
|
|
98
101
|
const _WebsiteColorTokensSchema = z.object({
|
|
99
102
|
background: cssSafeValue('background color'),
|
|
@@ -118,7 +121,7 @@ export type WebsiteColorTokens = z.infer<typeof _WebsiteColorTokensSchema>;
|
|
|
118
121
|
* @wildo_source:part:start saas.website.design-tokens.radius facet:layer:shared facet:family:website
|
|
119
122
|
*
|
|
120
123
|
* `WebsiteRadiusTokens` — border-radius scale exposed as
|
|
121
|
-
* `--website-
|
|
124
|
+
* `--website-radii-<token>` CSS variables.
|
|
122
125
|
*
|
|
123
126
|
* Marketing radii are intentionally a separate token set from the
|
|
124
127
|
* SaaS app's radii: the website typically wants larger pill / hero
|
|
@@ -164,3 +167,40 @@ export const WebsiteDesignTokensSchema: typeof _WebsiteDesignTokensSchema = _Web
|
|
|
164
167
|
|
|
165
168
|
export type WebsiteDesignTokens = z.infer<typeof _WebsiteDesignTokensSchema>;
|
|
166
169
|
/** @wildo_source:part:end saas.website.design-tokens.bundle */
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The CSS custom-property NAME one design token is emitted under —
|
|
173
|
+
* `--website-<group>-<token>`, with the token key verbatim (no case
|
|
174
|
+
* conversion), so `('colors', 'surfaceForeground')` is
|
|
175
|
+
* `--website-colors-surfaceForeground`.
|
|
176
|
+
*
|
|
177
|
+
* This is the one place the naming rule lives. `renderWebsiteDesignTokensCss`
|
|
178
|
+
* emits through it, and every engine component that reads a token reads
|
|
179
|
+
* through `websiteDesignTokenCssVar`. It exists because the rule used to be
|
|
180
|
+
* spelled by hand at each reader, and one reader spelled it
|
|
181
|
+
* `surface-foreground`: the consent banner's text colour silently fell back to
|
|
182
|
+
* `#000` on every site, whatever palette the application authored.
|
|
183
|
+
*/
|
|
184
|
+
export function websiteDesignTokenCssProperty(group: string, token: string): string {
|
|
185
|
+
return `--website-${group}-${token}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A `var(...)` reference to one design token, typed against the token bundle
|
|
190
|
+
* so a misspelt group or key does not compile.
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* ```ts
|
|
194
|
+
* import { websiteDesignTokenCssVar } from '@wildo-ai/saas-website';
|
|
195
|
+
* const style = { color: websiteDesignTokenCssVar('colors', 'surfaceForeground', '#000') };
|
|
196
|
+
* // → 'var(--website-colors-surfaceForeground, #000)'
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
export function websiteDesignTokenCssVar<G extends keyof WebsiteDesignTokens>(
|
|
200
|
+
group: G,
|
|
201
|
+
token: keyof WebsiteDesignTokens[G] & string,
|
|
202
|
+
fallback?: string,
|
|
203
|
+
): string {
|
|
204
|
+
const property = websiteDesignTokenCssProperty(group, token);
|
|
205
|
+
return fallback === undefined ? `var(${property})` : `var(${property}, ${fallback})`;
|
|
206
|
+
}
|
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
import { describe, expect, it } from 'vitest';
|
|
6
6
|
|
|
7
7
|
import { WEBSITE_CONSENT_LABEL_KEYS } from '../website-consent-label-keys.schemas';
|
|
8
|
+
import { WEBSITE_PROVIDER_COMPONENT_LABEL_KEYS } from '../website-provider-component-label-keys.schemas';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* The `add-website` scaffold must author EVERY framework-owned consent key.
|
|
@@ -68,4 +69,15 @@ describe('the add-website scaffold’s locale pack', () => {
|
|
|
68
69
|
expect(entries.length).toBe(WEBSITE_CONSENT_LABEL_KEYS.length);
|
|
69
70
|
for (const [, key, value] of entries) expect(value!.trim(), `${key!} is authored empty`).not.toBe('');
|
|
70
71
|
});
|
|
72
|
+
|
|
73
|
+
it('authors exactly the provider-component slot keys the framework requires of every site (#1703)', () => {
|
|
74
|
+
// Same parity, same reason: the slot's consent-required and unavailable wording is framework-owned
|
|
75
|
+
// and required of every pack, so a composed site missing it would not build.
|
|
76
|
+
const template = readFileSync(join(findRepositoryRoot(), TEMPLATE_RELATIVE_PATH), 'utf8');
|
|
77
|
+
const entries = [...template.matchAll(/"(website\.chrome\.provider-component\.[^"]+)"\s*:\s*"([^"]*)"/gu)];
|
|
78
|
+
expect(entries.map(([, key]) => key!).sort()).toEqual(
|
|
79
|
+
WEBSITE_PROVIDER_COMPONENT_LABEL_KEYS.map((key) => key as unknown as string).sort(),
|
|
80
|
+
);
|
|
81
|
+
for (const [, key, value] of entries) expect(value!.trim(), `${key!} is authored empty`).not.toBe('');
|
|
82
|
+
});
|
|
71
83
|
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { WebsiteLabelKeySchema, type WebsiteLabelKey } from './website-label-key.schemas';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The label keys the framework's provider-component slot reads (#1703).
|
|
5
|
+
*
|
|
6
|
+
* FRAMEWORK-OWNED, like the consent panel's keys: `WebsiteProviderComponent` renders these states,
|
|
7
|
+
* not the site's chrome, so the site does not list them in `chromeLabelKeys`.
|
|
8
|
+
* `composeEffectiveChromeLabelKeys` unions them into the label-pack validator's expected set, so a
|
|
9
|
+
* locale pack missing one fails the build rather than showing a visitor a raw key.
|
|
10
|
+
*
|
|
11
|
+
* Required of every site, including one that places no provider component today, for the reason the
|
|
12
|
+
* consent keys are: the words are cheap, and a site that adds an address lookup tomorrow must not
|
|
13
|
+
* ship it with an unlabelled refusal. The consent-required state also names each missing purpose and
|
|
14
|
+
* offers the panel, and it reuses the consent keys for both (`purpose-<purpose>`, `manage`) rather
|
|
15
|
+
* than a second wording of the same thing.
|
|
16
|
+
*/
|
|
17
|
+
export enum WebsiteProviderComponentLabelSlot {
|
|
18
|
+
/**
|
|
19
|
+
* Shown where a provider component would be when the visitor has not agreed to a purpose it rests
|
|
20
|
+
* on. Says the feature is off until they agree; the purposes and the way to agree follow it.
|
|
21
|
+
*/
|
|
22
|
+
CONSENT_REQUIRED = 'consent-required',
|
|
23
|
+
/**
|
|
24
|
+
* Shown when the component could not be loaded (a network failure, a blocked script). Says the
|
|
25
|
+
* feature is unavailable right now, without blaming the visitor.
|
|
26
|
+
*/
|
|
27
|
+
UNAVAILABLE = 'unavailable',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const PROVIDER_COMPONENT_LABEL_KEY_PREFIX = 'website.chrome.provider-component';
|
|
31
|
+
|
|
32
|
+
/** The fully-composed key for one slot state. */
|
|
33
|
+
export function websiteProviderComponentLabelKey(slot: WebsiteProviderComponentLabelSlot): WebsiteLabelKey {
|
|
34
|
+
return WebsiteLabelKeySchema.parse(`${PROVIDER_COMPONENT_LABEL_KEY_PREFIX}.${slot}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Every framework-owned provider-component key — what a locale pack must carry. Total over the vocabulary. */
|
|
38
|
+
export const WEBSITE_PROVIDER_COMPONENT_LABEL_KEYS: readonly WebsiteLabelKey[] = Object.freeze(
|
|
39
|
+
Object.values(WebsiteProviderComponentLabelSlot).map(websiteProviderComponentLabelKey),
|
|
40
|
+
);
|
|
@@ -94,9 +94,10 @@ export type WebsiteSitemapConfig = z.infer<typeof _WebsiteSitemapConfigSchema>;
|
|
|
94
94
|
* `'noindex'` is the deliberate exception (staging environments,
|
|
95
95
|
* beta surfaces, sites still under construction). The opt-OUT
|
|
96
96
|
* shape mirrors `WebsiteSitemapConfig.enabled` for consistency.
|
|
97
|
-
* - `'noindex'`
|
|
98
|
-
*
|
|
99
|
-
*
|
|
97
|
+
* - `'noindex'` is expressed by a `<meta name="robots" content="noindex">`
|
|
98
|
+
* on every page, NOT by `Disallow: /` in `robots.txt` (#494): a crawler
|
|
99
|
+
* barred from fetching a page never reads its noindex, so the URL can
|
|
100
|
+
* still be indexed bare. `robots.txt` stays allow-everything for it.
|
|
100
101
|
*
|
|
101
102
|
* **Why `disallow[]` is a path-prefix list (not a regex)**:
|
|
102
103
|
* - The `robots.txt` spec only supports literal path prefixes
|
|
@@ -420,10 +421,10 @@ const _WebsiteRootConfigSchema = z
|
|
|
420
421
|
* the chrome) or above it (creating a structural surface
|
|
421
422
|
* the framework needs anyway).
|
|
422
423
|
*
|
|
423
|
-
*
|
|
424
|
-
* label-pack
|
|
425
|
-
*
|
|
426
|
-
*
|
|
424
|
+
* It is validated as a chrome key without being listed in
|
|
425
|
+
* `chromeLabelKeys`: the label-pack loader adds it to the effective
|
|
426
|
+
* chrome key set (`composeEffectiveChromeLabelKeys` in
|
|
427
|
+
* `astro/label-pack-loader.ts`), so consumers cannot forget it.
|
|
427
428
|
*/
|
|
428
429
|
skipToMainContentLabelKey: WebsiteLabelKeySchema,
|
|
429
430
|
})
|
|
@@ -484,8 +485,8 @@ export const WebsiteRootConfigSchema: typeof _WebsiteRootConfigSchema = _Website
|
|
|
484
485
|
* and `navigation.footerComponent` as `unknown` (their schema field
|
|
485
486
|
* type), losing the `ComponentType<unknown>` narrowing carried by the
|
|
486
487
|
* hand-written `WebsitePageManifest` and `WebsiteNavigationConfig`
|
|
487
|
-
* interfaces.
|
|
488
|
-
*
|
|
488
|
+
* interfaces. The layout primitives and the Astro helpers consume
|
|
489
|
+
* `WebsiteRootConfig` and must see those
|
|
489
490
|
* fields pre-narrowed; otherwise every consumer would need a local
|
|
490
491
|
* `as ComponentType` cast at the JSX call site, defeating the type-safe
|
|
491
492
|
* component contract.
|
|
@@ -14,7 +14,8 @@ import { WebsiteSectionCategorySchema } from './website-section-category.shared'
|
|
|
14
14
|
* `core/define-website-section.tsx` module in Phase 2 because it carries
|
|
15
15
|
* React types in its inputs). The shape is declared in `schemas/`
|
|
16
16
|
* because it is the **observable contract** the page-manifest
|
|
17
|
-
* registration, label-pack validator, and
|
|
17
|
+
* registration, the label-pack validator, `renderPageStructuredData` and the
|
|
18
|
+
* structured-data audit depend on.
|
|
18
19
|
*
|
|
19
20
|
* **Shape rationale** (each field carries semantic weight — see
|
|
20
21
|
* `design-philosophy.mdc`):
|
|
@@ -24,7 +25,8 @@ import { WebsiteSectionCategorySchema } from './website-section-category.shared'
|
|
|
24
25
|
* (a section can be reused with the same `sectionRef` across pages
|
|
25
26
|
* when its label-key root is intentionally shared).
|
|
26
27
|
* - `category` — see `WebsiteSectionCategory` JSDoc; tags the section
|
|
27
|
-
* for
|
|
28
|
+
* for skill-mapping and analytics labelling. It does NOT select the
|
|
29
|
+
* section's structured data.
|
|
28
30
|
* - `expectedLabelKeys` — the **fully-composed** label keys the section
|
|
29
31
|
* commits to consuming. The plan originally proposed leaf-relative
|
|
30
32
|
* keys (`'headline'`) resolved at runtime against the
|
|
@@ -37,9 +39,13 @@ import { WebsiteSectionCategorySchema } from './website-section-category.shared'
|
|
|
37
39
|
* graph — `wildo website audit` can list a page's required keys
|
|
38
40
|
* without booting React.
|
|
39
41
|
* 3. The leaf-relative ergonomics (`useWebsiteLabel('headline')`)
|
|
40
|
-
* survive
|
|
41
|
-
*
|
|
42
|
-
* `expectedLabelKeys
|
|
42
|
+
* survive — the hook composes the leaf with the page+section
|
|
43
|
+
* context and looks the result up in the active pack. It does
|
|
44
|
+
* NOT check the key against `expectedLabelKeys`: a leaf the
|
|
45
|
+
* section never declared renders its key (with a console warning)
|
|
46
|
+
* and is caught by nothing until a reviewer reads the page. The
|
|
47
|
+
* build and `wildo website audit` check the pack against the
|
|
48
|
+
* DECLARED keys, so declare every slot the component reads.
|
|
43
49
|
* - `Component` — the React component that renders the section. Typed
|
|
44
50
|
* as `ComponentType<unknown>` here because schema-layer cannot lock
|
|
45
51
|
* the component's prop shape (props vary per section). The factory
|
|
@@ -49,8 +55,9 @@ import { WebsiteSectionCategorySchema } from './website-section-category.shared'
|
|
|
49
55
|
* - `getStructuredData?` — optional pure function the SEO/AEO/GEO
|
|
50
56
|
* builder library invokes at static-build time to materialize JSON-LD
|
|
51
57
|
* for the section. The return type is intentionally `unknown` here;
|
|
52
|
-
*
|
|
53
|
-
* `
|
|
58
|
+
* nothing narrows it by `category` — the section returns whatever
|
|
59
|
+
* vocabulary it emits, and `wildo website audit` reconciles that
|
|
60
|
+
* against the application's Schema.org catalog.
|
|
54
61
|
*
|
|
55
62
|
* **Why not `z.custom<ComponentType>(…)`**: a `z.custom` predicate
|
|
56
63
|
* cannot meaningfully validate "is a React component" beyond
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ProductAvailability,
|
|
3
|
+
ProductPriceDisclosure,
|
|
4
|
+
PublicPricingAmountBasis,
|
|
5
|
+
PublicPricingRecurrenceUnit,
|
|
6
|
+
publicPricingAmountDecimal,
|
|
7
|
+
type PublicPricingCatalog,
|
|
8
|
+
type PublicPricingPlan,
|
|
9
|
+
type PublicPricingPrice,
|
|
10
|
+
} from '@wildo-ai/saas-models/public-runtime';
|
|
11
|
+
|
|
12
|
+
import type { WebsiteStructuredDataPayload } from './website-structured-data.shared.schemas';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @wildo_source:part:start saas.website.pricing-offer-structured-data facet:layer:core facet:family:website facet:audience:app-developer
|
|
16
|
+
*
|
|
17
|
+
* `buildWebsitePricingOfferStructuredData` — the `AggregateOffer` a pricing page publishes, DERIVED from
|
|
18
|
+
* the application's public pricing catalogue (#1835) instead of typed by hand beside it.
|
|
19
|
+
*
|
|
20
|
+
* ## What it publishes, and what it refuses to
|
|
21
|
+
*
|
|
22
|
+
* A search engine shows an offer's `price` as the price of the offer, so an offer is published only
|
|
23
|
+
* when that is TRUE:
|
|
24
|
+
*
|
|
25
|
+
* - the plan is on sale (`availability: AVAILABLE`) — an upcoming plan cannot be bought;
|
|
26
|
+
* - its price is `LISTED` — a starting price is a floor, and an on-request plan has no price;
|
|
27
|
+
* - its published price states the whole offer for its unit: a flat or per-unit amount, never an
|
|
28
|
+
* indicative amount of a tiered, graduated or package price.
|
|
29
|
+
*
|
|
30
|
+
* Each published offer uses the plan's DEFAULT price (the one the catalogue marks) and carries a
|
|
31
|
+
* `UnitPriceSpecification` saying what the number is FOR: its recurrence as `billingDuration` (ISO 8601
|
|
32
|
+
* `P1M` / `P1Y`) and, for a per-unit price, a `referenceQuantity` of one of the page's unit (`member`)
|
|
33
|
+
* — so "$4 per member per month" is never read as "$4".
|
|
34
|
+
*
|
|
35
|
+
* One `AggregateOffer` per (currency, amount basis, recurrence): a range is only meaningful over prices
|
|
36
|
+
* that measure the same thing, so a flat price and a per-member price, or a monthly and a yearly one, are
|
|
37
|
+
* never folded into one `lowPrice` / `highPrice`. EVERY group's `@id` is `aggregateOfferIdPrefix` suffixed with
|
|
38
|
+
* what distinguishes it (`-per-unit-1-month-usd`), so an aggregate's identity depends only on what it
|
|
39
|
+
* aggregates: adding, removing or reordering a plan in ANOTHER group never moves it. Returns `[]` when
|
|
40
|
+
* nothing may be published — a page with only on-request plans publishes no price at all, which is the
|
|
41
|
+
* true statement.
|
|
42
|
+
*/
|
|
43
|
+
export interface BuildWebsitePricingOfferStructuredDataInput {
|
|
44
|
+
readonly catalog: PublicPricingCatalog;
|
|
45
|
+
/** The `@id` of the entity the offers price — typically the page's `SoftwareApplication`. */
|
|
46
|
+
readonly itemOfferedId: string;
|
|
47
|
+
/**
|
|
48
|
+
* The prefix of every `AggregateOffer` `@id` (`https://…/pricing#plans`); each group appends what it
|
|
49
|
+
* aggregates, e.g. `#plans-flat-1-month-usd`.
|
|
50
|
+
*/
|
|
51
|
+
readonly aggregateOfferIdPrefix: string;
|
|
52
|
+
/** The URL of the page the offers are made on; each offer's URL is this plus `#plan-<key>`. */
|
|
53
|
+
readonly url: string;
|
|
54
|
+
/**
|
|
55
|
+
* The plan's name in the page's locale. Names are text a person reads, so they come from the page's
|
|
56
|
+
* label pack; a plan the callback does not name is published without one rather than with its key.
|
|
57
|
+
*/
|
|
58
|
+
readonly planName?: (planKey: string) => string | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* What ONE unit of a per-unit price is, in the page's locale (`member`, `seat`) — published as the
|
|
61
|
+
* offer's `referenceQuantity.unitText`. Required when any published offer is per-unit: omitting it
|
|
62
|
+
* would state a per-member price as a whole price, so the builder refuses instead.
|
|
63
|
+
*/
|
|
64
|
+
readonly perUnitName?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function buildWebsitePricingOfferStructuredData(input: BuildWebsitePricingOfferStructuredDataInput): readonly WebsiteStructuredDataPayload[] {
|
|
68
|
+
const published = input.catalog.plans.flatMap((plan) => {
|
|
69
|
+
const price = publishablePrice(plan);
|
|
70
|
+
return price === undefined ? [] : [{ plan, price }];
|
|
71
|
+
});
|
|
72
|
+
if (published.some(({ price }) => price.amountBasis === PublicPricingAmountBasis.PER_UNIT) && !input.perUnitName) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
'[buildWebsitePricingOfferStructuredData] a published plan is priced per unit, so the page must name the unit '
|
|
75
|
+
+ '(`perUnitName`, e.g. "member"); without it the offer would state a per-unit price as the whole price.',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const groupKeyOf = (price: PublicPricingPrice): string =>
|
|
79
|
+
`${price.currency}|${price.amountBasis}|${price.recurrence.unit}|${price.recurrence.count}`;
|
|
80
|
+
const groupKeys = [...new Set(published.map(({ price }) => groupKeyOf(price)))];
|
|
81
|
+
return groupKeys.map((groupKey) => {
|
|
82
|
+
const offers = published.filter(({ price }) => groupKeyOf(price) === groupKey);
|
|
83
|
+
const sample = offers[0]!.price;
|
|
84
|
+
const amounts = offers.map(({ price }) => publicPricingAmountDecimal(price));
|
|
85
|
+
const numeric = amounts.map(Number);
|
|
86
|
+
return {
|
|
87
|
+
'@type': 'AggregateOffer',
|
|
88
|
+
'@id': `${input.aggregateOfferIdPrefix}-${aggregateSuffix(sample)}`,
|
|
89
|
+
itemOffered: { '@id': input.itemOfferedId },
|
|
90
|
+
priceCurrency: sample.currency,
|
|
91
|
+
lowPrice: amounts[numeric.indexOf(Math.min(...numeric))],
|
|
92
|
+
highPrice: amounts[numeric.indexOf(Math.max(...numeric))],
|
|
93
|
+
offerCount: offers.length,
|
|
94
|
+
url: input.url,
|
|
95
|
+
offers: offers.map(({ plan, price }) => {
|
|
96
|
+
const name = input.planName?.(plan.key);
|
|
97
|
+
const amount = publicPricingAmountDecimal(price);
|
|
98
|
+
return {
|
|
99
|
+
'@type': 'Offer',
|
|
100
|
+
...(name === undefined ? {} : { name }),
|
|
101
|
+
price: amount,
|
|
102
|
+
priceCurrency: price.currency,
|
|
103
|
+
availability: 'https://schema.org/InStock',
|
|
104
|
+
url: `${input.url}#plan-${plan.key}`,
|
|
105
|
+
priceSpecification: {
|
|
106
|
+
'@type': 'UnitPriceSpecification',
|
|
107
|
+
price: amount,
|
|
108
|
+
priceCurrency: price.currency,
|
|
109
|
+
...(price.recurrence.unit === PublicPricingRecurrenceUnit.ONE_TIME ? {} : { billingDuration: isoDurationOf(price) }),
|
|
110
|
+
...(price.amountBasis === PublicPricingAmountBasis.PER_UNIT
|
|
111
|
+
? { referenceQuantity: { '@type': 'QuantitativeValue', value: 1, unitText: input.perUnitName } }
|
|
112
|
+
: {}),
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** What distinguishes an aggregate's `@id`: its basis, its recurrence and its currency — exactly its group key. */
|
|
121
|
+
function aggregateSuffix(price: PublicPricingPrice): string {
|
|
122
|
+
return `${price.amountBasis}-${price.recurrence.count}-${price.recurrence.unit}-${price.currency}`.toLowerCase().replace(/_/gu, '-');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The plan's default price, when publishing it as the offer's price would be true. */
|
|
126
|
+
function publishablePrice(plan: PublicPricingPlan): PublicPricingPrice | undefined {
|
|
127
|
+
if (plan.availability !== ProductAvailability.AVAILABLE) return undefined;
|
|
128
|
+
if (plan.priceDisclosure !== ProductPriceDisclosure.LISTED) return undefined;
|
|
129
|
+
const price = plan.prices.find((candidate) => candidate.isDefault) ?? plan.prices[0];
|
|
130
|
+
if (price === undefined || price.amountBasis === PublicPricingAmountBasis.VARIABLE) return undefined;
|
|
131
|
+
return price;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Total over the recurring units: a member added to the vocabulary is a compile error here until it is mapped. */
|
|
135
|
+
const ISO_DURATION_DESIGNATOR: Record<Exclude<PublicPricingRecurrenceUnit, PublicPricingRecurrenceUnit.ONE_TIME>, string> = {
|
|
136
|
+
[PublicPricingRecurrenceUnit.MONTH]: 'M',
|
|
137
|
+
[PublicPricingRecurrenceUnit.YEAR]: 'Y',
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
function isoDurationOf(price: PublicPricingPrice): string {
|
|
141
|
+
const unit = price.recurrence.unit as Exclude<PublicPricingRecurrenceUnit, PublicPricingRecurrenceUnit.ONE_TIME>;
|
|
142
|
+
return `P${price.recurrence.count}${ISO_DURATION_DESIGNATOR[unit]}`;
|
|
143
|
+
}
|
|
144
|
+
/** @wildo_source:part:end saas.website.pricing-offer-structured-data */
|
|
@@ -103,6 +103,28 @@ export type StructuredDataReconciliationFinding =
|
|
|
103
103
|
| StructuredDataDeprecatedFieldFinding
|
|
104
104
|
| StructuredDataCrossEntityReferenceDriftFinding;
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* An OPTIONAL field a payload leaves out, reported because its catalog entry opted in with
|
|
108
|
+
* `recommendOptionals: true` — the entry's statement that for this type the optional fields are
|
|
109
|
+
* where the richer search and answer-engine results come from.
|
|
110
|
+
*
|
|
111
|
+
* Deliberately NOT a finding. A finding says the payload is wrong (a missing required field, an
|
|
112
|
+
* unknown type, a deprecated field) and `--strict` escalates every finding to a failure. A payload
|
|
113
|
+
* without its optional fields is valid; reporting it through the same channel would fail strict CI for
|
|
114
|
+
* following the vocabulary. So recommendations travel beside the findings, are shown as information,
|
|
115
|
+
* and never change an audit's outcome.
|
|
116
|
+
*/
|
|
117
|
+
export interface StructuredDataOptionalFieldRecommendation {
|
|
118
|
+
pageRef: string;
|
|
119
|
+
routePath: string;
|
|
120
|
+
sectionRef: string;
|
|
121
|
+
entityType: string;
|
|
122
|
+
entityId?: string;
|
|
123
|
+
/** The entry's optional fields this payload does not carry, in catalog order. */
|
|
124
|
+
missingOptionalFields: readonly string[];
|
|
125
|
+
message: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
106
128
|
export interface StructuredDataReconciliationPageResult {
|
|
107
129
|
pageRef: string;
|
|
108
130
|
routePath: string;
|
|
@@ -112,6 +134,8 @@ export interface StructuredDataReconciliationPageResult {
|
|
|
112
134
|
|
|
113
135
|
export interface StructuredDataReconciliationResult {
|
|
114
136
|
findings: StructuredDataReconciliationFinding[];
|
|
137
|
+
/** Optional-field recommendations from entries that opted in — information, never a finding. */
|
|
138
|
+
recommendations: StructuredDataOptionalFieldRecommendation[];
|
|
115
139
|
pages: StructuredDataReconciliationPageResult[];
|
|
116
140
|
}
|
|
117
141
|
|
|
@@ -133,6 +157,7 @@ export function reconcileStructuredDataAgainstCatalog(
|
|
|
133
157
|
options: ReconcileStructuredDataOptions,
|
|
134
158
|
): StructuredDataReconciliationResult {
|
|
135
159
|
const findings: StructuredDataReconciliationFinding[] = [];
|
|
160
|
+
const recommendations: StructuredDataOptionalFieldRecommendation[] = [];
|
|
136
161
|
const pages: StructuredDataReconciliationPageResult[] = [];
|
|
137
162
|
const sectionsByRef = new Map(
|
|
138
163
|
options.sections.map((section) => [section.sectionRef, section] as const),
|
|
@@ -301,7 +326,7 @@ export function reconcileStructuredDataAgainstCatalog(
|
|
|
301
326
|
|
|
302
327
|
const routeFindingStart = findings.length;
|
|
303
328
|
for (const payloadContext of payloadContexts) {
|
|
304
|
-
validatePayloadAgainstCatalog(payloadContext, catalogByType, declaredEntityIds, findings);
|
|
329
|
+
validatePayloadAgainstCatalog(payloadContext, catalogByType, declaredEntityIds, findings, recommendations);
|
|
305
330
|
}
|
|
306
331
|
|
|
307
332
|
pages.push({
|
|
@@ -332,6 +357,7 @@ export function reconcileStructuredDataAgainstCatalog(
|
|
|
332
357
|
|
|
333
358
|
return {
|
|
334
359
|
findings,
|
|
360
|
+
recommendations,
|
|
335
361
|
pages,
|
|
336
362
|
};
|
|
337
363
|
}
|
|
@@ -411,6 +437,7 @@ function validatePayloadAgainstCatalog(
|
|
|
411
437
|
catalogByType: ReadonlyMap<string, StructuredDataCatalogEntry>,
|
|
412
438
|
declaredEntityIds: ReadonlySet<string>,
|
|
413
439
|
findings: StructuredDataReconciliationFinding[],
|
|
440
|
+
recommendations: StructuredDataOptionalFieldRecommendation[],
|
|
414
441
|
): void {
|
|
415
442
|
const { payload, pageRef, routePath, sectionRef } = payloadContext;
|
|
416
443
|
const entityType = payload['@type'];
|
|
@@ -450,6 +477,24 @@ function validatePayloadAgainstCatalog(
|
|
|
450
477
|
}
|
|
451
478
|
}
|
|
452
479
|
|
|
480
|
+
if (catalogEntry.recommendOptionals === true) {
|
|
481
|
+
const missingOptionalFields = (catalogEntry.optionalFields ?? []).filter((field) => !hasPresentFieldValue(payload, field));
|
|
482
|
+
if (missingOptionalFields.length > 0) {
|
|
483
|
+
recommendations.push({
|
|
484
|
+
pageRef,
|
|
485
|
+
routePath,
|
|
486
|
+
sectionRef,
|
|
487
|
+
entityType,
|
|
488
|
+
entityId,
|
|
489
|
+
missingOptionalFields,
|
|
490
|
+
message:
|
|
491
|
+
`Structured-data payload @type="${entityType}" emitted by section "${sectionRef}" on page "${pageRef}" ` +
|
|
492
|
+
`could carry ${missingOptionalFields.map((field) => `"${field}"`).join(', ')} — optional, and the catalog ` +
|
|
493
|
+
'recommends them for richer search and answer-engine results.',
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
453
498
|
for (const deprecatedField of catalogEntry.deprecatedFields ?? []) {
|
|
454
499
|
if (!hasPresentFieldValue(payload, deprecatedField.field)) {
|
|
455
500
|
continue;
|
|
@@ -16,11 +16,13 @@
|
|
|
16
16
|
*
|
|
17
17
|
* - `@type` REQUIRED — the Schema.org entity kind (`'Article'`,
|
|
18
18
|
* `'BlogPosting'`, `'FAQPage'`, …). Typed as a free-form `string`
|
|
19
|
-
* here (not a closed enum)
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* companion's
|
|
23
|
-
*
|
|
19
|
+
* here (not a closed enum) because this package ships to the browser
|
|
20
|
+
* and must not carry the catalog: the renderer emits whatever type a
|
|
21
|
+
* section returns. What VALIDATES the type is the Schema.org catalog,
|
|
22
|
+
* through `wildo website audit` and the companion's reconciliation —
|
|
23
|
+
* and that catalog is a closed enum an application can override but
|
|
24
|
+
* not extend, so a type outside it reports `catalog-miss` until the
|
|
25
|
+
* framework adds it.
|
|
24
26
|
* - `@id?` OPTIONAL — a stable URI identifying the entity. Used for
|
|
25
27
|
* cross-referencing on the same page (e.g. `Article.publisher`
|
|
26
28
|
* referencing the page-level `Organization` via `@id`). The
|