@jxsuite/feed 0.4.1 → 0.4.2

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/feed.js CHANGED
@@ -1,3 +1,30 @@
1
+ // ../../packages/schema/src/guards.ts
2
+ function isString(value) {
3
+ return typeof value === "string";
4
+ }
5
+ function isNonNullObject(value) {
6
+ return typeof value === "object" && value !== null;
7
+ }
8
+ function isJsonObject(value) {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+ var SCHEMA_KEYWORDS = new Set([
12
+ "type",
13
+ "enum",
14
+ "minimum",
15
+ "maximum",
16
+ "minLength",
17
+ "maxLength",
18
+ "pattern",
19
+ "format",
20
+ "items",
21
+ "properties",
22
+ "required",
23
+ "description",
24
+ "title",
25
+ "$comment"
26
+ ]);
27
+
1
28
  // ../../packages/schema/src/locale.ts
2
29
  var RTL_SCRIPTS = new Set([
3
30
  "Adlm",
@@ -41,7 +68,7 @@ var RTL_SCRIPTS = new Set([
41
68
  "Yezi"
42
69
  ]);
43
70
  function canonicalizeLocale(tag) {
44
- if (typeof tag !== "string" || tag.trim() === "") {
71
+ if (!isString(tag) || tag.trim() === "") {
45
72
  return null;
46
73
  }
47
74
  try {
@@ -54,7 +81,7 @@ var DEFAULT_ROUTING = "prefix-except-default";
54
81
  function resolveI18n(projectConfig) {
55
82
  const raw = projectConfig.i18n;
56
83
  const errors = [];
57
- if (!raw || typeof raw !== "object") {
84
+ if (!isNonNullObject(raw)) {
58
85
  return { errors, i18n: null };
59
86
  }
60
87
  const declared = Array.isArray(raw.locales) ? raw.locales : [];
@@ -111,11 +138,11 @@ var DEFAULTS = {
111
138
  };
112
139
  function normalizeFeedConfig(section, siteLanguage) {
113
140
  const out = {};
114
- if (!section || typeof section !== "object") {
141
+ if (!section || !isNonNullObject(section)) {
115
142
  return out;
116
143
  }
117
144
  for (const [key, raw] of Object.entries(section)) {
118
- if (!raw || typeof raw !== "object") {
145
+ if (!raw || !isNonNullObject(raw)) {
119
146
  continue;
120
147
  }
121
148
  const cfg = raw;
@@ -146,7 +173,7 @@ function entryUrl(siteUrl, basePath, id, trailingSlash) {
146
173
  return new URL(path, siteUrl).href;
147
174
  }
148
175
  function readDate(value) {
149
- if (typeof value !== "string" || value === "") {
176
+ if (!isString(value) || value === "") {
150
177
  return null;
151
178
  }
152
179
  if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
@@ -155,18 +182,19 @@ function readDate(value) {
155
182
  return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/.test(value) ? value : null;
156
183
  }
157
184
  function entryToItem(entry, feed, siteUrl, trailingSlash) {
158
- const data = entry.data ?? {};
159
- const meta = entry._meta ?? {};
160
- const url = typeof meta.url === "string" && meta.url.startsWith("/") ? new URL(meta.url, siteUrl).href : entryUrl(siteUrl, feed.basePath, entry.id, trailingSlash);
161
- const published = readDate(data[feed.dateField]) ?? readDate(meta.mtime);
162
- const { author } = data;
185
+ const { _meta: meta, data } = entry;
186
+ const metaUrl = meta?.url;
187
+ const url = metaUrl !== undefined && metaUrl.startsWith("/") ? new URL(metaUrl, siteUrl).href : entryUrl(siteUrl, feed.basePath, entry.id, trailingSlash);
188
+ const published = readDate(data[feed.dateField]) ?? readDate(meta?.mtime);
189
+ const { author, description, title } = data;
190
+ const entryAuthorName = isString(author) ? author : isNonNullObject(author) && ("name" in author) && isString(author.name) ? author.name : undefined;
163
191
  return {
164
- authorName: typeof author === "string" ? author : author?.name ?? feed.author?.name ?? null,
192
+ authorName: entryAuthorName ?? feed.author?.name ?? null,
165
193
  contentHtml: feed.contentMode === "full" ? String(entry.body ?? "") : null,
166
194
  id: url,
167
195
  published,
168
- summary: typeof data.description === "string" ? data.description : typeof meta.excerpt === "string" ? meta.excerpt : "",
169
- title: typeof data.title === "string" ? data.title : entry.id,
196
+ summary: isString(description) ? description : meta?.excerpt ?? "",
197
+ title: isString(title) ? title : entry.id,
170
198
  updated: readDate(data[feed.updatedField]) ?? published,
171
199
  url
172
200
  };
@@ -296,7 +324,7 @@ function renderJsonFeed(page) {
296
324
  next_url: page.nextUrl,
297
325
  title: feed.title === "" ? "Feed" : feed.title,
298
326
  version: VERSION,
299
- ...feed.author?.name === undefined ? {} : { authors: [{ name: feed.author.name }] },
327
+ authors: feed.author?.name === undefined ? undefined : [{ name: feed.author.name }],
300
328
  items: items.map((item) => {
301
329
  const out = { id: item.id, title: item.title, url: item.url };
302
330
  if (item.contentHtml !== null) {
@@ -330,13 +358,14 @@ function jsonFeedUrl(siteUrl, output, archiveIndex) {
330
358
  var MEDIA_TYPES = { atom: "application/atom+xml", json: "application/feed+json" };
331
359
  function siteUrlOf(projectConfig) {
332
360
  const url = projectConfig?.url;
333
- return typeof url === "string" && url !== "" ? url : null;
361
+ return isString(url) && url !== "" ? url : null;
334
362
  }
335
363
  function feedLocales(projectConfig, collection) {
336
364
  const { i18n } = resolveI18n(projectConfig ?? {});
337
365
  const content = projectConfig?.content;
338
- const source = content?.[collection]?.source;
339
- const localized = typeof source === "string" && source.includes("{locale}");
366
+ const contentType = isJsonObject(content) ? content[collection] : undefined;
367
+ const source = isJsonObject(contentType) ? contentType.source : undefined;
368
+ const localized = isString(source) && source.includes("{locale}");
340
369
  return { i18n, locales: i18n !== null && localized ? i18n.locales : [] };
341
370
  }
342
371
  function localeVariant(feed, locale, i18n) {
@@ -365,16 +394,14 @@ var Feed = {
365
394
  for (const variant of published) {
366
395
  for (const format of variant.formats) {
367
396
  const href = format === "atom" ? atomUrl(siteUrl, variant.output) : jsonFeedUrl(siteUrl, variant.output);
368
- entries.push({
369
- attributes: {
370
- href,
371
- ...variant.language !== null && locales.length > 0 ? { hreflang: variant.language } : {},
372
- rel: "alternate",
373
- title: variant.title === "" ? "Feed" : variant.title,
374
- type: MEDIA_TYPES[format]
375
- },
376
- tagName: "link"
377
- });
397
+ const attributes = { href };
398
+ if (variant.language !== null && locales.length > 0) {
399
+ attributes.hreflang = variant.language;
400
+ }
401
+ attributes.rel = "alternate";
402
+ attributes.title = variant.title === "" ? "Feed" : variant.title;
403
+ attributes.type = MEDIA_TYPES[format];
404
+ entries.push({ attributes, tagName: "link" });
378
405
  }
379
406
  }
380
407
  }
@@ -419,32 +446,32 @@ function renderFeed(feed, entries, siteUrl, trailingSlash) {
419
446
  for (const format of feed.formats) {
420
447
  const url = format === "atom" ? atomUrl : jsonFeedUrl;
421
448
  if (format === "atom") {
422
- files.push({
423
- content: renderAtom({
424
- complete,
449
+ const subscription = {
450
+ complete,
451
+ feed,
452
+ items: current,
453
+ selfUrl: url(siteUrl, feed.output),
454
+ siteUrl,
455
+ updated: feedUpdated(items)
456
+ };
457
+ if (archives.length > 0) {
458
+ subscription.prevArchiveUrl = url(siteUrl, feed.output, archives.length - 1);
459
+ }
460
+ files.push({ content: renderAtom(subscription), path: feedPath(feed.output, "xml") });
461
+ for (const [i, page] of archives.entries()) {
462
+ const archive = {
463
+ currentUrl: url(siteUrl, feed.output),
425
464
  feed,
426
- items: current,
427
- selfUrl: url(siteUrl, feed.output),
465
+ items: page,
466
+ selfUrl: url(siteUrl, feed.output, i),
428
467
  siteUrl,
429
- updated: feedUpdated(items),
430
- ...archives.length > 0 ? { prevArchiveUrl: url(siteUrl, feed.output, archives.length - 1) } : {}
431
- }),
432
- path: feedPath(feed.output, "xml")
433
- });
434
- for (const [i, page] of archives.entries()) {
435
- files.push({
436
- content: renderAtom({
437
- currentUrl: url(siteUrl, feed.output),
438
- feed,
439
- items: page,
440
- selfUrl: url(siteUrl, feed.output, i),
441
- siteUrl,
442
- updated: feedUpdated(page),
443
- ...i > 0 ? { prevArchiveUrl: url(siteUrl, feed.output, i - 1) } : {},
444
- nextArchiveUrl: i < archives.length - 1 ? url(siteUrl, feed.output, i + 1) : url(siteUrl, feed.output)
445
- }),
446
- path: feedPath(feed.output, "xml", i)
447
- });
468
+ updated: feedUpdated(page)
469
+ };
470
+ if (i > 0) {
471
+ archive.prevArchiveUrl = url(siteUrl, feed.output, i - 1);
472
+ }
473
+ archive.nextArchiveUrl = i < archives.length - 1 ? url(siteUrl, feed.output, i + 1) : url(siteUrl, feed.output);
474
+ files.push({ content: renderAtom(archive), path: feedPath(feed.output, "xml", i) });
448
475
  }
449
476
  } else {
450
477
  files.push({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/feed",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Syndication feeds for Jx: Atom (RFC 4287) and JSON Feed emitted from content collections at build time",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,7 +35,7 @@
35
35
  "upgrade": "bun update --latest && bun install"
36
36
  },
37
37
  "dependencies": {
38
- "@jxsuite/schema": "^3.0.0"
38
+ "@jxsuite/schema": "^4.0.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "ajv": "^8.20.0"
package/src/feed.ts CHANGED
@@ -9,10 +9,18 @@
9
9
  * @docs framework/site/feeds
10
10
  */
11
11
 
12
+ import type { EmitHostContext } from "@jxsuite/schema/format-registry";
13
+ import { isJsonObject, isString } from "@jxsuite/schema/guards";
12
14
  import { canonicalizeLocale, localeUrlPrefix, resolveI18n } from "@jxsuite/schema/locale";
13
15
  import type { ResolvedI18n } from "@jxsuite/schema/locale";
14
- import type { ContentLoaderEntry, JxHeadEntry, ProjectConfig } from "@jxsuite/schema/types";
16
+ import type {
17
+ ContentLoaderEntry,
18
+ JsonValue,
19
+ JxHeadEntry,
20
+ ProjectConfig,
21
+ } from "@jxsuite/schema/types";
15
22
  import { atomUrl, renderAtom } from "./atom.ts";
23
+ import type { AtomPage } from "./atom.ts";
16
24
  import { jsonFeedUrl, renderJsonFeed } from "./json-feed.ts";
17
25
  import {
18
26
  entryToItem,
@@ -24,13 +32,6 @@ import {
24
32
  } from "./shared.ts";
25
33
  import type { NormalizedFeed, NormalizedFeedConfig } from "./shared.ts";
26
34
 
27
- interface EmitContext {
28
- projectConfig?: ProjectConfig;
29
- root?: string;
30
- sections?: Record<string, unknown>;
31
- routes?: unknown[];
32
- }
33
-
34
35
  interface HeadContext {
35
36
  projectConfig?: ProjectConfig;
36
37
  root?: string;
@@ -40,7 +41,13 @@ const MEDIA_TYPES = { atom: "application/atom+xml", json: "application/feed+json
40
41
 
41
42
  function siteUrlOf(projectConfig: ProjectConfig | undefined): string | null {
42
43
  const url = projectConfig?.url;
43
- return typeof url === "string" && url !== "" ? url : null;
44
+ return isString(url) && url !== "" ? url : null;
45
+ }
46
+
47
+ /** The project's resolved i18n, and the locales one feed is published in. */
48
+ interface FeedLocales {
49
+ i18n: ResolvedI18n | null;
50
+ locales: string[];
44
51
  }
45
52
 
46
53
  /**
@@ -57,17 +64,14 @@ function siteUrlOf(projectConfig: ProjectConfig | undefined): string | null {
57
64
  *
58
65
  * @param {ProjectConfig | undefined} projectConfig
59
66
  * @param {string} collection
60
- * @returns {{ i18n: ResolvedI18n | null; locales: string[] }}
67
+ * @returns {FeedLocales}
61
68
  */
62
- function feedLocales(
63
- projectConfig: ProjectConfig | undefined,
64
- collection: string,
65
- ): { i18n: ResolvedI18n | null; locales: string[] } {
69
+ function feedLocales(projectConfig: ProjectConfig | undefined, collection: string): FeedLocales {
66
70
  const { i18n } = resolveI18n(projectConfig ?? {});
67
- const content = (projectConfig as { content?: Record<string, { source?: unknown }> } | undefined)
68
- ?.content;
69
- const source = content?.[collection]?.source;
70
- const localized = typeof source === "string" && source.includes("{locale}");
71
+ const content = projectConfig?.content;
72
+ const contentType = isJsonObject(content) ? content[collection] : undefined;
73
+ const source = isJsonObject(contentType) ? contentType.source : undefined;
74
+ const localized = isString(source) && source.includes("{locale}");
71
75
  return { i18n, locales: i18n !== null && localized ? i18n.locales : [] };
72
76
  }
73
77
 
@@ -84,7 +88,7 @@ function localeVariant(feed: NormalizedFeed, locale: string, i18n: ResolvedI18n
84
88
 
85
89
  export const Feed = {
86
90
  /** Normalize the `feed` section into `_project.feed`. */
87
- projectData(sectionValue: unknown, ctx?: HeadContext): NormalizedFeedConfig {
91
+ projectData(sectionValue: JsonValue | undefined, ctx?: HeadContext): NormalizedFeedConfig {
88
92
  return normalizeFeedConfig(sectionValue, ctx?.projectConfig?.defaults?.lang);
89
93
  },
90
94
 
@@ -94,7 +98,7 @@ export const Feed = {
94
98
  * Pure configuration, which is exactly why this can run before content loads — a feed's URL comes
95
99
  * from its `output`, not from its entries.
96
100
  */
97
- head(sectionValue: unknown, ctx: HeadContext): JxHeadEntry[] {
101
+ head(sectionValue: JsonValue | undefined, ctx: HeadContext): JxHeadEntry[] {
98
102
  const siteUrl = siteUrlOf(ctx.projectConfig);
99
103
  if (siteUrl === null) {
100
104
  return [];
@@ -116,18 +120,15 @@ export const Feed = {
116
120
  format === "atom"
117
121
  ? atomUrl(siteUrl, variant.output)
118
122
  : jsonFeedUrl(siteUrl, variant.output);
119
- entries.push({
120
- attributes: {
121
- href,
122
- ...(variant.language !== null && locales.length > 0
123
- ? { hreflang: variant.language }
124
- : {}),
125
- rel: "alternate",
126
- title: variant.title === "" ? "Feed" : variant.title,
127
- type: MEDIA_TYPES[format],
128
- },
129
- tagName: "link",
130
- });
123
+ // Attribute order is emitted order: href, then hreflang when localized, then the rest.
124
+ const attributes: NonNullable<JxHeadEntry["attributes"]> = { href };
125
+ if (variant.language !== null && locales.length > 0) {
126
+ attributes.hreflang = variant.language;
127
+ }
128
+ attributes.rel = "alternate";
129
+ attributes.title = variant.title === "" ? "Feed" : variant.title;
130
+ attributes.type = MEDIA_TYPES[format];
131
+ entries.push({ attributes, tagName: "link" });
131
132
  }
132
133
  }
133
134
  }
@@ -135,13 +136,19 @@ export const Feed = {
135
136
  },
136
137
 
137
138
  /** Serialize every configured feed, plus its RFC 5005 archives when enabled. */
138
- emit(sectionValue: unknown, ctx: EmitContext): { path: string; content: string }[] {
139
+ emit(
140
+ sectionValue: JsonValue | undefined,
141
+ ctx: Partial<Pick<EmitHostContext, "projectConfig" | "sections">>,
142
+ ): { path: string; content: string }[] {
139
143
  const siteUrl = siteUrlOf(ctx.projectConfig);
140
144
  if (siteUrl === null) {
141
145
  console.warn("@jxsuite/feed: `url` is not set in project.json — feeds need absolute links.");
142
146
  return [];
143
147
  }
144
148
  const config = normalizeFeedConfig(sectionValue, ctx.projectConfig?.defaults?.lang);
149
+ /* SAFETY: the `content` section's owner (the parser's `Content.projectData`) answers a
150
+ Map of collection name to loaded entries (specs/extensions.md §8.4), and the host forwards
151
+ each section's answer unchanged. */
145
152
  const content = ctx.sections?.content as Map<string, ContentLoaderEntry[]> | undefined;
146
153
  const trailingSlash = String(ctx.projectConfig?.build?.trailingSlash ?? "always");
147
154
  const files: { path: string; content: string }[] = [];
@@ -198,38 +205,34 @@ function renderFeed(
198
205
  for (const format of feed.formats) {
199
206
  const url = format === "atom" ? atomUrl : jsonFeedUrl;
200
207
  if (format === "atom") {
201
- files.push({
202
- content: renderAtom({
203
- complete,
204
- feed,
205
- items: current,
206
- selfUrl: url(siteUrl, feed.output),
207
- siteUrl,
208
- updated: feedUpdated(items),
209
- ...(archives.length > 0
210
- ? { prevArchiveUrl: url(siteUrl, feed.output, archives.length - 1) }
211
- : {}),
212
- }),
213
- path: feedPath(feed.output, "xml"),
214
- });
208
+ const subscription: AtomPage = {
209
+ complete,
210
+ feed,
211
+ items: current,
212
+ selfUrl: url(siteUrl, feed.output),
213
+ siteUrl,
214
+ updated: feedUpdated(items),
215
+ };
216
+ if (archives.length > 0) {
217
+ subscription.prevArchiveUrl = url(siteUrl, feed.output, archives.length - 1);
218
+ }
219
+ files.push({ content: renderAtom(subscription), path: feedPath(feed.output, "xml") });
215
220
  // Archives run oldest-first, so `prev-archive` walks backwards through time.
216
221
  for (const [i, page] of archives.entries()) {
217
- files.push({
218
- content: renderAtom({
219
- currentUrl: url(siteUrl, feed.output),
220
- feed,
221
- items: page,
222
- selfUrl: url(siteUrl, feed.output, i),
223
- siteUrl,
224
- updated: feedUpdated(page),
225
- ...(i > 0 ? { prevArchiveUrl: url(siteUrl, feed.output, i - 1) } : {}),
226
- nextArchiveUrl:
227
- i < archives.length - 1
228
- ? url(siteUrl, feed.output, i + 1)
229
- : url(siteUrl, feed.output),
230
- }),
231
- path: feedPath(feed.output, "xml", i),
232
- });
222
+ const archive: AtomPage = {
223
+ currentUrl: url(siteUrl, feed.output),
224
+ feed,
225
+ items: page,
226
+ selfUrl: url(siteUrl, feed.output, i),
227
+ siteUrl,
228
+ updated: feedUpdated(page),
229
+ };
230
+ if (i > 0) {
231
+ archive.prevArchiveUrl = url(siteUrl, feed.output, i - 1);
232
+ }
233
+ archive.nextArchiveUrl =
234
+ i < archives.length - 1 ? url(siteUrl, feed.output, i + 1) : url(siteUrl, feed.output);
235
+ files.push({ content: renderAtom(archive), path: feedPath(feed.output, "xml", i) });
233
236
  }
234
237
  } else {
235
238
  files.push({
package/src/json-feed.ts CHANGED
@@ -32,9 +32,25 @@ interface JsonFeedItem {
32
32
  authors?: { name: string }[];
33
33
  }
34
34
 
35
+ /*
36
+ * The top-level JSON Feed object, members in serialized order. An `undefined` member is dropped by
37
+ * JSON.stringify, which is how an optional key stays absent rather than null.
38
+ */
39
+ interface JsonFeedDocument {
40
+ description: string | undefined;
41
+ feed_url: string;
42
+ home_page_url: string;
43
+ language: string | undefined;
44
+ next_url: string | undefined;
45
+ title: string;
46
+ version: string;
47
+ authors: { name: string }[] | undefined;
48
+ items: JsonFeedItem[];
49
+ }
50
+
35
51
  export function renderJsonFeed(page: JsonFeedPage): string {
36
52
  const { feed, items, siteUrl } = page;
37
- const doc: Record<string, unknown> = {
53
+ const doc: JsonFeedDocument = {
38
54
  description: feed.description === "" ? undefined : feed.description,
39
55
  feed_url: page.selfUrl,
40
56
  home_page_url: new URL(feed.basePath, siteUrl).href,
@@ -44,7 +60,7 @@ export function renderJsonFeed(page: JsonFeedPage): string {
44
60
  title: feed.title === "" ? "Feed" : feed.title,
45
61
  version: VERSION,
46
62
  // Authors is the 1.1 spelling; `author` was 1.0 and is deprecated.
47
- ...(feed.author?.name === undefined ? {} : { authors: [{ name: feed.author.name }] }),
63
+ authors: feed.author?.name === undefined ? undefined : [{ name: feed.author.name }],
48
64
  items: items.map((item): JsonFeedItem => {
49
65
  const out: JsonFeedItem = { id: item.id, title: item.title, url: item.url };
50
66
  if (item.contentHtml !== null) {
package/src/shared.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  * text, which is what makes both testable with a literal array.
6
6
  */
7
7
 
8
- import type { ContentLoaderEntry } from "@jxsuite/schema/types";
8
+ import { isNonNullObject, isString } from "@jxsuite/schema/guards";
9
+ import type { ContentFieldValue, ContentLoaderEntry, JsonValue } from "@jxsuite/schema/types";
9
10
 
10
11
  /** What a project may declare under one key of the `feed` section. */
11
12
  export interface FeedConfig {
@@ -29,6 +30,7 @@ export interface NormalizedFeed extends Required<Omit<FeedConfig, "author" | "la
29
30
  language: string | null;
30
31
  }
31
32
 
33
+ /** The normalized `feed` section: each declared feed key mapped to its defaults-filled config. */
32
34
  export type NormalizedFeedConfig = Record<string, NormalizedFeed>;
33
35
 
34
36
  const DEFAULTS = {
@@ -43,16 +45,22 @@ const DEFAULTS = {
43
45
  updatedField: "updated",
44
46
  } as const;
45
47
 
46
- export function normalizeFeedConfig(section: unknown, siteLanguage?: string): NormalizedFeedConfig {
48
+ export function normalizeFeedConfig(section: JsonValue | undefined, siteLanguage?: string) {
47
49
  const out: NormalizedFeedConfig = {};
48
- if (!section || typeof section !== "object") {
50
+ if (!section || !isNonNullObject(section)) {
49
51
  return out;
50
52
  }
51
- for (const [key, raw] of Object.entries(section as Record<string, unknown>)) {
52
- if (!raw || typeof raw !== "object") {
53
+ for (const [key, raw] of Object.entries(section)) {
54
+ if (!raw || !isNonNullObject(raw)) {
53
55
  continue;
54
56
  }
55
- const cfg = raw as FeedConfig;
57
+ /*
58
+ * SAFETY: `raw` is a non-null JSON object (checked above), and the member types below are the
59
+ * contract of schemas/project.fragment.schema.json, which `jx validate` and the editor enforce.
60
+ * `collection` and `basePath` are read as optional because this normalizer fills defaults for
61
+ * them rather than rejecting their absence; it never re-validates member types.
62
+ */
63
+ const cfg = raw as Partial<FeedConfig>;
56
64
  out[key] = {
57
65
  archive: cfg.archive ?? DEFAULTS.archive,
58
66
  author: cfg.author ?? null,
@@ -102,8 +110,8 @@ export function entryUrl(
102
110
  }
103
111
 
104
112
  /** RFC 3339 or nothing. A feed date that is not a real timestamp is worse than an absent one. */
105
- function readDate(value: unknown): string | null {
106
- if (typeof value !== "string" || value === "") {
113
+ function readDate(value: ContentFieldValue | undefined): string | null {
114
+ if (!isString(value) || value === "") {
107
115
  return null;
108
116
  }
109
117
  if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
@@ -120,35 +128,33 @@ export function entryToItem(
120
128
  siteUrl: string,
121
129
  trailingSlash: string,
122
130
  ): FeedItem {
123
- const data = (entry.data ?? {}) as Record<string, unknown>;
124
- const meta = (entry._meta ?? {}) as Record<string, unknown>;
131
+ const { _meta: meta, data } = entry;
125
132
  /*
126
133
  * An entry of a content type with a `route` already knows its URL: the loader stamped it at
127
134
  * `_meta.url`, from the same template the pages and the links were generated from. Only an entry
128
135
  * with no route is placed by the feed's own `basePath` + id, which is the older, flat rule.
129
136
  */
137
+ const metaUrl = meta?.url;
130
138
  const url =
131
- typeof meta.url === "string" && meta.url.startsWith("/")
132
- ? new URL(meta.url, siteUrl).href
139
+ metaUrl !== undefined && metaUrl.startsWith("/")
140
+ ? new URL(metaUrl, siteUrl).href
133
141
  : entryUrl(siteUrl, feed.basePath, entry.id, trailingSlash);
134
- const published = readDate(data[feed.dateField]) ?? readDate(meta.mtime);
135
- const { author } = data;
142
+ const published = readDate(data[feed.dateField]) ?? readDate(meta?.mtime);
143
+ const { author, description, title } = data;
144
+ // A frontmatter author is either a bare name or an object carrying one (`{ name, uri }`).
145
+ const entryAuthorName = isString(author)
146
+ ? author
147
+ : isNonNullObject(author) && "name" in author && isString(author.name)
148
+ ? author.name
149
+ : undefined;
136
150
 
137
151
  return {
138
- authorName:
139
- typeof author === "string"
140
- ? author
141
- : ((author as { name?: string } | undefined)?.name ?? feed.author?.name ?? null),
152
+ authorName: entryAuthorName ?? feed.author?.name ?? null,
142
153
  contentHtml: feed.contentMode === "full" ? String(entry.body ?? "") : null,
143
154
  id: url,
144
155
  published,
145
- summary:
146
- typeof data.description === "string"
147
- ? data.description
148
- : typeof meta.excerpt === "string"
149
- ? meta.excerpt
150
- : "",
151
- title: typeof data.title === "string" ? data.title : entry.id,
156
+ summary: isString(description) ? description : (meta?.excerpt ?? ""),
157
+ title: isString(title) ? title : entry.id,
152
158
  updated: readDate(data[feed.updatedField]) ?? published,
153
159
  url,
154
160
  };
@@ -187,6 +193,12 @@ export function feedUpdated(items: readonly FeedItem[]): string | null {
187
193
  return newest;
188
194
  }
189
195
 
196
+ /** A feed split for RFC 5005: the subscription page and its archives, oldest archive first. */
197
+ export interface FeedPages {
198
+ current: FeedItem[];
199
+ archives: FeedItem[][];
200
+ }
201
+
190
202
  /**
191
203
  * Split into the subscription page and its archives (RFC 5005 §2).
192
204
  *
@@ -198,10 +210,7 @@ export function feedUpdated(items: readonly FeedItem[]): string | null {
198
210
  * each time an entry is added; counting from the oldest means archive 1 keeps its contents forever
199
211
  * and only the newest archive — the one still filling — ever changes.
200
212
  */
201
- export function paginate(
202
- items: readonly FeedItem[],
203
- pageSize: number,
204
- ): { current: FeedItem[]; archives: FeedItem[][] } {
213
+ export function paginate(items: readonly FeedItem[], pageSize: number): FeedPages {
205
214
  if (items.length <= pageSize) {
206
215
  return { archives: [], current: [...items] };
207
216
  }