@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/LICENSE +21 -0
- package/dist/feed.js +465 -0
- package/jx-extension.json +7 -0
- package/package.json +44 -0
- package/schemas/project.fragment.schema.json +65 -0
- package/src/Feed.class.json +114 -0
- package/src/atom.ts +113 -0
- package/src/feed.ts +247 -0
- package/src/json-feed.ts +78 -0
- package/src/shared.ts +225 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://jxsuite.com/schema/class/v1",
|
|
3
|
+
"$prototype": "Class",
|
|
4
|
+
"title": "Feed",
|
|
5
|
+
"description": "Atom (RFC 4287) and JSON Feed generation from content collections, with RFC 5005 archives",
|
|
6
|
+
"$implementation": "./feed.ts",
|
|
7
|
+
"project": {
|
|
8
|
+
"key": "feed",
|
|
9
|
+
"title": "Feeds",
|
|
10
|
+
"description": "Syndication feeds generated from content collections"
|
|
11
|
+
},
|
|
12
|
+
"$studio": {
|
|
13
|
+
"settings": {
|
|
14
|
+
"label": "Feeds",
|
|
15
|
+
"description": "One entry per feed. Each names a content collection and the URL prefix its entries are served under."
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"$defs": {
|
|
19
|
+
"returnTypes": {
|
|
20
|
+
"NormalizedFeedConfig": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"description": "The feed section with defaults applied, keyed by feed name"
|
|
23
|
+
},
|
|
24
|
+
"HeadEntries": {
|
|
25
|
+
"type": "array",
|
|
26
|
+
"description": "<head> entries — the feed auto-discovery links",
|
|
27
|
+
"items": { "type": "object" }
|
|
28
|
+
},
|
|
29
|
+
"EmitFiles": {
|
|
30
|
+
"type": "array",
|
|
31
|
+
"description": "Files to write under outDir",
|
|
32
|
+
"items": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": { "path": { "type": "string" }, "content": { "type": "string" } },
|
|
35
|
+
"required": ["path", "content"]
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"methods": {
|
|
40
|
+
"projectData": {
|
|
41
|
+
"role": "projectData",
|
|
42
|
+
"$prototype": "Function",
|
|
43
|
+
"access": "public",
|
|
44
|
+
"scope": "static",
|
|
45
|
+
"identifier": "projectData",
|
|
46
|
+
"timing": ["compiler", "server"],
|
|
47
|
+
"parameters": [
|
|
48
|
+
{
|
|
49
|
+
"identifier": "sectionValue",
|
|
50
|
+
"type": { "type": "object" },
|
|
51
|
+
"description": "The project.json `feed` section value"
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"returnType": { "$ref": "#/$defs/returnTypes/NormalizedFeedConfig" },
|
|
55
|
+
"description": "Normalize the feed section (defaults applied) into _project.feed"
|
|
56
|
+
},
|
|
57
|
+
"head": {
|
|
58
|
+
"role": "head",
|
|
59
|
+
"$prototype": "Function",
|
|
60
|
+
"access": "public",
|
|
61
|
+
"scope": "static",
|
|
62
|
+
"identifier": "head",
|
|
63
|
+
"timing": ["compiler"],
|
|
64
|
+
"parameters": [
|
|
65
|
+
{
|
|
66
|
+
"identifier": "sectionValue",
|
|
67
|
+
"type": { "type": "object" },
|
|
68
|
+
"description": "The project.json `feed` section value"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"identifier": "ctx",
|
|
72
|
+
"type": {
|
|
73
|
+
"type": "object",
|
|
74
|
+
"properties": { "projectConfig": { "type": "object" }, "root": { "type": "string" } }
|
|
75
|
+
},
|
|
76
|
+
"description": "Host context: { projectConfig, root } (extensions.md §8.6)"
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
"returnType": { "$ref": "#/$defs/returnTypes/HeadEntries" },
|
|
80
|
+
"description": "The <link rel=\"alternate\"> auto-discovery tags, derived from configuration alone"
|
|
81
|
+
},
|
|
82
|
+
"emit": {
|
|
83
|
+
"role": "emit",
|
|
84
|
+
"$prototype": "Function",
|
|
85
|
+
"access": "public",
|
|
86
|
+
"scope": "static",
|
|
87
|
+
"identifier": "emit",
|
|
88
|
+
"timing": ["compiler"],
|
|
89
|
+
"parameters": [
|
|
90
|
+
{
|
|
91
|
+
"identifier": "sectionValue",
|
|
92
|
+
"type": { "type": "object" },
|
|
93
|
+
"description": "The project.json `feed` section value"
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"identifier": "ctx",
|
|
97
|
+
"type": {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"properties": {
|
|
100
|
+
"projectConfig": { "type": "object" },
|
|
101
|
+
"root": { "type": "string" },
|
|
102
|
+
"sections": { "type": "object" },
|
|
103
|
+
"routes": { "type": "array" }
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
"description": "Host context: { projectConfig, root, sections, routes } (extensions.md §8.4)"
|
|
107
|
+
}
|
|
108
|
+
],
|
|
109
|
+
"returnType": { "$ref": "#/$defs/returnTypes/EmitFiles" },
|
|
110
|
+
"description": "Serialize every configured feed and its RFC 5005 archives"
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/atom.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atom 1.0 (RFC 4287), with RFC 5005 archived-feed links.
|
|
3
|
+
*
|
|
4
|
+
* Atom rather than RSS 2.0. RSS has no standards body, its date format is RFC 822, and its `<guid>`
|
|
5
|
+
* semantics were never pinned down; Atom is an IETF standard with required identity and timestamps,
|
|
6
|
+
* and every reader handles it. The omission is a decision, not an oversight.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { escapeXml, feedPath } from "./shared.ts";
|
|
10
|
+
import type { FeedItem, NormalizedFeed } from "./shared.ts";
|
|
11
|
+
|
|
12
|
+
const ATOM_NS = "http://www.w3.org/2005/Atom";
|
|
13
|
+
/** RFC 5005 §4 — the namespace for `<fh:complete/>`. */
|
|
14
|
+
const HISTORY_NS = "http://purl.org/syndication/history/1.0";
|
|
15
|
+
|
|
16
|
+
export interface AtomPage {
|
|
17
|
+
/** Absolute site URL, used to build `<id>` values. */
|
|
18
|
+
siteUrl: string;
|
|
19
|
+
feed: NormalizedFeed;
|
|
20
|
+
items: readonly FeedItem[];
|
|
21
|
+
/** Absolute URL of THIS document. */
|
|
22
|
+
selfUrl: string;
|
|
23
|
+
/** The page the site's readers subscribe to. Absent on the subscription document itself. */
|
|
24
|
+
currentUrl?: string;
|
|
25
|
+
prevArchiveUrl?: string;
|
|
26
|
+
nextArchiveUrl?: string;
|
|
27
|
+
/** RFC 5005 §4: this document holds the feed's entire history. */
|
|
28
|
+
complete?: boolean;
|
|
29
|
+
/** Feed-level timestamp — the newest item, never the build time. */
|
|
30
|
+
updated: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function tag(name: string, value: string | null | undefined, indent = " "): string {
|
|
34
|
+
return value === null || value === undefined || value === ""
|
|
35
|
+
? ""
|
|
36
|
+
: `${indent}<${name}>${escapeXml(value)}</${name}>\n`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function link(rel: string, href: string, type?: string): string {
|
|
40
|
+
const t = type === undefined ? "" : ` type="${type}"`;
|
|
41
|
+
return ` <link rel="${rel}" href="${escapeXml(href)}"${t}/>\n`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function renderEntry(item: FeedItem): string {
|
|
45
|
+
const inner = " ";
|
|
46
|
+
const author =
|
|
47
|
+
item.authorName === null
|
|
48
|
+
? ""
|
|
49
|
+
: `${inner}<author>\n${inner} <name>${escapeXml(item.authorName)}</name>\n${inner}</author>\n`;
|
|
50
|
+
return [
|
|
51
|
+
" <entry>\n",
|
|
52
|
+
tag("id", item.id, inner),
|
|
53
|
+
tag("title", item.title, inner),
|
|
54
|
+
`${inner}<link rel="alternate" href="${escapeXml(item.url)}"/>\n`,
|
|
55
|
+
tag("updated", item.updated ?? item.published, inner),
|
|
56
|
+
tag("published", item.published, inner),
|
|
57
|
+
author,
|
|
58
|
+
tag("summary", item.summary, inner),
|
|
59
|
+
item.contentHtml === null
|
|
60
|
+
? ""
|
|
61
|
+
: `${inner}<content type="html">${escapeXml(item.contentHtml)}</content>\n`,
|
|
62
|
+
" </entry>\n",
|
|
63
|
+
].join("");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function renderAtom(page: AtomPage): string {
|
|
67
|
+
const { feed, items, siteUrl } = page;
|
|
68
|
+
const ns = page.complete ? ` xmlns:fh="${HISTORY_NS}"` : "";
|
|
69
|
+
/*
|
|
70
|
+
* `xml:lang` on the feed element, which RFC 4287 §2 says every child inherits — the Atom way of
|
|
71
|
+
* saying what language this document carries. JSON Feed says it with a `language` member; the two
|
|
72
|
+
* are the same fact, and a multilingual site publishes one feed per language rather than one feed
|
|
73
|
+
* that cannot answer the question.
|
|
74
|
+
*/
|
|
75
|
+
const lang = feed.language === null ? "" : ` xml:lang="${escapeXml(feed.language)}"`;
|
|
76
|
+
const head = [
|
|
77
|
+
'<?xml version="1.0" encoding="utf-8"?>\n',
|
|
78
|
+
`<feed xmlns="${ATOM_NS}"${ns}${lang}>\n`,
|
|
79
|
+
tag("id", page.selfUrl),
|
|
80
|
+
tag("title", feed.title === "" ? "Feed" : feed.title),
|
|
81
|
+
tag("subtitle", feed.description),
|
|
82
|
+
/*
|
|
83
|
+
* RFC 4287 requires `<updated>`. It is the newest ITEM, not the build time: a feed whose
|
|
84
|
+
* timestamp moves on every deploy re-notifies every subscriber for no reason.
|
|
85
|
+
*/
|
|
86
|
+
tag("updated", page.updated ?? "1970-01-01T00:00:00Z"),
|
|
87
|
+
link("self", page.selfUrl, "application/atom+xml"),
|
|
88
|
+
link("alternate", new URL(feed.basePath, siteUrl).href, "text/html"),
|
|
89
|
+
page.currentUrl === undefined ? "" : link("current", page.currentUrl, "application/atom+xml"),
|
|
90
|
+
page.prevArchiveUrl === undefined
|
|
91
|
+
? ""
|
|
92
|
+
: link("prev-archive", page.prevArchiveUrl, "application/atom+xml"),
|
|
93
|
+
page.nextArchiveUrl === undefined
|
|
94
|
+
? ""
|
|
95
|
+
: link("next-archive", page.nextArchiveUrl, "application/atom+xml"),
|
|
96
|
+
page.complete === true ? " <fh:complete/>\n" : "",
|
|
97
|
+
feed.author?.name === undefined
|
|
98
|
+
? ""
|
|
99
|
+
: ` <author>\n <name>${escapeXml(feed.author.name)}</name>\n${
|
|
100
|
+
feed.author.uri === undefined ? "" : ` <uri>${escapeXml(feed.author.uri)}</uri>\n`
|
|
101
|
+
}${
|
|
102
|
+
feed.author.email === undefined
|
|
103
|
+
? ""
|
|
104
|
+
: ` <email>${escapeXml(feed.author.email)}</email>\n`
|
|
105
|
+
} </author>\n`,
|
|
106
|
+
];
|
|
107
|
+
return `${head.join("")}${items.map((i) => renderEntry(i)).join("")}</feed>\n`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Absolute URL of an Atom document within this feed. */
|
|
111
|
+
export function atomUrl(siteUrl: string, output: string, archiveIndex?: number): string {
|
|
112
|
+
return new URL(feedPath(output, "xml", archiveIndex), siteUrl).href;
|
|
113
|
+
}
|
package/src/feed.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Feed` section-owner class.
|
|
3
|
+
*
|
|
4
|
+
* Three capabilities, and the split between two of them is the interesting part. `emit` derives
|
|
5
|
+
* files from loaded content and runs after every page has been written; `head` derives the `<link
|
|
6
|
+
* rel="alternate">` discovery tag from **configuration** and must run before the first page is
|
|
7
|
+
* built. A feed needs both, which is why `head` exists (specs/extensions.md §8.6).
|
|
8
|
+
*
|
|
9
|
+
* @docs framework/site/feeds
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { canonicalizeLocale, localeUrlPrefix, resolveI18n } from "@jxsuite/schema/locale";
|
|
13
|
+
import type { ResolvedI18n } from "@jxsuite/schema/locale";
|
|
14
|
+
import type { ContentLoaderEntry, JxHeadEntry, ProjectConfig } from "@jxsuite/schema/types";
|
|
15
|
+
import { atomUrl, renderAtom } from "./atom.ts";
|
|
16
|
+
import { jsonFeedUrl, renderJsonFeed } from "./json-feed.ts";
|
|
17
|
+
import {
|
|
18
|
+
entryToItem,
|
|
19
|
+
feedPath,
|
|
20
|
+
feedUpdated,
|
|
21
|
+
normalizeFeedConfig,
|
|
22
|
+
paginate,
|
|
23
|
+
sortItems,
|
|
24
|
+
} from "./shared.ts";
|
|
25
|
+
import type { NormalizedFeed, NormalizedFeedConfig } from "./shared.ts";
|
|
26
|
+
|
|
27
|
+
interface EmitContext {
|
|
28
|
+
projectConfig?: ProjectConfig;
|
|
29
|
+
root?: string;
|
|
30
|
+
sections?: Record<string, unknown>;
|
|
31
|
+
routes?: unknown[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface HeadContext {
|
|
35
|
+
projectConfig?: ProjectConfig;
|
|
36
|
+
root?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const MEDIA_TYPES = { atom: "application/atom+xml", json: "application/feed+json" } as const;
|
|
40
|
+
|
|
41
|
+
function siteUrlOf(projectConfig: ProjectConfig | undefined): string | null {
|
|
42
|
+
const url = projectConfig?.url;
|
|
43
|
+
return typeof url === "string" && url !== "" ? url : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The locales a feed is published in, or `[]` when it is published once.
|
|
48
|
+
*
|
|
49
|
+
* A feed says what language it carries, and a collection spread over one directory per locale
|
|
50
|
+
* (site-architecture.md §13.3) carries several — so it is several feeds. One feed mixing three
|
|
51
|
+
* languages is worse than it sounds: a reader subscribes to it in theirs and receives every post
|
|
52
|
+
* three times, twice in a language they do not read.
|
|
53
|
+
*
|
|
54
|
+
* Read from the collection's `source`, because `head` has to answer this before any content is
|
|
55
|
+
* loaded and configuration is all it has. That the `content` section belongs to another extension
|
|
56
|
+
* is not a coupling this introduces — a feed already names a collection out of it.
|
|
57
|
+
*
|
|
58
|
+
* @param {ProjectConfig | undefined} projectConfig
|
|
59
|
+
* @param {string} collection
|
|
60
|
+
* @returns {{ i18n: ResolvedI18n | null; locales: string[] }}
|
|
61
|
+
*/
|
|
62
|
+
function feedLocales(
|
|
63
|
+
projectConfig: ProjectConfig | undefined,
|
|
64
|
+
collection: string,
|
|
65
|
+
): { i18n: ResolvedI18n | null; locales: string[] } {
|
|
66
|
+
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
|
+
return { i18n, locales: i18n !== null && localized ? i18n.locales : [] };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One locale's view of a feed: its own URL space, its own language. */
|
|
75
|
+
function localeVariant(feed: NormalizedFeed, locale: string, i18n: ResolvedI18n | null) {
|
|
76
|
+
const prefix = localeUrlPrefix(locale, i18n);
|
|
77
|
+
return {
|
|
78
|
+
...feed,
|
|
79
|
+
basePath: `${prefix}${feed.basePath}`,
|
|
80
|
+
language: locale,
|
|
81
|
+
output: `${prefix}${feed.output}`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const Feed = {
|
|
86
|
+
/** Normalize the `feed` section into `_project.feed`. */
|
|
87
|
+
projectData(sectionValue: unknown, ctx?: HeadContext): NormalizedFeedConfig {
|
|
88
|
+
return normalizeFeedConfig(sectionValue, ctx?.projectConfig?.defaults?.lang);
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The auto-discovery links.
|
|
93
|
+
*
|
|
94
|
+
* Pure configuration, which is exactly why this can run before content loads — a feed's URL comes
|
|
95
|
+
* from its `output`, not from its entries.
|
|
96
|
+
*/
|
|
97
|
+
head(sectionValue: unknown, ctx: HeadContext): JxHeadEntry[] {
|
|
98
|
+
const siteUrl = siteUrlOf(ctx.projectConfig);
|
|
99
|
+
if (siteUrl === null) {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
const config = normalizeFeedConfig(sectionValue, ctx.projectConfig?.defaults?.lang);
|
|
103
|
+
const entries: JxHeadEntry[] = [];
|
|
104
|
+
for (const feed of Object.values(config)) {
|
|
105
|
+
const { i18n, locales } = feedLocales(ctx.projectConfig, feed.collection);
|
|
106
|
+
/*
|
|
107
|
+
* One link per language, each carrying `hreflang`. `head` runs before routing and cannot know
|
|
108
|
+
* which locale the page it lands on is in, so it advertises all of them and lets the client
|
|
109
|
+
* choose — which is what `hreflang` on an `alternate` link is for.
|
|
110
|
+
*/
|
|
111
|
+
const published =
|
|
112
|
+
locales.length > 0 ? locales.map((l) => localeVariant(feed, l, i18n)) : [feed];
|
|
113
|
+
for (const variant of published) {
|
|
114
|
+
for (const format of variant.formats) {
|
|
115
|
+
const href =
|
|
116
|
+
format === "atom"
|
|
117
|
+
? atomUrl(siteUrl, variant.output)
|
|
118
|
+
: 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
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return entries;
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
/** Serialize every configured feed, plus its RFC 5005 archives when enabled. */
|
|
138
|
+
emit(sectionValue: unknown, ctx: EmitContext): { path: string; content: string }[] {
|
|
139
|
+
const siteUrl = siteUrlOf(ctx.projectConfig);
|
|
140
|
+
if (siteUrl === null) {
|
|
141
|
+
console.warn("@jxsuite/feed: `url` is not set in project.json — feeds need absolute links.");
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
const config = normalizeFeedConfig(sectionValue, ctx.projectConfig?.defaults?.lang);
|
|
145
|
+
const content = ctx.sections?.content as Map<string, ContentLoaderEntry[]> | undefined;
|
|
146
|
+
const trailingSlash = String(ctx.projectConfig?.build?.trailingSlash ?? "always");
|
|
147
|
+
const files: { path: string; content: string }[] = [];
|
|
148
|
+
|
|
149
|
+
for (const [key, feed] of Object.entries(config)) {
|
|
150
|
+
const entries = content?.get(feed.collection);
|
|
151
|
+
if (!entries) {
|
|
152
|
+
console.warn(
|
|
153
|
+
`@jxsuite/feed: feed "${key}" names collection "${feed.collection}", which is not a ` +
|
|
154
|
+
"loaded content collection — skipped",
|
|
155
|
+
);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const { i18n, locales } = feedLocales(ctx.projectConfig, feed.collection);
|
|
159
|
+
if (locales.length === 0) {
|
|
160
|
+
files.push(...renderFeed(feed, entries, siteUrl, trailingSlash));
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
/*
|
|
164
|
+
* One feed per language, each holding only that language's entries. An entry with no locale
|
|
165
|
+
* belongs to none of them — it cannot happen for a `{locale}` source, and inventing a home
|
|
166
|
+
* for it would publish it in every language at once.
|
|
167
|
+
*/
|
|
168
|
+
for (const locale of locales) {
|
|
169
|
+
const own = entries.filter((e) => canonicalizeLocale(e._meta?.locale) === locale);
|
|
170
|
+
if (own.length > 0) {
|
|
171
|
+
files.push(...renderFeed(localeVariant(feed, locale, i18n), own, siteUrl, trailingSlash));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return files;
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
function renderFeed(
|
|
180
|
+
feed: NormalizedFeed,
|
|
181
|
+
entries: readonly ContentLoaderEntry[],
|
|
182
|
+
siteUrl: string,
|
|
183
|
+
trailingSlash: string,
|
|
184
|
+
): { path: string; content: string }[] {
|
|
185
|
+
const items = sortItems(entries.map((e) => entryToItem(e, feed, siteUrl, trailingSlash)));
|
|
186
|
+
const files: { path: string; content: string }[] = [];
|
|
187
|
+
const { archives, current } = feed.archive
|
|
188
|
+
? paginate(items, feed.pageSize)
|
|
189
|
+
: { archives: [], current: items.slice(0, feed.pageSize) };
|
|
190
|
+
|
|
191
|
+
/*
|
|
192
|
+
* `fh:complete` is only true when the document really holds everything — no archives AND nothing
|
|
193
|
+
* trimmed by pageSize. Claiming completeness while entries are missing is worse than saying
|
|
194
|
+
* nothing, because a reader believes it.
|
|
195
|
+
*/
|
|
196
|
+
const complete = archives.length === 0 && current.length === items.length;
|
|
197
|
+
|
|
198
|
+
for (const format of feed.formats) {
|
|
199
|
+
const url = format === "atom" ? atomUrl : jsonFeedUrl;
|
|
200
|
+
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
|
+
});
|
|
215
|
+
// Archives run oldest-first, so `prev-archive` walks backwards through time.
|
|
216
|
+
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
|
+
});
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
files.push({
|
|
236
|
+
content: renderJsonFeed({
|
|
237
|
+
feed,
|
|
238
|
+
items: current,
|
|
239
|
+
selfUrl: url(siteUrl, feed.output),
|
|
240
|
+
siteUrl,
|
|
241
|
+
}),
|
|
242
|
+
path: feedPath(feed.output, "json"),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return files;
|
|
247
|
+
}
|
package/src/json-feed.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Feed 1.1.
|
|
3
|
+
*
|
|
4
|
+
* The same model as the Atom serializer, in the format a reader that would rather not parse XML
|
|
5
|
+
* asks for. It carries no RFC 5005 links: JSON Feed has its own `next_url` pagination and mixing
|
|
6
|
+
* the two conventions in one document would be worse than offering the archives in Atom alone.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { feedPath } from "./shared.ts";
|
|
10
|
+
import type { FeedItem, NormalizedFeed } from "./shared.ts";
|
|
11
|
+
|
|
12
|
+
const VERSION = "https://jsonfeed.org/version/1.1";
|
|
13
|
+
|
|
14
|
+
export interface JsonFeedPage {
|
|
15
|
+
siteUrl: string;
|
|
16
|
+
feed: NormalizedFeed;
|
|
17
|
+
items: readonly FeedItem[];
|
|
18
|
+
selfUrl: string;
|
|
19
|
+
/** JSON Feed's own pagination: the next, older page. */
|
|
20
|
+
nextUrl?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface JsonFeedItem {
|
|
24
|
+
id: string;
|
|
25
|
+
url: string;
|
|
26
|
+
title: string;
|
|
27
|
+
content_html?: string;
|
|
28
|
+
content_text?: string;
|
|
29
|
+
summary?: string;
|
|
30
|
+
date_published?: string;
|
|
31
|
+
date_modified?: string;
|
|
32
|
+
authors?: { name: string }[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function renderJsonFeed(page: JsonFeedPage): string {
|
|
36
|
+
const { feed, items, siteUrl } = page;
|
|
37
|
+
const doc: Record<string, unknown> = {
|
|
38
|
+
description: feed.description === "" ? undefined : feed.description,
|
|
39
|
+
feed_url: page.selfUrl,
|
|
40
|
+
home_page_url: new URL(feed.basePath, siteUrl).href,
|
|
41
|
+
// BCP 47, and the one place a feed states the language of what it carries.
|
|
42
|
+
language: feed.language ?? undefined,
|
|
43
|
+
next_url: page.nextUrl,
|
|
44
|
+
title: feed.title === "" ? "Feed" : feed.title,
|
|
45
|
+
version: VERSION,
|
|
46
|
+
// Authors is the 1.1 spelling; `author` was 1.0 and is deprecated.
|
|
47
|
+
...(feed.author?.name === undefined ? {} : { authors: [{ name: feed.author.name }] }),
|
|
48
|
+
items: items.map((item): JsonFeedItem => {
|
|
49
|
+
const out: JsonFeedItem = { id: item.id, title: item.title, url: item.url };
|
|
50
|
+
if (item.contentHtml !== null) {
|
|
51
|
+
out.content_html = item.contentHtml;
|
|
52
|
+
} else {
|
|
53
|
+
// 1.1 requires at least one of content_html / content_text.
|
|
54
|
+
out.content_text = item.summary;
|
|
55
|
+
}
|
|
56
|
+
if (item.summary !== "") {
|
|
57
|
+
out.summary = item.summary;
|
|
58
|
+
}
|
|
59
|
+
if (item.published !== null) {
|
|
60
|
+
out.date_published = item.published;
|
|
61
|
+
}
|
|
62
|
+
if (item.updated !== null && item.updated !== item.published) {
|
|
63
|
+
out.date_modified = item.updated;
|
|
64
|
+
}
|
|
65
|
+
if (item.authorName !== null) {
|
|
66
|
+
out.authors = [{ name: item.authorName }];
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
// `undefined` values are dropped by JSON.stringify, which is what keeps optional keys absent
|
|
72
|
+
// Rather than null — a null `language` would claim the feed has no language.
|
|
73
|
+
return `${JSON.stringify(doc, null, 2)}\n`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function jsonFeedUrl(siteUrl: string, output: string, archiveIndex?: number): string {
|
|
77
|
+
return new URL(feedPath(output, "json", archiveIndex), siteUrl).href;
|
|
78
|
+
}
|