@jxsuite/feed 0.4.1 → 0.4.3
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 +78 -51
- package/package.json +2 -2
- package/src/feed.ts +66 -63
- package/src/json-feed.ts +18 -2
- package/src/shared.ts +38 -29
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 (
|
|
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
|
|
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 ||
|
|
141
|
+
if (!section || !isNonNullObject(section)) {
|
|
115
142
|
return out;
|
|
116
143
|
}
|
|
117
144
|
for (const [key, raw] of Object.entries(section)) {
|
|
118
|
-
if (!raw ||
|
|
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 (
|
|
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
|
|
159
|
-
const
|
|
160
|
-
const url =
|
|
161
|
-
const published = readDate(data[feed.dateField]) ?? readDate(meta
|
|
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:
|
|
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:
|
|
169
|
-
title:
|
|
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
|
-
|
|
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
|
|
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
|
|
339
|
-
const
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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:
|
|
427
|
-
selfUrl: url(siteUrl, feed.output),
|
|
465
|
+
items: page,
|
|
466
|
+
selfUrl: url(siteUrl, feed.output, i),
|
|
428
467
|
siteUrl,
|
|
429
|
-
updated: feedUpdated(
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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.
|
|
3
|
+
"version": "0.4.3",
|
|
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": "^
|
|
38
|
+
"@jxsuite/schema": "^4.1.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 {
|
|
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
|
|
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 {
|
|
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 =
|
|
68
|
-
|
|
69
|
-
const source =
|
|
70
|
-
const localized =
|
|
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:
|
|
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:
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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(
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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:
|
|
48
|
+
export function normalizeFeedConfig(section: JsonValue | undefined, siteLanguage?: string) {
|
|
47
49
|
const out: NormalizedFeedConfig = {};
|
|
48
|
-
if (!section ||
|
|
50
|
+
if (!section || !isNonNullObject(section)) {
|
|
49
51
|
return out;
|
|
50
52
|
}
|
|
51
|
-
for (const [key, raw] of Object.entries(section
|
|
52
|
-
if (!raw ||
|
|
53
|
+
for (const [key, raw] of Object.entries(section)) {
|
|
54
|
+
if (!raw || !isNonNullObject(raw)) {
|
|
53
55
|
continue;
|
|
54
56
|
}
|
|
55
|
-
|
|
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:
|
|
106
|
-
if (
|
|
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
|
|
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
|
-
|
|
132
|
-
? new URL(
|
|
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
|
|
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
|
-
|
|
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
|
}
|