@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/meta.ts ADDED
@@ -0,0 +1,269 @@
1
+ // The typed metadata model and its rendering to head tags. Nothing here reaches
2
+ // for a global default: a route that does not declare a description does not get
3
+ // one, it fails the build (see validate.ts).
4
+
5
+ import { absoluteUrl, attributes, escapeXml } from './xml';
6
+
7
+ /** Search results truncate past this; validate.ts enforces it. */
8
+ export const TITLE_MAX_LENGTH = 60;
9
+ export const DESCRIPTION_MIN_LENGTH = 50;
10
+ export const DESCRIPTION_MAX_LENGTH = 160;
11
+
12
+ export interface RobotsDirectives {
13
+ index?: boolean;
14
+ follow?: boolean;
15
+ maxSnippet?: number;
16
+ maxImagePreview?: 'none' | 'standard' | 'large';
17
+ maxVideoPreview?: number;
18
+ noarchive?: boolean;
19
+ }
20
+
21
+ export interface OpenGraphImage {
22
+ url: string;
23
+ /** 1200x630 is the card size every platform crops from. */
24
+ width?: number;
25
+ height?: number;
26
+ alt?: string;
27
+ type?: string;
28
+ }
29
+
30
+ export interface OpenGraph {
31
+ type?: 'website' | 'article' | 'profile' | 'product' | 'video.other';
32
+ title?: string;
33
+ description?: string;
34
+ url?: string;
35
+ siteName?: string;
36
+ locale?: string;
37
+ image?: string | OpenGraphImage;
38
+ publishedTime?: string;
39
+ modifiedTime?: string;
40
+ author?: string;
41
+ section?: string;
42
+ tags?: readonly string[];
43
+ }
44
+
45
+ export interface TwitterCard {
46
+ card?: 'summary' | 'summary_large_image' | 'app' | 'player';
47
+ site?: string;
48
+ creator?: string;
49
+ image?: string;
50
+ imageAlt?: string;
51
+ }
52
+
53
+ export interface AlternateLocale {
54
+ /** BCP-47 tag, or `x-default`. */
55
+ hreflang: string;
56
+ href: string;
57
+ }
58
+
59
+ export interface ThemeColor {
60
+ /** Any CSS colour. Pull it from @ultimat3/ui's tokens at the call site. */
61
+ color: string;
62
+ scheme?: 'light' | 'dark';
63
+ }
64
+
65
+ export interface RouteMeta {
66
+ title?: string;
67
+ /** `'%s — Ultimate'`. Applied unless the title already contains the brand. */
68
+ titleTemplate?: string;
69
+ description?: string;
70
+ canonical?: string;
71
+ robots?: RobotsDirectives;
72
+ og?: OpenGraph;
73
+ twitter?: TwitterCard;
74
+ /** One entry per locale. `x-default` is added automatically when absent. */
75
+ alternates?: readonly AlternateLocale[];
76
+ /** The href `x-default` points at. Defaults to the canonical. */
77
+ xDefault?: string;
78
+ themeColor?: readonly ThemeColor[];
79
+ /** JSON-LD nodes from `ld.*`. Rendered as one script tag per node. */
80
+ ld?: readonly Readonly<Record<string, unknown>>[];
81
+ }
82
+
83
+ export interface HeadTag {
84
+ readonly tag: 'title' | 'meta' | 'link' | 'script';
85
+ readonly attrs: Readonly<Record<string, string>>;
86
+ /** Text content, already safe to embed for `title`; escaped on render. */
87
+ readonly text?: string;
88
+ }
89
+
90
+ export interface RenderMetaOptions {
91
+ /** Absolutises canonical, og:url, and alternates. */
92
+ baseUrl?: string;
93
+ /** Resolved path, used when `canonical` is omitted. */
94
+ path?: string;
95
+ }
96
+
97
+ export function applyTitleTemplate(title: string, template?: string): string {
98
+ if (template === undefined || template === '') return title;
99
+ return template.includes(title) ? title : template.replace('%s', title);
100
+ }
101
+
102
+ export function robotsContent(directives: RobotsDirectives): string {
103
+ const parts: string[] = [
104
+ directives.index === false ? 'noindex' : 'index',
105
+ directives.follow === false ? 'nofollow' : 'follow',
106
+ ];
107
+ if (directives.noarchive === true) parts.push('noarchive');
108
+ if (directives.maxSnippet !== undefined) parts.push(`max-snippet:${directives.maxSnippet}`);
109
+ if (directives.maxImagePreview !== undefined) {
110
+ parts.push(`max-image-preview:${directives.maxImagePreview}`);
111
+ }
112
+ if (directives.maxVideoPreview !== undefined) {
113
+ parts.push(`max-video-preview:${directives.maxVideoPreview}`);
114
+ }
115
+ return parts.join(',');
116
+ }
117
+
118
+ /**
119
+ * Full alternate set including `x-default`, which tells search engines which
120
+ * URL to serve when no declared locale matches the user. Omitting it is the most
121
+ * common i18n SEO bug, so it is added rather than merely allowed.
122
+ */
123
+ export function hreflangSet(
124
+ alternates: readonly AlternateLocale[],
125
+ fallbackHref: string | undefined,
126
+ ): readonly AlternateLocale[] {
127
+ if (alternates.length === 0) return [];
128
+ const hasDefault = alternates.some((entry) => entry.hreflang === 'x-default');
129
+ if (hasDefault) return alternates;
130
+ const href = fallbackHref ?? alternates[0]?.href;
131
+ return href === undefined ? alternates : [...alternates, { hreflang: 'x-default', href }];
132
+ }
133
+
134
+ export function renderMeta(meta: RouteMeta, options: RenderMetaOptions = {}): readonly HeadTag[] {
135
+ const tags: HeadTag[] = [];
136
+ const abs = (value: string): string =>
137
+ options.baseUrl === undefined ? value : absoluteUrl(options.baseUrl, value);
138
+
139
+ const canonical = meta.canonical ?? (options.path === undefined ? undefined : options.path);
140
+ const canonicalHref = canonical === undefined ? undefined : abs(canonical);
141
+
142
+ if (meta.title !== undefined) {
143
+ tags.push({
144
+ tag: 'title',
145
+ attrs: {},
146
+ text: applyTitleTemplate(meta.title, meta.titleTemplate),
147
+ });
148
+ }
149
+ if (meta.description !== undefined) {
150
+ tags.push({ tag: 'meta', attrs: { name: 'description', content: meta.description } });
151
+ }
152
+ if (canonicalHref !== undefined) {
153
+ tags.push({ tag: 'link', attrs: { rel: 'canonical', href: canonicalHref } });
154
+ }
155
+ tags.push({
156
+ tag: 'meta',
157
+ attrs: { name: 'robots', content: robotsContent(meta.robots ?? {}) },
158
+ });
159
+
160
+ // --- Open Graph ------------------------------------------------------------
161
+ const og = meta.og ?? {};
162
+ const ogEntries: Array<[string, string | undefined]> = [
163
+ ['og:type', og.type ?? 'website'],
164
+ ['og:title', og.title ?? meta.title],
165
+ ['og:description', og.description ?? meta.description],
166
+ ['og:url', og.url === undefined ? canonicalHref : abs(og.url)],
167
+ ['og:site_name', og.siteName],
168
+ ['og:locale', og.locale],
169
+ ];
170
+ for (const [property, content] of ogEntries) {
171
+ if (content !== undefined) tags.push({ tag: 'meta', attrs: { property, content } });
172
+ }
173
+ if (og.image !== undefined) {
174
+ const image = typeof og.image === 'string' ? { url: og.image } : og.image;
175
+ tags.push({ tag: 'meta', attrs: { property: 'og:image', content: abs(image.url) } });
176
+ if (image.width !== undefined) {
177
+ tags.push({
178
+ tag: 'meta',
179
+ attrs: { property: 'og:image:width', content: String(image.width) },
180
+ });
181
+ }
182
+ if (image.height !== undefined) {
183
+ tags.push({
184
+ tag: 'meta',
185
+ attrs: { property: 'og:image:height', content: String(image.height) },
186
+ });
187
+ }
188
+ if (image.alt !== undefined) {
189
+ tags.push({ tag: 'meta', attrs: { property: 'og:image:alt', content: image.alt } });
190
+ }
191
+ }
192
+ if (og.type === 'article') {
193
+ const articleEntries: Array<[string, string | undefined]> = [
194
+ ['article:published_time', og.publishedTime],
195
+ ['article:modified_time', og.modifiedTime],
196
+ ['article:author', og.author],
197
+ ['article:section', og.section],
198
+ ];
199
+ for (const [property, content] of articleEntries) {
200
+ if (content !== undefined) tags.push({ tag: 'meta', attrs: { property, content } });
201
+ }
202
+ for (const tag of og.tags ?? []) {
203
+ tags.push({ tag: 'meta', attrs: { property: 'article:tag', content: tag } });
204
+ }
205
+ }
206
+
207
+ // --- Twitter ---------------------------------------------------------------
208
+ const twitter = meta.twitter ?? {};
209
+ const twitterEntries: Array<[string, string | undefined]> = [
210
+ ['twitter:card', twitter.card ?? (og.image === undefined ? 'summary' : 'summary_large_image')],
211
+ ['twitter:site', twitter.site],
212
+ ['twitter:creator', twitter.creator],
213
+ ['twitter:title', og.title ?? meta.title],
214
+ ['twitter:description', og.description ?? meta.description],
215
+ ['twitter:image', twitter.image === undefined ? undefined : abs(twitter.image)],
216
+ ['twitter:image:alt', twitter.imageAlt],
217
+ ];
218
+ for (const [name, content] of twitterEntries) {
219
+ if (content !== undefined) tags.push({ tag: 'meta', attrs: { name, content } });
220
+ }
221
+
222
+ // --- hreflang --------------------------------------------------------------
223
+ for (const alternate of hreflangSet(meta.alternates ?? [], meta.xDefault ?? canonicalHref)) {
224
+ tags.push({
225
+ tag: 'link',
226
+ attrs: { rel: 'alternate', hreflang: alternate.hreflang, href: abs(alternate.href) },
227
+ });
228
+ }
229
+
230
+ // --- theme-color per colour scheme ----------------------------------------
231
+ for (const entry of meta.themeColor ?? []) {
232
+ tags.push({
233
+ tag: 'meta',
234
+ attrs:
235
+ entry.scheme === undefined
236
+ ? { name: 'theme-color', content: entry.color }
237
+ : {
238
+ name: 'theme-color',
239
+ media: `(prefers-color-scheme: ${entry.scheme})`,
240
+ content: entry.color,
241
+ },
242
+ });
243
+ }
244
+
245
+ // --- JSON-LD ---------------------------------------------------------------
246
+ for (const node of meta.ld ?? []) {
247
+ tags.push({
248
+ tag: 'script',
249
+ attrs: { type: 'application/ld+json' },
250
+ text: JSON.stringify(node),
251
+ });
252
+ }
253
+
254
+ return tags;
255
+ }
256
+
257
+ /** Serialise head tags to HTML. `<script>` content is JSON, escaped for `</`. */
258
+ export function renderHeadTags(tags: readonly HeadTag[]): string {
259
+ return tags
260
+ .map((tag) => {
261
+ if (tag.tag === 'title') return `<title>${escapeXml(tag.text ?? '')}</title>`;
262
+ if (tag.tag === 'script') {
263
+ const safe = (tag.text ?? '').replaceAll('</', '<\\/');
264
+ return `<script${attributes(tag.attrs)}>${safe}</script>`;
265
+ }
266
+ return `<${tag.tag}${attributes(tag.attrs)}>`;
267
+ })
268
+ .join('\n');
269
+ }
package/src/robots.ts ADDED
@@ -0,0 +1,79 @@
1
+ // robots.txt generation. Environment-aware, and the default is the safe one:
2
+ // anything that is not explicitly production emits `Disallow: /`, because a
3
+ // preview deploy that gets indexed outranks and cannibalises the real site.
4
+
5
+ import { absoluteUrl } from './xml';
6
+
7
+ export type SeoEnvironment = 'production' | 'preview' | 'development' | 'test';
8
+
9
+ export interface RobotsGroup {
10
+ /** One or more user agents this group applies to. */
11
+ userAgent: string | readonly string[];
12
+ allow?: readonly string[];
13
+ disallow?: readonly string[];
14
+ crawlDelay?: number;
15
+ }
16
+
17
+ export interface RobotsConfig {
18
+ baseUrl: string;
19
+ /** Omitted means "resolve from the environment", which defaults to preview. */
20
+ environment?: SeoEnvironment | undefined;
21
+ groups?: readonly RobotsGroup[];
22
+ /** Sitemap paths or absolute URLs. Only emitted in production. */
23
+ sitemaps?: readonly string[];
24
+ /** Extra lines appended verbatim, e.g. a `Host:` directive. */
25
+ extra?: readonly string[];
26
+ }
27
+
28
+ /**
29
+ * Fail-closed: only the exact string `production` opts a deploy into indexing.
30
+ * A typo, an unset variable, or a branch deploy all resolve to `preview`.
31
+ */
32
+ export function resolveEnvironment(
33
+ env: Readonly<Record<string, string | undefined>> = process.env,
34
+ ): SeoEnvironment {
35
+ const raw = env['ULTIMATE_ENV'] ?? env['NODE_ENV'];
36
+ if (raw === 'production') return 'production';
37
+ if (raw === 'test') return 'test';
38
+ if (raw === 'development') return 'development';
39
+ return 'preview';
40
+ }
41
+
42
+ export function isIndexable(environment: SeoEnvironment): boolean {
43
+ return environment === 'production';
44
+ }
45
+
46
+ function agents(userAgent: string | readonly string[]): readonly string[] {
47
+ return typeof userAgent === 'string' ? [userAgent] : userAgent;
48
+ }
49
+
50
+ export function buildRobots(config: RobotsConfig): string {
51
+ const environment = config.environment ?? resolveEnvironment();
52
+ const lines: string[] = [`# environment: ${environment}`];
53
+
54
+ if (!isIndexable(environment)) {
55
+ // No sitemap either: advertising one invites a crawl we just refused.
56
+ lines.push('User-agent: *', 'Disallow: /');
57
+ return `${lines.join('\n')}\n`;
58
+ }
59
+
60
+ const groups: readonly RobotsGroup[] =
61
+ config.groups === undefined || config.groups.length === 0
62
+ ? [{ userAgent: '*', allow: ['/'] }]
63
+ : config.groups;
64
+
65
+ for (const group of groups) {
66
+ for (const agent of agents(group.userAgent)) lines.push(`User-agent: ${agent}`);
67
+ for (const path of group.allow ?? []) lines.push(`Allow: ${path}`);
68
+ for (const path of group.disallow ?? []) lines.push(`Disallow: ${path}`);
69
+ if (group.crawlDelay !== undefined) lines.push(`Crawl-delay: ${group.crawlDelay}`);
70
+ lines.push('');
71
+ }
72
+
73
+ for (const sitemap of config.sitemaps ?? ['/sitemap.xml']) {
74
+ lines.push(`Sitemap: ${absoluteUrl(config.baseUrl, sitemap)}`);
75
+ }
76
+ for (const line of config.extra ?? []) lines.push(line);
77
+
78
+ return `${lines.join('\n').trimEnd()}\n`;
79
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,62 @@
1
+ // The route-table shape @ultimat3/seo consumes. Emitted by the framework into
2
+ // `x.manifest.json`; every checker here reports against `file`, so an agent can
3
+ // open the exact source rather than guess which route a URL came from.
4
+
5
+ import type { RouteMeta } from './meta';
6
+
7
+ export type RenderMode = 'static' | 'isr' | 'ssr' | 'stream' | 'spa';
8
+
9
+ /** `site/` is the only surface SEO applies to; `app/` is behind auth. */
10
+ export type Surface = 'site' | 'app' | 'api';
11
+
12
+ export type ChangeFreq = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
13
+
14
+ export interface RouteBudget {
15
+ /** Byte budgets accept `'40kb'` or a raw number of bytes. */
16
+ js?: string | number;
17
+ css?: string | number;
18
+ /** Milliseconds. */
19
+ lcp?: number;
20
+ /** Unitless layout-shift score. */
21
+ cls?: number;
22
+ /** Milliseconds. */
23
+ inp?: number;
24
+ }
25
+
26
+ export interface RouteRecord {
27
+ /** URL pattern, e.g. `/blog/:slug`. */
28
+ path: string;
29
+ /** Source file relative to the app root — named verbatim in every error. */
30
+ file: string;
31
+ surface: Surface;
32
+ render: RenderMode;
33
+ meta?: RouteMeta;
34
+ /** Concrete paths for a dynamic route, from `defineRoute({ prerender })`. */
35
+ prerender?: () => readonly string[] | Promise<readonly string[]>;
36
+ budget?: RouteBudget;
37
+ /** Keep out of the sitemap and emit `noindex`. */
38
+ noindex?: boolean;
39
+ lastmod?: string;
40
+ changefreq?: ChangeFreq;
41
+ /** 0.0–1.0. Omit unless the site genuinely has a priority hierarchy. */
42
+ priority?: number;
43
+ }
44
+
45
+ export function isDynamic(path: string): boolean {
46
+ return path.includes(':') || path.includes('*');
47
+ }
48
+
49
+ /** Routes that should appear in a sitemap: public, indexable, not app-only. */
50
+ export function indexableRoutes(routes: readonly RouteRecord[]): readonly RouteRecord[] {
51
+ return routes.filter(
52
+ (route) =>
53
+ route.surface === 'site' && route.noindex !== true && route.meta?.robots?.index !== false,
54
+ );
55
+ }
56
+
57
+ /** Every concrete URL a route resolves to, expanding `prerender()`. */
58
+ export async function expandRoute(route: RouteRecord): Promise<readonly string[]> {
59
+ if (!isDynamic(route.path)) return [route.path];
60
+ if (route.prerender === undefined) return [];
61
+ return await route.prerender();
62
+ }
package/src/rss.ts ADDED
@@ -0,0 +1,178 @@
1
+ // Feed generation from a route's enumerated items. All three formats come from
2
+ // one item list, because a site that ships RSS but not JSON Feed has simply
3
+ // picked a winner for its readers.
4
+
5
+ import { absoluteUrl, cdata, escapeXml, xmlElement } from './xml';
6
+
7
+ export interface FeedAuthor {
8
+ name: string;
9
+ email?: string;
10
+ url?: string;
11
+ }
12
+
13
+ export interface FeedItem {
14
+ /** Stable, permanent identifier. The canonical URL is a fine choice. */
15
+ id: string;
16
+ url: string;
17
+ title: string;
18
+ /** ISO 8601. */
19
+ published: string;
20
+ updated?: string;
21
+ summary?: string;
22
+ /** Full HTML body. Emitted as CDATA in RSS, escaped in Atom. */
23
+ contentHtml?: string;
24
+ author?: FeedAuthor;
25
+ tags?: readonly string[];
26
+ image?: string;
27
+ }
28
+
29
+ export interface FeedChannel {
30
+ title: string;
31
+ description: string;
32
+ /** Absolute site root. */
33
+ siteUrl: string;
34
+ /** Path or absolute URL of the feed itself. */
35
+ feedUrl: string;
36
+ /** BCP-47. */
37
+ language: string;
38
+ /** Defaults to the newest item's timestamp. */
39
+ updated?: string;
40
+ author?: FeedAuthor;
41
+ copyright?: string;
42
+ icon?: string;
43
+ }
44
+
45
+ export interface Feed {
46
+ readonly rss: string;
47
+ readonly atom: string;
48
+ readonly json: string;
49
+ }
50
+
51
+ function newest(items: readonly FeedItem[]): string {
52
+ const times = items.map((item) => Date.parse(item.updated ?? item.published));
53
+ const max = times.length === 0 ? Date.now() : Math.max(...times);
54
+ return new Date(max).toISOString();
55
+ }
56
+
57
+ function rfc822(iso: string): string {
58
+ return new Date(iso).toUTCString();
59
+ }
60
+
61
+ function buildRss(channel: FeedChannel, items: readonly FeedItem[], updated: string): string {
62
+ const self = absoluteUrl(channel.siteUrl, channel.feedUrl);
63
+ const entries = items
64
+ .map((item) => {
65
+ const parts = [
66
+ xmlElement('title', item.title),
67
+ xmlElement('link', item.url),
68
+ ` <guid isPermaLink="false">${escapeXml(item.id)}</guid>`,
69
+ xmlElement('pubDate', rfc822(item.published)),
70
+ ];
71
+ if (item.summary !== undefined) parts.push(xmlElement('description', item.summary));
72
+ if (item.contentHtml !== undefined) {
73
+ parts.push(` <content:encoded>${cdata(item.contentHtml)}</content:encoded>`);
74
+ }
75
+ for (const tag of item.tags ?? []) parts.push(xmlElement('category', tag));
76
+ return ` <item>\n${parts.map((line) => (line.startsWith(' ') ? line : ` ${line}`)).join('\n')}\n </item>`;
77
+ })
78
+ .join('\n');
79
+
80
+ return [
81
+ '<?xml version="1.0" encoding="UTF-8"?>',
82
+ '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">',
83
+ ' <channel>',
84
+ ` ${xmlElement('title', channel.title)}`,
85
+ ` ${xmlElement('link', channel.siteUrl)}`,
86
+ ` ${xmlElement('description', channel.description)}`,
87
+ ` ${xmlElement('language', channel.language)}`,
88
+ ` ${xmlElement('lastBuildDate', rfc822(updated))}`,
89
+ ` <atom:link href="${escapeXml(self)}" rel="self" type="application/rss+xml"/>`,
90
+ channel.copyright === undefined ? '' : ` ${xmlElement('copyright', channel.copyright)}`,
91
+ entries,
92
+ ' </channel>',
93
+ '</rss>',
94
+ '',
95
+ ]
96
+ .filter((line) => line !== '')
97
+ .join('\n');
98
+ }
99
+
100
+ function buildAtom(channel: FeedChannel, items: readonly FeedItem[], updated: string): string {
101
+ const self = absoluteUrl(channel.siteUrl, channel.feedUrl);
102
+ const entries = items
103
+ .map((item) => {
104
+ const parts = [
105
+ ` ${xmlElement('title', item.title)}`,
106
+ ` <link href="${escapeXml(item.url)}"/>`,
107
+ ` ${xmlElement('id', item.id)}`,
108
+ ` ${xmlElement('updated', item.updated ?? item.published)}`,
109
+ ` ${xmlElement('published', item.published)}`,
110
+ ];
111
+ if (item.summary !== undefined) parts.push(` ${xmlElement('summary', item.summary)}`);
112
+ if (item.contentHtml !== undefined) {
113
+ parts.push(` <content type="html">${escapeXml(item.contentHtml)}</content>`);
114
+ }
115
+ if (item.author !== undefined) {
116
+ parts.push(` <author>${xmlElement('name', item.author.name)}</author>`);
117
+ }
118
+ for (const tag of item.tags ?? []) {
119
+ parts.push(` <category term="${escapeXml(tag)}"/>`);
120
+ }
121
+ return ` <entry>\n${parts.join('\n')}\n </entry>`;
122
+ })
123
+ .join('\n');
124
+
125
+ return [
126
+ '<?xml version="1.0" encoding="UTF-8"?>',
127
+ `<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="${escapeXml(channel.language)}">`,
128
+ ` ${xmlElement('title', channel.title)}`,
129
+ ` ${xmlElement('subtitle', channel.description)}`,
130
+ ` ${xmlElement('id', channel.siteUrl)}`,
131
+ ` ${xmlElement('updated', updated)}`,
132
+ ` <link href="${escapeXml(channel.siteUrl)}"/>`,
133
+ ` <link href="${escapeXml(self)}" rel="self" type="application/atom+xml"/>`,
134
+ entries,
135
+ '</feed>',
136
+ '',
137
+ ].join('\n');
138
+ }
139
+
140
+ function buildJsonFeed(channel: FeedChannel, items: readonly FeedItem[]): string {
141
+ return `${JSON.stringify(
142
+ {
143
+ version: 'https://jsonfeed.org/version/1.1',
144
+ title: channel.title,
145
+ description: channel.description,
146
+ home_page_url: channel.siteUrl,
147
+ feed_url: absoluteUrl(channel.siteUrl, channel.feedUrl),
148
+ language: channel.language,
149
+ icon: channel.icon,
150
+ authors: channel.author === undefined ? undefined : [channel.author],
151
+ items: items.map((item) => ({
152
+ id: item.id,
153
+ url: item.url,
154
+ title: item.title,
155
+ summary: item.summary,
156
+ content_html: item.contentHtml,
157
+ image: item.image,
158
+ date_published: item.published,
159
+ date_modified: item.updated,
160
+ tags: item.tags,
161
+ authors: item.author === undefined ? undefined : [item.author],
162
+ })),
163
+ },
164
+ null,
165
+ 2,
166
+ )}\n`;
167
+ }
168
+
169
+ /** RSS 2.0, Atom, and JSON Feed 1.1 from one item list. */
170
+ export function buildFeed(channel: FeedChannel, items: readonly FeedItem[]): Feed {
171
+ const ordered = [...items].sort((a, b) => Date.parse(b.published) - Date.parse(a.published));
172
+ const updated = channel.updated ?? newest(ordered);
173
+ return {
174
+ rss: buildRss(channel, ordered, updated),
175
+ atom: buildAtom(channel, ordered, updated),
176
+ json: buildJsonFeed(channel, ordered),
177
+ };
178
+ }