@ultimat3/seo 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/images.ts ADDED
@@ -0,0 +1,235 @@
1
+ // The responsive image contract: what the markup promises. Two non-negotiables — modern
2
+ // formats are offered before the fallback, and the intrinsic width/height are always inlined
3
+ // so the browser reserves the box before the bytes arrive, keeping CLS at 0. Producing those
4
+ // bytes is `image-driver.ts`; nothing here decodes a pixel.
5
+
6
+ import { imageQueryInvalid } from './errors';
7
+ import { attributes, escapeAttribute } from './xml';
8
+
9
+ /** Ordered widest-first is wrong for `srcset`; browsers want ascending. */
10
+ export const DEFAULT_WIDTHS: readonly number[] = [320, 480, 640, 768, 1024, 1280, 1536, 1920];
11
+
12
+ /** Most-preferred first. The original format is always appended last. */
13
+ export const FORMAT_ORDER = ['avif', 'webp'] as const;
14
+
15
+ export type ModernFormat = (typeof FORMAT_ORDER)[number];
16
+
17
+ export const MIME_TYPES: Readonly<Record<string, string>> = {
18
+ avif: 'image/avif',
19
+ webp: 'image/webp',
20
+ jpg: 'image/jpeg',
21
+ jpeg: 'image/jpeg',
22
+ png: 'image/png',
23
+ gif: 'image/gif',
24
+ svg: 'image/svg+xml',
25
+ };
26
+
27
+ export interface ImageInput {
28
+ src: string;
29
+ /** Intrinsic pixel dimensions. Required — this is what prevents layout shift. */
30
+ width: number;
31
+ height: number;
32
+ /** Empty string only for decorative images. */
33
+ alt: string;
34
+ /** `sizes` attribute. Defaults to `100vw`, which is honest but conservative. */
35
+ sizes?: string;
36
+ /** LCP candidate: eager + high fetch priority + no lazy attribute. */
37
+ priority?: boolean;
38
+ /** Base64 data URI rendered behind the image while it loads. */
39
+ blurDataUrl?: string;
40
+ }
41
+
42
+ export interface ImageSourceSet {
43
+ readonly type: string;
44
+ readonly srcset: string;
45
+ readonly sizes: string;
46
+ }
47
+
48
+ export interface ResponsiveImage {
49
+ readonly sources: readonly ImageSourceSet[];
50
+ readonly img: {
51
+ readonly src: string;
52
+ readonly srcset: string;
53
+ readonly sizes: string;
54
+ readonly alt: string;
55
+ readonly width: number;
56
+ readonly height: number;
57
+ readonly loading: 'lazy' | 'eager';
58
+ readonly decoding: 'async' | 'sync';
59
+ readonly fetchpriority: 'high' | 'auto';
60
+ /** `aspect-ratio` plus the blur placeholder, when one was generated. */
61
+ readonly style: string;
62
+ };
63
+ }
64
+
65
+ export interface ResponsiveImageOptions {
66
+ widths?: readonly number[];
67
+ formats?: readonly ModernFormat[];
68
+ /** Builds the URL for one variant. Defaults to `IMAGE_QUERY_KEYS` query parameters (`?w=&f=`). */
69
+ urlFor?: (src: string, width: number, format?: string) => string;
70
+ }
71
+
72
+ export function extensionOf(src: string): string {
73
+ return (src.split('?')[0]?.split('.').pop() ?? '').toLowerCase();
74
+ }
75
+
76
+ /**
77
+ * The one spelling of the transform query keys. `defaultUrlFor` writes them and
78
+ * `parseImageQuery` reads them back — a literal `'w'` in one place and a literal `'w'` in the
79
+ * other is how a rename of one silently stops answering the other's URLs.
80
+ */
81
+ export const IMAGE_QUERY_KEYS = { width: 'w', format: 'f', quality: 'q' } as const;
82
+
83
+ function defaultUrlFor(src: string, width: number, format?: string): string {
84
+ const separator = src.includes('?') ? '&' : '?';
85
+ // Both keys read from IMAGE_QUERY_KEYS, never a literal 'w'/'f' — see the constant above.
86
+ const widthParam = `${IMAGE_QUERY_KEYS.width}=${width}`;
87
+ const formatParam = format === undefined ? '' : `&${IMAGE_QUERY_KEYS.format}=${format}`;
88
+ return `${src}${separator}${widthParam}${formatParam}`;
89
+ }
90
+
91
+ export interface ImageQuery {
92
+ readonly width?: number | undefined;
93
+ readonly format?: string | undefined;
94
+ readonly quality?: number | undefined;
95
+ }
96
+
97
+ /**
98
+ * `w` and `q` share one shape: digits only, so `/^[1-9]\d*$/` rejects an empty string, `"0"`, a
99
+ * negative sign and a fractional point in a single test instead of four checks that could each
100
+ * drift out of sync with the others.
101
+ *
102
+ * Digits alone are still not a number, which is why the range gate is here and not only in
103
+ * `parseQuality`: 400 of them parse to `Infinity`, and `Infinity > 0` passes every positive-integer
104
+ * test there is, so `?w=999…9` used to reach the driver as a width nothing can allocate.
105
+ */
106
+ function parsePositiveInt(param: string, raw: string): number {
107
+ if (!/^[1-9]\d*$/.test(raw)) throw imageQueryInvalid(param, raw, 'must be a positive integer');
108
+ const value = Number.parseInt(raw, 10);
109
+ if (!Number.isSafeInteger(value)) {
110
+ throw imageQueryInvalid(param, raw, 'is past the largest integer a pixel count can hold');
111
+ }
112
+ return value;
113
+ }
114
+
115
+ function parseQuality(raw: string): number {
116
+ const quality = parsePositiveInt(IMAGE_QUERY_KEYS.quality, raw);
117
+ if (quality > 100) throw imageQueryInvalid(IMAGE_QUERY_KEYS.quality, raw, 'must be 100 or less');
118
+ return quality;
119
+ }
120
+
121
+ /**
122
+ * Naming no *real* format is deliberately not refused here: `image-driver.ts`'s
123
+ * `requestedFormat` already owns "is this an encodable format", and throwing in two places
124
+ * would give one bad URL two different error codes depending on which module ran first. This
125
+ * only refuses the one thing that is unambiguously this module's fact — the key was present and
126
+ * empty.
127
+ */
128
+ function parseFormat(raw: string): string {
129
+ if (raw === '')
130
+ throw imageQueryInvalid(IMAGE_QUERY_KEYS.format, raw, 'must be a non-empty string');
131
+ return raw;
132
+ }
133
+
134
+ /**
135
+ * `null` means no transform was asked for — a plain asset read, not a bad request. An
136
+ * asked-for-but-unusable value throws instead, because silently serving the full-size
137
+ * original against a `?w=320` URL is the layout shift this contract exists to prevent.
138
+ */
139
+ export function parseImageQuery(params: URLSearchParams): ImageQuery | null {
140
+ const rawWidth = params.get(IMAGE_QUERY_KEYS.width);
141
+ const rawFormat = params.get(IMAGE_QUERY_KEYS.format);
142
+ const rawQuality = params.get(IMAGE_QUERY_KEYS.quality);
143
+ if (rawWidth === null && rawFormat === null && rawQuality === null) return null;
144
+
145
+ return {
146
+ ...(rawWidth === null ? {} : { width: parsePositiveInt(IMAGE_QUERY_KEYS.width, rawWidth) }),
147
+ ...(rawFormat === null ? {} : { format: parseFormat(rawFormat) }),
148
+ ...(rawQuality === null ? {} : { quality: parseQuality(rawQuality) }),
149
+ };
150
+ }
151
+
152
+ /** Never upscale: drop candidate widths above the intrinsic width. */
153
+ export function usableWidths(intrinsic: number, widths: readonly number[]): readonly number[] {
154
+ const usable = widths.filter((width) => width <= intrinsic);
155
+ if (usable.length === 0) return [intrinsic];
156
+ return usable.includes(intrinsic) ? usable : [...usable, intrinsic];
157
+ }
158
+
159
+ export function srcsetFor(
160
+ input: ImageInput,
161
+ widths: readonly number[],
162
+ format: string | undefined,
163
+ urlFor: NonNullable<ResponsiveImageOptions['urlFor']>,
164
+ ): string {
165
+ return widths.map((width) => `${urlFor(input.src, width, format)} ${width}w`).join(', ');
166
+ }
167
+
168
+ export function responsiveImage(
169
+ input: ImageInput,
170
+ options: ResponsiveImageOptions = {},
171
+ ): ResponsiveImage {
172
+ const urlFor = options.urlFor ?? defaultUrlFor;
173
+ const widths = usableWidths(input.width, options.widths ?? DEFAULT_WIDTHS);
174
+ const sizes = input.sizes ?? '100vw';
175
+ const formats = options.formats ?? FORMAT_ORDER;
176
+
177
+ const sources: ImageSourceSet[] = formats.map((format) => ({
178
+ type: MIME_TYPES[format] ?? `image/${format}`,
179
+ srcset: srcsetFor(input, widths, format, urlFor),
180
+ sizes,
181
+ }));
182
+
183
+ const style = [
184
+ `aspect-ratio:${input.width}/${input.height}`,
185
+ input.blurDataUrl === undefined
186
+ ? ''
187
+ : `background-image:url(${input.blurDataUrl});background-size:cover`,
188
+ ]
189
+ .filter((part) => part !== '')
190
+ .join(';');
191
+
192
+ return {
193
+ sources,
194
+ img: {
195
+ src: urlFor(input.src, widths[widths.length - 1] ?? input.width, undefined),
196
+ srcset: srcsetFor(input, widths, undefined, urlFor),
197
+ sizes,
198
+ alt: input.alt,
199
+ width: input.width,
200
+ height: input.height,
201
+ loading: input.priority === true ? 'eager' : 'lazy',
202
+ decoding: input.priority === true ? 'sync' : 'async',
203
+ fetchpriority: input.priority === true ? 'high' : 'auto',
204
+ style,
205
+ },
206
+ };
207
+ }
208
+
209
+ /** `<picture>` with AVIF, then WebP, then the original. */
210
+ export function renderPicture(image: ResponsiveImage): string {
211
+ const sources = image.sources
212
+ .map(
213
+ (source) =>
214
+ `<source${attributes({ type: source.type, srcset: source.srcset, sizes: source.sizes })}>`,
215
+ )
216
+ .join('');
217
+ const img = image.img;
218
+ return `<picture>${sources}<img${attributes({
219
+ src: img.src,
220
+ srcset: img.srcset,
221
+ sizes: img.sizes,
222
+ alt: img.alt,
223
+ width: String(img.width),
224
+ height: String(img.height),
225
+ loading: img.loading,
226
+ decoding: img.decoding,
227
+ fetchpriority: img.fetchpriority,
228
+ style: img.style,
229
+ })}></picture>`;
230
+ }
231
+
232
+ /** Escapes a data URI for inline `style`, for callers assembling their own tags. */
233
+ export function inlineBlur(dataUrl: string): string {
234
+ return escapeAttribute(`background-image:url(${dataUrl});background-size:cover`);
235
+ }
package/src/index.ts ADDED
@@ -0,0 +1,121 @@
1
+ // The public surface of @ultimat3/seo. Explicit named exports only.
2
+
3
+ export type { BudgetMeasurement, BudgetMetric, BudgetReport, BudgetViolation } from './budgets';
4
+ export { assertBudgets, BUDGET_UNITS, checkBudgets, DEFAULT_BUDGET, parseBytes } from './budgets';
5
+ export type { SeoErrorCode, SeoErrorInit } from './errors';
6
+ export {
7
+ budgetExceeded,
8
+ canonicalMismatch,
9
+ duplicateMeta,
10
+ imageQueryInvalid,
11
+ ldInvalid,
12
+ metaMissing,
13
+ metaTooLong,
14
+ notImplementedDriver,
15
+ SEO_ERROR_CODES,
16
+ SeoError,
17
+ sitemapTooLarge,
18
+ } from './errors';
19
+ export type {
20
+ BuiltinImageDriverOptions,
21
+ ImageTransformDriver,
22
+ TransformedImage,
23
+ TransformRequest,
24
+ } from './image-driver';
25
+ export { builtinImageDriver } from './image-driver';
26
+ export type {
27
+ ImageInput,
28
+ ImageQuery,
29
+ ImageSourceSet,
30
+ ModernFormat,
31
+ ResponsiveImage,
32
+ ResponsiveImageOptions,
33
+ } from './images';
34
+ export {
35
+ DEFAULT_WIDTHS,
36
+ extensionOf,
37
+ FORMAT_ORDER,
38
+ IMAGE_QUERY_KEYS,
39
+ inlineBlur,
40
+ MIME_TYPES,
41
+ parseImageQuery,
42
+ renderPicture,
43
+ responsiveImage,
44
+ srcsetFor,
45
+ usableWidths,
46
+ } from './images';
47
+ export type {
48
+ ArticleAuthor,
49
+ ArticleAuthorOrganization,
50
+ ArticleAuthorPerson,
51
+ ArticleInput,
52
+ BreadcrumbInput,
53
+ EventInput,
54
+ FaqInput,
55
+ JsonLd,
56
+ OfferInput,
57
+ OrganizationInput,
58
+ PersonInput,
59
+ ProductInput,
60
+ SoftwareApplicationInput,
61
+ WebSiteInput,
62
+ } from './ld';
63
+ export {
64
+ Article,
65
+ BreadcrumbList,
66
+ Event,
67
+ FAQPage,
68
+ LD_CONTEXT,
69
+ ld,
70
+ Organization,
71
+ Person,
72
+ Product,
73
+ renderLd,
74
+ SoftwareApplication,
75
+ WebSite,
76
+ } from './ld';
77
+ export type {
78
+ AlternateLocale,
79
+ HeadTag,
80
+ OpenGraph,
81
+ OpenGraphImage,
82
+ RenderMetaOptions,
83
+ RobotsDirectives,
84
+ RouteMeta,
85
+ ThemeColor,
86
+ TwitterCard,
87
+ } from './meta';
88
+ export {
89
+ applyTitleTemplate,
90
+ DESCRIPTION_MAX_LENGTH,
91
+ DESCRIPTION_MIN_LENGTH,
92
+ hreflangSet,
93
+ renderHeadTags,
94
+ renderMeta,
95
+ robotsContent,
96
+ TITLE_MAX_LENGTH,
97
+ } from './meta';
98
+ export type { RobotsConfig, RobotsGroup, SeoEnvironment } from './robots';
99
+ export { buildRobots, isIndexable, resolveEnvironment } from './robots';
100
+ export type { ChangeFreq, RenderMode, RouteBudget, RouteRecord, Surface } from './routes';
101
+ export { expandRoute, indexableRoutes, isDynamic } from './routes';
102
+ export type { Feed, FeedAuthor, FeedChannel, FeedItem } from './rss';
103
+ export { buildFeed } from './rss';
104
+ export type {
105
+ BuildSitemapOptions,
106
+ SitemapAlternate,
107
+ SitemapFile,
108
+ SitemapResult,
109
+ SitemapUrl,
110
+ } from './sitemap';
111
+ export {
112
+ buildSitemap,
113
+ chunk,
114
+ SITEMAP_INDEX_MAX_FILES,
115
+ SITEMAP_MAX_URLS,
116
+ sitemapUrls,
117
+ } from './sitemap';
118
+ export type { MetaIssue, MetaValidationReport, ValidateMetaOptions } from './validate';
119
+ export { assertMeta, validateMeta } from './validate';
120
+
121
+ export { absoluteUrl, escapeAttribute, escapeXml } from './xml';
package/src/ld.ts ADDED
@@ -0,0 +1,325 @@
1
+ // Typed JSON-LD builders. Required schema.org fields are required in the input
2
+ // type, so a missing `datePublished` is a compile error rather than a Search
3
+ // Console warning three weeks later. Runtime checks catch empty strings that the
4
+ // type system cannot (a value read from a CMS).
5
+
6
+ import { ldInvalid } from './errors';
7
+ import type { HeadTag } from './meta';
8
+
9
+ export type JsonLd = Readonly<Record<string, unknown>>;
10
+
11
+ export const LD_CONTEXT = 'https://schema.org';
12
+
13
+ function required(type: string, field: string, value: string, hint: string): string {
14
+ if (value.trim() === '') throw ldInvalid(type, field, hint);
15
+ return value;
16
+ }
17
+
18
+ function node(type: string, body: Readonly<Record<string, unknown>>): JsonLd {
19
+ const out: Record<string, unknown> = { '@context': LD_CONTEXT, '@type': type };
20
+ for (const [key, value] of Object.entries(body)) {
21
+ if (value !== undefined && value !== null) out[key] = value;
22
+ }
23
+ return out;
24
+ }
25
+
26
+ // --- inputs ------------------------------------------------------------------
27
+
28
+ export interface PersonInput {
29
+ name: string;
30
+ url?: string;
31
+ image?: string;
32
+ jobTitle?: string;
33
+ sameAs?: readonly string[];
34
+ }
35
+
36
+ export interface OrganizationInput {
37
+ name: string;
38
+ url: string;
39
+ logo?: string;
40
+ sameAs?: readonly string[];
41
+ description?: string;
42
+ }
43
+
44
+ /** Discriminated so an Organization author is never mis-rendered as a Person. */
45
+ export interface ArticleAuthorPerson extends PersonInput {
46
+ type?: 'Person';
47
+ }
48
+
49
+ export interface ArticleAuthorOrganization extends OrganizationInput {
50
+ type: 'Organization';
51
+ }
52
+
53
+ export type ArticleAuthor = ArticleAuthorPerson | ArticleAuthorOrganization;
54
+
55
+ export interface ArticleInput {
56
+ headline: string;
57
+ /** ISO 8601. Required by schema.org for Article rich results. */
58
+ datePublished: string;
59
+ author: ArticleAuthor;
60
+ dateModified?: string;
61
+ description?: string;
62
+ image?: string | readonly string[];
63
+ url?: string;
64
+ publisher?: OrganizationInput;
65
+ articleSection?: string;
66
+ keywords?: readonly string[];
67
+ }
68
+
69
+ export interface OfferInput {
70
+ /** Decimal string, never a float, e.g. `'19.99'`. */
71
+ price: string;
72
+ priceCurrency: string;
73
+ availability?: 'InStock' | 'OutOfStock' | 'PreOrder' | 'BackOrder';
74
+ url?: string;
75
+ priceValidUntil?: string;
76
+ }
77
+
78
+ export interface ProductInput {
79
+ name: string;
80
+ offers: OfferInput;
81
+ image?: string | readonly string[];
82
+ description?: string;
83
+ sku?: string;
84
+ brand?: string;
85
+ aggregateRating?: { ratingValue: string; reviewCount: number };
86
+ }
87
+
88
+ export interface WebSiteInput {
89
+ name: string;
90
+ url: string;
91
+ /** Enables the sitelinks search box. `{search_term_string}` is substituted. */
92
+ searchUrlTemplate?: string;
93
+ inLanguage?: string;
94
+ publisher?: OrganizationInput;
95
+ }
96
+
97
+ export interface BreadcrumbInput {
98
+ items: ReadonlyArray<{ name: string; url: string }>;
99
+ }
100
+
101
+ export interface FaqInput {
102
+ questions: ReadonlyArray<{ question: string; answer: string }>;
103
+ }
104
+
105
+ export interface EventInput {
106
+ name: string;
107
+ /** ISO 8601. Required. */
108
+ startDate: string;
109
+ /** A venue name or a URL for an online event. Required. */
110
+ location: string;
111
+ endDate?: string;
112
+ description?: string;
113
+ url?: string;
114
+ eventAttendanceMode?: 'Offline' | 'Online' | 'Mixed';
115
+ offers?: OfferInput;
116
+ }
117
+
118
+ export interface SoftwareApplicationInput {
119
+ name: string;
120
+ /** e.g. `DeveloperApplication`. Required for the app rich result. */
121
+ applicationCategory: string;
122
+ operatingSystem: string;
123
+ offers?: OfferInput;
124
+ aggregateRating?: { ratingValue: string; reviewCount: number };
125
+ url?: string;
126
+ }
127
+
128
+ // --- builders ----------------------------------------------------------------
129
+
130
+ function personOrOrg(input: ArticleAuthor): JsonLd {
131
+ return input.type === 'Organization' ? Organization(input) : Person(input);
132
+ }
133
+
134
+ function offer(input: OfferInput): JsonLd {
135
+ required('Offer', 'price', input.price, 'a decimal string such as "19.99"');
136
+ required('Offer', 'priceCurrency', input.priceCurrency, 'an ISO-4217 code such as "USD"');
137
+ return node('Offer', {
138
+ price: input.price,
139
+ priceCurrency: input.priceCurrency,
140
+ availability:
141
+ input.availability === undefined ? undefined : `https://schema.org/${input.availability}`,
142
+ url: input.url,
143
+ priceValidUntil: input.priceValidUntil,
144
+ });
145
+ }
146
+
147
+ export function Person(input: PersonInput): JsonLd {
148
+ required('Person', 'name', input.name, 'the person as they should be credited');
149
+ return node('Person', {
150
+ name: input.name,
151
+ url: input.url,
152
+ image: input.image,
153
+ jobTitle: input.jobTitle,
154
+ sameAs: input.sameAs,
155
+ });
156
+ }
157
+
158
+ export function Organization(input: OrganizationInput): JsonLd {
159
+ required('Organization', 'name', input.name, 'the legal or trading name');
160
+ required('Organization', 'url', input.url, 'the canonical homepage URL');
161
+ return node('Organization', {
162
+ name: input.name,
163
+ url: input.url,
164
+ logo: input.logo,
165
+ sameAs: input.sameAs,
166
+ description: input.description,
167
+ });
168
+ }
169
+
170
+ export function Article(input: ArticleInput): JsonLd {
171
+ required('Article', 'headline', input.headline, 'the visible article headline');
172
+ required('Article', 'datePublished', input.datePublished, 'an ISO 8601 date');
173
+ return node('Article', {
174
+ headline: input.headline,
175
+ datePublished: input.datePublished,
176
+ dateModified: input.dateModified ?? input.datePublished,
177
+ author: personOrOrg(input.author),
178
+ description: input.description,
179
+ image: input.image,
180
+ url: input.url,
181
+ publisher: input.publisher === undefined ? undefined : Organization(input.publisher),
182
+ articleSection: input.articleSection,
183
+ keywords: input.keywords,
184
+ });
185
+ }
186
+
187
+ export function Product(input: ProductInput): JsonLd {
188
+ required('Product', 'name', input.name, 'the product name shown on the page');
189
+ return node('Product', {
190
+ name: input.name,
191
+ offers: offer(input.offers),
192
+ image: input.image,
193
+ description: input.description,
194
+ sku: input.sku,
195
+ brand: input.brand === undefined ? undefined : { '@type': 'Brand', name: input.brand },
196
+ aggregateRating:
197
+ input.aggregateRating === undefined
198
+ ? undefined
199
+ : { '@type': 'AggregateRating', ...input.aggregateRating },
200
+ });
201
+ }
202
+
203
+ export function WebSite(input: WebSiteInput): JsonLd {
204
+ required('WebSite', 'name', input.name, 'the site name');
205
+ required('WebSite', 'url', input.url, 'the canonical homepage URL');
206
+ return node('WebSite', {
207
+ name: input.name,
208
+ url: input.url,
209
+ inLanguage: input.inLanguage,
210
+ publisher: input.publisher === undefined ? undefined : Organization(input.publisher),
211
+ potentialAction:
212
+ input.searchUrlTemplate === undefined
213
+ ? undefined
214
+ : {
215
+ '@type': 'SearchAction',
216
+ target: {
217
+ '@type': 'EntryPoint',
218
+ urlTemplate: input.searchUrlTemplate,
219
+ },
220
+ 'query-input': 'required name=search_term_string',
221
+ },
222
+ });
223
+ }
224
+
225
+ export function BreadcrumbList(input: BreadcrumbInput): JsonLd {
226
+ if (input.items.length === 0) {
227
+ throw ldInvalid('BreadcrumbList', 'items', 'at least one crumb');
228
+ }
229
+ return node('BreadcrumbList', {
230
+ itemListElement: input.items.map((item, index) => ({
231
+ '@type': 'ListItem',
232
+ position: index + 1,
233
+ name: required('BreadcrumbList', 'items[].name', item.name, 'the crumb label'),
234
+ item: required('BreadcrumbList', 'items[].url', item.url, 'an absolute URL'),
235
+ })),
236
+ });
237
+ }
238
+
239
+ export function FAQPage(input: FaqInput): JsonLd {
240
+ if (input.questions.length === 0) {
241
+ throw ldInvalid('FAQPage', 'questions', 'at least one question/answer pair');
242
+ }
243
+ return node('FAQPage', {
244
+ mainEntity: input.questions.map((entry) => ({
245
+ '@type': 'Question',
246
+ name: required('FAQPage', 'questions[].question', entry.question, 'the question text'),
247
+ acceptedAnswer: {
248
+ '@type': 'Answer',
249
+ text: required('FAQPage', 'questions[].answer', entry.answer, 'the answer text'),
250
+ },
251
+ })),
252
+ });
253
+ }
254
+
255
+ export function Event(input: EventInput): JsonLd {
256
+ required('Event', 'name', input.name, 'the event name');
257
+ required('Event', 'startDate', input.startDate, 'an ISO 8601 date-time');
258
+ required('Event', 'location', input.location, 'a venue name or an online URL');
259
+ const online = /^https?:\/\//.test(input.location);
260
+ return node('Event', {
261
+ name: input.name,
262
+ startDate: input.startDate,
263
+ endDate: input.endDate,
264
+ description: input.description,
265
+ url: input.url,
266
+ eventAttendanceMode: `https://schema.org/${input.eventAttendanceMode ?? (online ? 'Online' : 'Offline')}EventAttendanceMode`,
267
+ location: online
268
+ ? { '@type': 'VirtualLocation', url: input.location }
269
+ : { '@type': 'Place', name: input.location },
270
+ offers: input.offers === undefined ? undefined : offer(input.offers),
271
+ });
272
+ }
273
+
274
+ export function SoftwareApplication(input: SoftwareApplicationInput): JsonLd {
275
+ required('SoftwareApplication', 'name', input.name, 'the application name');
276
+ required(
277
+ 'SoftwareApplication',
278
+ 'applicationCategory',
279
+ input.applicationCategory,
280
+ 'a schema.org application category',
281
+ );
282
+ required(
283
+ 'SoftwareApplication',
284
+ 'operatingSystem',
285
+ input.operatingSystem,
286
+ 'the supported OS list, e.g. "Linux, macOS"',
287
+ );
288
+ return node('SoftwareApplication', {
289
+ name: input.name,
290
+ applicationCategory: input.applicationCategory,
291
+ operatingSystem: input.operatingSystem,
292
+ url: input.url,
293
+ offers: input.offers === undefined ? undefined : offer(input.offers),
294
+ aggregateRating:
295
+ input.aggregateRating === undefined
296
+ ? undefined
297
+ : { '@type': 'AggregateRating', ...input.aggregateRating },
298
+ });
299
+ }
300
+
301
+ /** The namespace routes use: `meta: () => ({ ld: [ld.Article(post)] })`. */
302
+ export const ld = {
303
+ Article,
304
+ BreadcrumbList,
305
+ Event,
306
+ FAQPage,
307
+ Organization,
308
+ Person,
309
+ Product,
310
+ SoftwareApplication,
311
+ WebSite,
312
+ } as const;
313
+
314
+ /** One `<script type="application/ld+json">` holding a @graph of every node. */
315
+ export function renderLd(nodes: readonly JsonLd[]): HeadTag {
316
+ const payload =
317
+ nodes.length === 1
318
+ ? nodes[0]
319
+ : { '@context': LD_CONTEXT, '@graph': nodes.map(({ '@context': _drop, ...rest }) => rest) };
320
+ return {
321
+ tag: 'script',
322
+ attrs: { type: 'application/ld+json' },
323
+ text: JSON.stringify(payload),
324
+ };
325
+ }