@jxsuite/feed 0.1.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/shared.ts ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * The feed model: config normalization, and turning a loaded content entry into a feed item.
3
+ *
4
+ * Pure. Neither serializer touches the project shape or the filesystem — they take items and return
5
+ * text, which is what makes both testable with a literal array.
6
+ */
7
+
8
+ import type { ContentLoaderEntry } from "@jxsuite/schema/types";
9
+
10
+ /** What a project may declare under one key of the `feed` section. */
11
+ export interface FeedConfig {
12
+ collection: string;
13
+ basePath: string;
14
+ title?: string;
15
+ description?: string;
16
+ formats?: ("atom" | "json")[];
17
+ output?: string;
18
+ pageSize?: number;
19
+ archive?: boolean;
20
+ author?: { name?: string; uri?: string; email?: string };
21
+ dateField?: string;
22
+ updatedField?: string;
23
+ contentMode?: "full" | "summary" | "none";
24
+ language?: string;
25
+ }
26
+
27
+ export interface NormalizedFeed extends Required<Omit<FeedConfig, "author" | "language">> {
28
+ author: { name?: string; uri?: string; email?: string } | null;
29
+ language: string | null;
30
+ }
31
+
32
+ export type NormalizedFeedConfig = Record<string, NormalizedFeed>;
33
+
34
+ const DEFAULTS = {
35
+ archive: false,
36
+ contentMode: "summary",
37
+ dateField: "date",
38
+ description: "",
39
+ formats: ["atom", "json"],
40
+ output: "/feed",
41
+ pageSize: 20,
42
+ title: "",
43
+ updatedField: "updated",
44
+ } as const;
45
+
46
+ export function normalizeFeedConfig(section: unknown, siteLanguage?: string): NormalizedFeedConfig {
47
+ const out: NormalizedFeedConfig = {};
48
+ if (!section || typeof section !== "object") {
49
+ return out;
50
+ }
51
+ for (const [key, raw] of Object.entries(section as Record<string, unknown>)) {
52
+ if (!raw || typeof raw !== "object") {
53
+ continue;
54
+ }
55
+ const cfg = raw as FeedConfig;
56
+ out[key] = {
57
+ archive: cfg.archive ?? DEFAULTS.archive,
58
+ author: cfg.author ?? null,
59
+ basePath: normalizeBasePath(cfg.basePath ?? "/"),
60
+ collection: cfg.collection ?? key,
61
+ contentMode: cfg.contentMode ?? DEFAULTS.contentMode,
62
+ dateField: cfg.dateField ?? DEFAULTS.dateField,
63
+ description: cfg.description ?? DEFAULTS.description,
64
+ formats: cfg.formats ?? [...DEFAULTS.formats],
65
+ language: cfg.language ?? siteLanguage ?? null,
66
+ output: cfg.output ?? DEFAULTS.output,
67
+ pageSize: cfg.pageSize ?? DEFAULTS.pageSize,
68
+ title: cfg.title ?? DEFAULTS.title,
69
+ updatedField: cfg.updatedField ?? DEFAULTS.updatedField,
70
+ };
71
+ }
72
+ return out;
73
+ }
74
+
75
+ function normalizeBasePath(p: string): string {
76
+ const withSlash = p.startsWith("/") ? p : `/${p}`;
77
+ return withSlash.endsWith("/") ? withSlash : `${withSlash}/`;
78
+ }
79
+
80
+ /** One entry, reduced to what both serializers need. */
81
+ export interface FeedItem {
82
+ id: string;
83
+ url: string;
84
+ title: string;
85
+ /** RFC 3339, or null when the entry carries no readable date. */
86
+ published: string | null;
87
+ updated: string | null;
88
+ summary: string;
89
+ contentHtml: string | null;
90
+ authorName: string | null;
91
+ }
92
+
93
+ /** Absolute URL for an entry, matching how the route table spells it. */
94
+ export function entryUrl(
95
+ siteUrl: string,
96
+ basePath: string,
97
+ id: string,
98
+ trailingSlash: string,
99
+ ): string {
100
+ const path = trailingSlash === "never" ? `${basePath}${id}` : `${basePath}${id}/`;
101
+ return new URL(path, siteUrl).href;
102
+ }
103
+
104
+ /** 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 === "") {
107
+ return null;
108
+ }
109
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
110
+ return `${value}T00:00:00Z`;
111
+ }
112
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/.test(value)
113
+ ? value
114
+ : null;
115
+ }
116
+
117
+ export function entryToItem(
118
+ entry: ContentLoaderEntry,
119
+ feed: NormalizedFeed,
120
+ siteUrl: string,
121
+ trailingSlash: string,
122
+ ): FeedItem {
123
+ const data = (entry.data ?? {}) as Record<string, unknown>;
124
+ const meta = (entry._meta ?? {}) as Record<string, unknown>;
125
+ const url = entryUrl(siteUrl, feed.basePath, entry.id, trailingSlash);
126
+ const published = readDate(data[feed.dateField]) ?? readDate(meta.mtime);
127
+ const { author } = data;
128
+
129
+ return {
130
+ authorName:
131
+ typeof author === "string"
132
+ ? author
133
+ : ((author as { name?: string } | undefined)?.name ?? feed.author?.name ?? null),
134
+ contentHtml: feed.contentMode === "full" ? String(entry.body ?? "") : null,
135
+ id: url,
136
+ published,
137
+ summary:
138
+ typeof data.description === "string"
139
+ ? data.description
140
+ : typeof meta.excerpt === "string"
141
+ ? meta.excerpt
142
+ : "",
143
+ title: typeof data.title === "string" ? data.title : entry.id,
144
+ updated: readDate(data[feed.updatedField]) ?? published,
145
+ url,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Newest first, and undated entries last.
151
+ *
152
+ * The dates arrive already normalized to UTC by the parser's coercion pass, which is what makes a
153
+ * plain string comparison chronological rather than accidental.
154
+ */
155
+ export function sortItems(items: FeedItem[]): FeedItem[] {
156
+ return [...items].toSorted((a, b) => {
157
+ if (a.published === b.published) {
158
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
159
+ }
160
+ if (a.published === null) {
161
+ return 1;
162
+ }
163
+ if (b.published === null) {
164
+ return -1;
165
+ }
166
+ return a.published < b.published ? 1 : -1;
167
+ });
168
+ }
169
+
170
+ /** The feed-level `<updated>`: the newest item, never the build time. */
171
+ export function feedUpdated(items: readonly FeedItem[]): string | null {
172
+ let newest: string | null = null;
173
+ for (const item of items) {
174
+ const stamp = item.updated ?? item.published;
175
+ if (stamp !== null && (newest === null || stamp > newest)) {
176
+ newest = stamp;
177
+ }
178
+ }
179
+ return newest;
180
+ }
181
+
182
+ /**
183
+ * Split into the subscription page and its archives (RFC 5005 §2).
184
+ *
185
+ * The subscription document carries the newest page; archives are indexed oldest-first, so a reader
186
+ * walking `prev-archive` moves backwards through time.
187
+ *
188
+ * **Chunked from the oldest end, and that is the point.** RFC 5005 §2 says an archive document
189
+ * SHOULD NOT change once published. Chunking from the newest end would reshuffle every boundary
190
+ * each time an entry is added; counting from the oldest means archive 1 keeps its contents forever
191
+ * and only the newest archive — the one still filling — ever changes.
192
+ */
193
+ export function paginate(
194
+ items: readonly FeedItem[],
195
+ pageSize: number,
196
+ ): { current: FeedItem[]; archives: FeedItem[][] } {
197
+ if (items.length <= pageSize) {
198
+ return { archives: [], current: [...items] };
199
+ }
200
+ const current = items.slice(0, pageSize);
201
+ const oldestFirst = items.slice(pageSize).toReversed();
202
+ const archives: FeedItem[][] = [];
203
+ for (let i = 0; i < oldestFirst.length; i += pageSize) {
204
+ // Oldest-first across pages; newest-first within a page, like every other feed document.
205
+ archives.push(oldestFirst.slice(i, i + pageSize).toReversed());
206
+ }
207
+ return { archives, current };
208
+ }
209
+
210
+ export function escapeXml(value: string): string {
211
+ return value
212
+ .replaceAll("&", "&amp;")
213
+ .replaceAll("<", "&lt;")
214
+ .replaceAll(">", "&gt;")
215
+ .replaceAll('"', "&quot;")
216
+ .replaceAll("'", "&apos;");
217
+ }
218
+
219
+ /** `/feed` + `.xml`, and `/feed/archive/2.xml` for an archive page. */
220
+ export function feedPath(output: string, ext: string, archiveIndex?: number): string {
221
+ const base = output.startsWith("/") ? output : `/${output}`;
222
+ return archiveIndex === undefined
223
+ ? `${base}.${ext}`
224
+ : `${base}/archive/${archiveIndex + 1}.${ext}`;
225
+ }