@sdxc/atom 0.0.0-pre.1
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.md +21 -0
- package/README.md +245 -0
- package/dist/index.d.ts +231 -0
- package/dist/index.js +162 -0
- package/dist/lib/build-document.d.ts +17 -0
- package/dist/lib/build-document.js +195 -0
- package/dist/lib/clone.d.ts +42 -0
- package/dist/lib/clone.js +147 -0
- package/dist/lib/constants.d.ts +17 -0
- package/dist/lib/constants.js +17 -0
- package/dist/lib/content.d.ts +34 -0
- package/dist/lib/content.js +75 -0
- package/dist/lib/extensions.d.ts +23 -0
- package/dist/lib/extensions.js +42 -0
- package/dist/lib/namespaces.d.ts +44 -0
- package/dist/lib/namespaces.js +63 -0
- package/dist/lib/parse-feed.d.ts +20 -0
- package/dist/lib/parse-feed.js +431 -0
- package/dist/lib/text-construct.d.ts +33 -0
- package/dist/lib/text-construct.js +97 -0
- package/dist/lib/utils.d.ts +59 -0
- package/dist/lib/utils.js +99 -0
- package/dist/lib/validate-entry.d.ts +23 -0
- package/dist/lib/validate-entry.js +34 -0
- package/dist/lib/validate-feed.d.ts +15 -0
- package/dist/lib/validate-feed.js +23 -0
- package/dist/lib/xml-base.d.ts +36 -0
- package/dist/lib/xml-base.js +49 -0
- package/package.json +23 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves which prefixes a document binds to which namespaces, so an element can
|
|
3
|
+
* be tested for membership in the Atom namespace rather than for a spelling. The
|
|
4
|
+
* XML layer performs no namespace resolution, so this package does its own.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { ATOM_NAMESPACE } from "./constants.js";
|
|
10
|
+
import { prefixOf } from "./utils.js";
|
|
11
|
+
/**
|
|
12
|
+
* Reads every `xmlns` declaration off an element.
|
|
13
|
+
*
|
|
14
|
+
* @param element - The element whose declarations should be read
|
|
15
|
+
* @returns The declared namespaces, keyed by prefix
|
|
16
|
+
*/
|
|
17
|
+
export function readNamespaceDeclarations(element) {
|
|
18
|
+
let declarations = {};
|
|
19
|
+
for (let [name, value] of Object.entries(element.attributes ?? {})) {
|
|
20
|
+
if (name === "xmlns") {
|
|
21
|
+
declarations[""] = value;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (name.startsWith("xmlns:"))
|
|
25
|
+
declarations[name.slice("xmlns:".length)] = value;
|
|
26
|
+
}
|
|
27
|
+
return declarations;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Extends an inherited scope with the declarations an element adds, so a nested
|
|
31
|
+
* element that rebinds a prefix is read against its own binding.
|
|
32
|
+
*
|
|
33
|
+
* @param scope - The namespaces inherited from ancestors
|
|
34
|
+
* @param element - The element whose declarations should be layered on top
|
|
35
|
+
* @returns The scope in effect inside the element
|
|
36
|
+
*/
|
|
37
|
+
export function extendNamespaceScope(scope, element) {
|
|
38
|
+
let declarations = readNamespaceDeclarations(element);
|
|
39
|
+
if (Object.keys(declarations).length === 0)
|
|
40
|
+
return scope;
|
|
41
|
+
return { ...scope, ...declarations };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Resolves the namespace a qualified name belongs to under a scope.
|
|
45
|
+
*
|
|
46
|
+
* @param name - The qualified element name
|
|
47
|
+
* @param scope - The namespaces in effect
|
|
48
|
+
* @returns The namespace URI, or `undefined` when the prefix is unbound
|
|
49
|
+
*/
|
|
50
|
+
export function namespaceOf(name, scope) {
|
|
51
|
+
return scope[prefixOf(name)];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Reports whether an element belongs to the Atom namespace, which is what
|
|
55
|
+
* separates an Atom element from a foreign one carrying the same local name.
|
|
56
|
+
*
|
|
57
|
+
* @param name - The qualified element name
|
|
58
|
+
* @param scope - The namespaces in effect
|
|
59
|
+
* @returns `true` when the name resolves to the Atom namespace
|
|
60
|
+
*/
|
|
61
|
+
export function isAtomName(name, scope) {
|
|
62
|
+
return namespaceOf(name, scope) === ATOM_NAMESPACE;
|
|
63
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses an XML tree into Atom feed and entry data, threading the namespace and
|
|
3
|
+
* base-URI scopes down the document so an element is judged by the namespace it
|
|
4
|
+
* resolves to and every relative reference is read against the bases above it.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import type { XML } from "@sdxc/xml";
|
|
11
|
+
import type { Atom } from "../index.js";
|
|
12
|
+
import { AtomParseError } from "../index.js";
|
|
13
|
+
/**
|
|
14
|
+
* Parses a document into feed metadata and entries.
|
|
15
|
+
*
|
|
16
|
+
* @param xml - The parsed XML document
|
|
17
|
+
* @param base - Document URI, seeding the base against which relative references resolve
|
|
18
|
+
* @returns The feed and its entries, or the reason the document is not Atom
|
|
19
|
+
*/
|
|
20
|
+
export declare function parseDocument(xml: XML, base?: string): Result<Atom.Document, AtomParseError>;
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses an XML tree into Atom feed and entry data, threading the namespace and
|
|
3
|
+
* base-URI scopes down the document so an element is judged by the namespace it
|
|
4
|
+
* resolves to and every relative reference is read against the bases above it.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { failure, success } from "@sdxc/result";
|
|
10
|
+
import { AtomParseError } from "../index.js";
|
|
11
|
+
import { cloneAttributes } from "./clone.js";
|
|
12
|
+
import { parseContent } from "./content.js";
|
|
13
|
+
import { toExtensionElement } from "./extensions.js";
|
|
14
|
+
import { extendNamespaceScope, isAtomName, readNamespaceDeclarations } from "./namespaces.js";
|
|
15
|
+
import { parseText } from "./text-construct.js";
|
|
16
|
+
import { collapseArray, getChildElements, getElementText, localName, parseOptionalNumber, } from "./utils.js";
|
|
17
|
+
import { extendScope, resolveUri } from "./xml-base.js";
|
|
18
|
+
/**
|
|
19
|
+
* Parses a document into feed metadata and entries.
|
|
20
|
+
*
|
|
21
|
+
* @param xml - The parsed XML document
|
|
22
|
+
* @param base - Document URI, seeding the base against which relative references resolve
|
|
23
|
+
* @returns The feed and its entries, or the reason the document is not Atom
|
|
24
|
+
*/
|
|
25
|
+
export function parseDocument(xml, base) {
|
|
26
|
+
let root = xml.root;
|
|
27
|
+
let namespaces = readNamespaceDeclarations(root);
|
|
28
|
+
if (localName(root.name) !== "feed") {
|
|
29
|
+
return failure(new AtomParseError(`Expected the root element to be "feed".`));
|
|
30
|
+
}
|
|
31
|
+
if (!isAtomName(root.name, namespaces)) {
|
|
32
|
+
return failure(new AtomParseError("Expected the root element in the Atom namespace."));
|
|
33
|
+
}
|
|
34
|
+
let scope = extendScope({ base }, root);
|
|
35
|
+
let feedResult = parseFeed(root, scope, namespaces);
|
|
36
|
+
if (feedResult.status === "failure")
|
|
37
|
+
return feedResult;
|
|
38
|
+
let entries = [];
|
|
39
|
+
for (let child of getChildElements(root)) {
|
|
40
|
+
if (!isAtomName(child.name, namespaces) || localName(child.name) !== "entry")
|
|
41
|
+
continue;
|
|
42
|
+
let entryResult = parseEntry(child, scope, namespaces);
|
|
43
|
+
if (entryResult.status === "failure")
|
|
44
|
+
return entryResult;
|
|
45
|
+
entries.push(entryResult.data);
|
|
46
|
+
}
|
|
47
|
+
return success({ feed: feedResult.data, entries });
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Parses the feed element's own children, skipping the entries, which the caller
|
|
51
|
+
* collects separately so one pass does not have to build both shapes at once.
|
|
52
|
+
*/
|
|
53
|
+
function parseFeed(element, scope, namespaces) {
|
|
54
|
+
let authors = [];
|
|
55
|
+
let contributors = [];
|
|
56
|
+
let links = [];
|
|
57
|
+
let categories = [];
|
|
58
|
+
let extensions = [];
|
|
59
|
+
let feed = {
|
|
60
|
+
namespaces: cloneAttributes(namespaces),
|
|
61
|
+
attributes: readOwnAttributes(element),
|
|
62
|
+
lang: scope.lang,
|
|
63
|
+
base: scope.base,
|
|
64
|
+
};
|
|
65
|
+
for (let child of getChildElements(element)) {
|
|
66
|
+
if (!isAtomName(child.name, extendNamespaceScope(namespaces, child))) {
|
|
67
|
+
extensions.push(toExtensionElement(child));
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
let childScope = extendScope(scope, child);
|
|
71
|
+
switch (localName(child.name)) {
|
|
72
|
+
case "id": {
|
|
73
|
+
feed.id = getElementText(child);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
case "title": {
|
|
77
|
+
feed.title = parseText(child);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
case "updated": {
|
|
81
|
+
feed.updated = getElementText(child);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case "subtitle": {
|
|
85
|
+
feed.subtitle = parseText(child);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case "rights": {
|
|
89
|
+
feed.rights = parseText(child);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
case "author": {
|
|
93
|
+
authors.push(parsePerson(child, childScope, namespaces));
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
case "contributor": {
|
|
97
|
+
contributors.push(parsePerson(child, childScope, namespaces));
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case "link": {
|
|
101
|
+
links.push(parseLink(child, childScope, namespaces));
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
case "category": {
|
|
105
|
+
categories.push(parseCategory(child, namespaces));
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case "generator": {
|
|
109
|
+
feed.generator = parseGenerator(child, childScope);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case "icon": {
|
|
113
|
+
feed.icon = resolveUri(childScope, getElementText(child));
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
case "logo": {
|
|
117
|
+
feed.logo = resolveUri(childScope, getElementText(child));
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
/** Collected by the caller, which builds the entry list in its own pass. */
|
|
121
|
+
case "entry": {
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
default: {
|
|
125
|
+
extensions.push(toExtensionElement(child));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (!feed.id)
|
|
130
|
+
return failure(new AtomParseError("Feed must include an id."));
|
|
131
|
+
if (feed.title === undefined)
|
|
132
|
+
return failure(new AtomParseError("Feed must include a title."));
|
|
133
|
+
if (!feed.updated) {
|
|
134
|
+
return failure(new AtomParseError("Feed must include an updated timestamp."));
|
|
135
|
+
}
|
|
136
|
+
if (authors.length > 0)
|
|
137
|
+
feed.author = collapseArray(authors);
|
|
138
|
+
if (contributors.length > 0)
|
|
139
|
+
feed.contributor = collapseArray(contributors);
|
|
140
|
+
if (links.length > 0)
|
|
141
|
+
feed.link = collapseArray(links);
|
|
142
|
+
if (categories.length > 0)
|
|
143
|
+
feed.category = collapseArray(categories);
|
|
144
|
+
if (extensions.length > 0)
|
|
145
|
+
feed.extensions = extensions;
|
|
146
|
+
return success(feed);
|
|
147
|
+
}
|
|
148
|
+
/** Parses one entry element. */
|
|
149
|
+
function parseEntry(element, parentScope, namespaces) {
|
|
150
|
+
let scope = extendScope(parentScope, element);
|
|
151
|
+
let authors = [];
|
|
152
|
+
let contributors = [];
|
|
153
|
+
let links = [];
|
|
154
|
+
let categories = [];
|
|
155
|
+
let extensions = [];
|
|
156
|
+
let entry = {
|
|
157
|
+
attributes: readOwnAttributes(element),
|
|
158
|
+
lang: scope.lang === parentScope.lang ? undefined : scope.lang,
|
|
159
|
+
base: scope.base === parentScope.base ? undefined : scope.base,
|
|
160
|
+
};
|
|
161
|
+
for (let child of getChildElements(element)) {
|
|
162
|
+
if (!isAtomName(child.name, extendNamespaceScope(namespaces, child))) {
|
|
163
|
+
extensions.push(toExtensionElement(child));
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
let childScope = extendScope(scope, child);
|
|
167
|
+
switch (localName(child.name)) {
|
|
168
|
+
case "id": {
|
|
169
|
+
entry.id = getElementText(child);
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
case "title": {
|
|
173
|
+
entry.title = parseText(child);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case "updated": {
|
|
177
|
+
entry.updated = getElementText(child);
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
case "published": {
|
|
181
|
+
entry.published = getElementText(child);
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case "summary": {
|
|
185
|
+
entry.summary = parseText(child);
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
case "content": {
|
|
189
|
+
entry.content = parseContent(child, childScope);
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
case "rights": {
|
|
193
|
+
entry.rights = parseText(child);
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
case "author": {
|
|
197
|
+
authors.push(parsePerson(child, childScope, namespaces));
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
case "contributor": {
|
|
201
|
+
contributors.push(parsePerson(child, childScope, namespaces));
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
case "link": {
|
|
205
|
+
links.push(parseLink(child, childScope, namespaces));
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
case "category": {
|
|
209
|
+
categories.push(parseCategory(child, namespaces));
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case "source": {
|
|
213
|
+
entry.source = parseSource(child, childScope, namespaces);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
default: {
|
|
217
|
+
extensions.push(toExtensionElement(child));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!entry.id)
|
|
222
|
+
return failure(new AtomParseError("Entry must include an id."));
|
|
223
|
+
if (entry.title === undefined)
|
|
224
|
+
return failure(new AtomParseError("Entry must include a title."));
|
|
225
|
+
if (!entry.updated) {
|
|
226
|
+
return failure(new AtomParseError("Entry must include an updated timestamp."));
|
|
227
|
+
}
|
|
228
|
+
if (authors.length > 0)
|
|
229
|
+
entry.author = collapseArray(authors);
|
|
230
|
+
if (contributors.length > 0)
|
|
231
|
+
entry.contributor = collapseArray(contributors);
|
|
232
|
+
if (links.length > 0)
|
|
233
|
+
entry.link = collapseArray(links);
|
|
234
|
+
if (categories.length > 0)
|
|
235
|
+
entry.category = collapseArray(categories);
|
|
236
|
+
if (extensions.length > 0)
|
|
237
|
+
entry.extensions = extensions;
|
|
238
|
+
return success(entry);
|
|
239
|
+
}
|
|
240
|
+
/** Parses a person construct: an author or a contributor. */
|
|
241
|
+
function parsePerson(element, scope, namespaces) {
|
|
242
|
+
let person = { name: "" };
|
|
243
|
+
let extensions = [];
|
|
244
|
+
for (let child of getChildElements(element)) {
|
|
245
|
+
if (!isAtomName(child.name, extendNamespaceScope(namespaces, child))) {
|
|
246
|
+
extensions.push(toExtensionElement(child));
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
switch (localName(child.name)) {
|
|
250
|
+
case "name": {
|
|
251
|
+
person.name = getElementText(child);
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
case "uri": {
|
|
255
|
+
person.uri = resolveUri(extendScope(scope, child), getElementText(child));
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
case "email": {
|
|
259
|
+
person.email = getElementText(child);
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
default: {
|
|
263
|
+
extensions.push(toExtensionElement(child));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (extensions.length > 0)
|
|
268
|
+
person.extensions = extensions;
|
|
269
|
+
return person;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Parses a link, resolving `href` against the base in scope. `rel` is left as the
|
|
273
|
+
* document spelled it, absent included, because RFC 4287's `alternate` default is
|
|
274
|
+
* a reader's concern rather than something to bake into stored data.
|
|
275
|
+
*/
|
|
276
|
+
function parseLink(element, scope, namespaces) {
|
|
277
|
+
let attributes = { ...element.attributes };
|
|
278
|
+
let link = { href: resolveUri(scope, attributes["href"] ?? "") };
|
|
279
|
+
if (attributes["rel"] !== undefined)
|
|
280
|
+
link.rel = attributes["rel"];
|
|
281
|
+
if (attributes["type"] !== undefined)
|
|
282
|
+
link.type = attributes["type"];
|
|
283
|
+
if (attributes["hreflang"] !== undefined)
|
|
284
|
+
link.hreflang = attributes["hreflang"];
|
|
285
|
+
if (attributes["title"] !== undefined)
|
|
286
|
+
link.title = attributes["title"];
|
|
287
|
+
if (attributes["length"] !== undefined) {
|
|
288
|
+
link.length = parseOptionalNumber(attributes["length"]);
|
|
289
|
+
}
|
|
290
|
+
for (let name of ["href", "rel", "type", "hreflang", "title", "length"])
|
|
291
|
+
delete attributes[name];
|
|
292
|
+
let remaining = cloneAttributes(stripScopeAttributes(attributes));
|
|
293
|
+
if (remaining)
|
|
294
|
+
link.attributes = remaining;
|
|
295
|
+
let extensions = collectForeignChildren(element, namespaces);
|
|
296
|
+
if (extensions.length > 0)
|
|
297
|
+
link.extensions = extensions;
|
|
298
|
+
return link;
|
|
299
|
+
}
|
|
300
|
+
/** Parses a category, collapsing to the bare term when nothing else is present. */
|
|
301
|
+
function parseCategory(element, namespaces) {
|
|
302
|
+
let attributes = { ...element.attributes };
|
|
303
|
+
let term = attributes["term"] ?? "";
|
|
304
|
+
let scheme = attributes["scheme"];
|
|
305
|
+
let label = attributes["label"];
|
|
306
|
+
for (let name of ["term", "scheme", "label"])
|
|
307
|
+
delete attributes[name];
|
|
308
|
+
let remaining = cloneAttributes(stripScopeAttributes(attributes));
|
|
309
|
+
let extensions = collectForeignChildren(element, namespaces);
|
|
310
|
+
if (scheme === undefined && label === undefined && !remaining && extensions.length === 0) {
|
|
311
|
+
return term;
|
|
312
|
+
}
|
|
313
|
+
let category = { term };
|
|
314
|
+
if (scheme !== undefined)
|
|
315
|
+
category.scheme = scheme;
|
|
316
|
+
if (label !== undefined)
|
|
317
|
+
category.label = label;
|
|
318
|
+
if (remaining)
|
|
319
|
+
category.attributes = remaining;
|
|
320
|
+
if (extensions.length > 0)
|
|
321
|
+
category.extensions = extensions;
|
|
322
|
+
return category;
|
|
323
|
+
}
|
|
324
|
+
/** Parses the generator element, whose `uri` resolves against the base in scope. */
|
|
325
|
+
function parseGenerator(element, scope) {
|
|
326
|
+
let attributes = element.attributes ?? {};
|
|
327
|
+
let generator = { value: getElementText(element) };
|
|
328
|
+
if (attributes["uri"] !== undefined)
|
|
329
|
+
generator.uri = resolveUri(scope, attributes["uri"]);
|
|
330
|
+
if (attributes["version"] !== undefined)
|
|
331
|
+
generator.version = attributes["version"];
|
|
332
|
+
return generator;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Parses the source element, which carries a subset of feed metadata describing
|
|
336
|
+
* where a copied entry came from.
|
|
337
|
+
*/
|
|
338
|
+
function parseSource(element, scope, namespaces) {
|
|
339
|
+
let source = {};
|
|
340
|
+
let authors = [];
|
|
341
|
+
let links = [];
|
|
342
|
+
let extensions = [];
|
|
343
|
+
for (let child of getChildElements(element)) {
|
|
344
|
+
if (!isAtomName(child.name, extendNamespaceScope(namespaces, child))) {
|
|
345
|
+
extensions.push(toExtensionElement(child));
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
let childScope = extendScope(scope, child);
|
|
349
|
+
switch (localName(child.name)) {
|
|
350
|
+
case "id": {
|
|
351
|
+
source.id = getElementText(child);
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
case "title": {
|
|
355
|
+
source.title = parseText(child);
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
case "subtitle": {
|
|
359
|
+
source.subtitle = parseText(child);
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
case "updated": {
|
|
363
|
+
source.updated = getElementText(child);
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
case "rights": {
|
|
367
|
+
source.rights = parseText(child);
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
case "author": {
|
|
371
|
+
authors.push(parsePerson(child, childScope, namespaces));
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
case "link": {
|
|
375
|
+
links.push(parseLink(child, childScope, namespaces));
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
default: {
|
|
379
|
+
extensions.push(toExtensionElement(child));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
let attributes = readOwnAttributes(element);
|
|
384
|
+
if (attributes)
|
|
385
|
+
source.attributes = attributes;
|
|
386
|
+
if (authors.length > 0)
|
|
387
|
+
source.author = collapseArray(authors);
|
|
388
|
+
if (links.length > 0)
|
|
389
|
+
source.link = collapseArray(links);
|
|
390
|
+
if (extensions.length > 0)
|
|
391
|
+
source.extensions = extensions;
|
|
392
|
+
return source;
|
|
393
|
+
}
|
|
394
|
+
/** Collects the children of an element that fall outside the Atom namespace. */
|
|
395
|
+
function collectForeignChildren(element, namespaces) {
|
|
396
|
+
let extensions = [];
|
|
397
|
+
for (let child of getChildElements(element)) {
|
|
398
|
+
if (isAtomName(child.name, extendNamespaceScope(namespaces, child)))
|
|
399
|
+
continue;
|
|
400
|
+
extensions.push(toExtensionElement(child));
|
|
401
|
+
}
|
|
402
|
+
return extensions;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Reads the attributes an element carries in its own right, dropping the ones
|
|
406
|
+
* already modelled elsewhere: namespace declarations become `namespaces`, and
|
|
407
|
+
* `xml:base`/`xml:lang` become the scope every reference was resolved against.
|
|
408
|
+
*/
|
|
409
|
+
function readOwnAttributes(element) {
|
|
410
|
+
let attributes = {};
|
|
411
|
+
for (let [name, value] of Object.entries(element.attributes ?? {})) {
|
|
412
|
+
if (name === "xmlns" || name.startsWith("xmlns:"))
|
|
413
|
+
continue;
|
|
414
|
+
if (name === "xml:base" || name === "xml:lang")
|
|
415
|
+
continue;
|
|
416
|
+
attributes[name] = value;
|
|
417
|
+
}
|
|
418
|
+
return cloneAttributes(attributes);
|
|
419
|
+
}
|
|
420
|
+
/** Drops the scope-carrying attributes from a record already stripped of its own keys. */
|
|
421
|
+
function stripScopeAttributes(attributes) {
|
|
422
|
+
let remaining = {};
|
|
423
|
+
for (let [name, value] of Object.entries(attributes)) {
|
|
424
|
+
if (name === "xmlns" || name.startsWith("xmlns:"))
|
|
425
|
+
continue;
|
|
426
|
+
if (name === "xml:base" || name === "xml:lang")
|
|
427
|
+
continue;
|
|
428
|
+
remaining[name] = value;
|
|
429
|
+
}
|
|
430
|
+
return remaining;
|
|
431
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads and writes Atom text constructs, whose `type` decides whether the payload
|
|
3
|
+
* is plain text, a run of HTML, or an XHTML subtree that has to be serialized back
|
|
4
|
+
* into markup before a consumer can use it (RFC 4287 §3.1).
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { XML } from "@sdxc/xml";
|
|
10
|
+
import type { Atom } from "../index.js";
|
|
11
|
+
/**
|
|
12
|
+
* Reads one text construct.
|
|
13
|
+
*
|
|
14
|
+
* An `xhtml` construct collapses to the serialized markup its single `div`
|
|
15
|
+
* wrapper contained — the wrapper itself is structural and is dropped — so every
|
|
16
|
+
* construct hands back a string regardless of how it was written.
|
|
17
|
+
*
|
|
18
|
+
* @param element - The element holding the construct
|
|
19
|
+
* @returns The construct, collapsed to a bare string when it carries no type
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseText(element: XML.Element): Atom.TextInput;
|
|
22
|
+
/**
|
|
23
|
+
* Builds the element for one text construct.
|
|
24
|
+
*
|
|
25
|
+
* An `xhtml` construct is written back as `html`: the value is markup in a string
|
|
26
|
+
* by then, and re-parsing it to rebuild a wrapper would fail on any fragment the
|
|
27
|
+
* XML parser rejects. The payload survives; the typing narrows.
|
|
28
|
+
*
|
|
29
|
+
* @param name - The element name to write
|
|
30
|
+
* @param text - The construct to serialize
|
|
31
|
+
* @returns The element, ready to place in a document
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildTextElement(name: string, text: Atom.TextInput): XML.Element;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads and writes Atom text constructs, whose `type` decides whether the payload
|
|
3
|
+
* is plain text, a run of HTML, or an XHTML subtree that has to be serialized back
|
|
4
|
+
* into markup before a consumer can use it (RFC 4287 §3.1).
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { isFailure } from "@sdxc/result";
|
|
10
|
+
import { XML } from "@sdxc/xml";
|
|
11
|
+
import { DEFAULT_TEXT_TYPE } from "./constants.js";
|
|
12
|
+
import { getChildElements, getElementText, localName } from "./utils.js";
|
|
13
|
+
/** The characters that carry markup meaning when XHTML text is re-serialized. */
|
|
14
|
+
const TEXT_ESCAPES = {
|
|
15
|
+
"<": "<",
|
|
16
|
+
">": ">",
|
|
17
|
+
"&": "&",
|
|
18
|
+
};
|
|
19
|
+
const TEXT_PATTERN = /[<>&]/g;
|
|
20
|
+
/**
|
|
21
|
+
* Reads one text construct.
|
|
22
|
+
*
|
|
23
|
+
* An `xhtml` construct collapses to the serialized markup its single `div`
|
|
24
|
+
* wrapper contained — the wrapper itself is structural and is dropped — so every
|
|
25
|
+
* construct hands back a string regardless of how it was written.
|
|
26
|
+
*
|
|
27
|
+
* @param element - The element holding the construct
|
|
28
|
+
* @returns The construct, collapsed to a bare string when it carries no type
|
|
29
|
+
*/
|
|
30
|
+
export function parseText(element) {
|
|
31
|
+
let type = element.attributes?.type ?? DEFAULT_TEXT_TYPE;
|
|
32
|
+
if (type === "xhtml")
|
|
33
|
+
return { value: readXhtml(element), type: "xhtml" };
|
|
34
|
+
if (type === "html")
|
|
35
|
+
return { value: getElementText(element), type: "html" };
|
|
36
|
+
/**
|
|
37
|
+
* A `text` construct is the default, so the bare string says everything the
|
|
38
|
+
* structured form would and reads better at a call site.
|
|
39
|
+
*/
|
|
40
|
+
return getElementText(element);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Serializes the children of an `xhtml` construct's wrapper.
|
|
44
|
+
*
|
|
45
|
+
* The wrapper is the `div` RFC 4287 §3.1.1.3 requires; when a document omits it
|
|
46
|
+
* the element's own children are serialized instead, so malformed input still
|
|
47
|
+
* yields the markup it meant rather than nothing.
|
|
48
|
+
*/
|
|
49
|
+
function readXhtml(element) {
|
|
50
|
+
let wrapper = getChildElements(element).find((child) => localName(child.name) === "div");
|
|
51
|
+
let host = wrapper ?? element;
|
|
52
|
+
let markup = "";
|
|
53
|
+
for (let child of host.children ?? []) {
|
|
54
|
+
if (typeof child === "string") {
|
|
55
|
+
markup += escapeText(child);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
markup += stringifyElement(child);
|
|
59
|
+
}
|
|
60
|
+
return markup;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Serializes one element of an XHTML construct back into markup.
|
|
64
|
+
*
|
|
65
|
+
* A failure yields the element's text alone, because losing the tags around a
|
|
66
|
+
* paragraph is a better outcome for a reader than losing the paragraph.
|
|
67
|
+
*/
|
|
68
|
+
function stringifyElement(element) {
|
|
69
|
+
let result = XML.stringify(element);
|
|
70
|
+
if (isFailure(result))
|
|
71
|
+
return escapeText(getElementText(element));
|
|
72
|
+
return result.data;
|
|
73
|
+
}
|
|
74
|
+
/** Escapes the three characters that would otherwise read as markup. */
|
|
75
|
+
function escapeText(value) {
|
|
76
|
+
return value.replace(TEXT_PATTERN, (character) => TEXT_ESCAPES[character] ?? character);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Builds the element for one text construct.
|
|
80
|
+
*
|
|
81
|
+
* An `xhtml` construct is written back as `html`: the value is markup in a string
|
|
82
|
+
* by then, and re-parsing it to rebuild a wrapper would fail on any fragment the
|
|
83
|
+
* XML parser rejects. The payload survives; the typing narrows.
|
|
84
|
+
*
|
|
85
|
+
* @param name - The element name to write
|
|
86
|
+
* @param text - The construct to serialize
|
|
87
|
+
* @returns The element, ready to place in a document
|
|
88
|
+
*/
|
|
89
|
+
export function buildTextElement(name, text) {
|
|
90
|
+
if (typeof text === "string")
|
|
91
|
+
return { name, attributes: {}, children: [text] };
|
|
92
|
+
let type = text.type === "xhtml" ? "html" : text.type;
|
|
93
|
+
let attributes = {};
|
|
94
|
+
if (type && type !== DEFAULT_TEXT_TYPE)
|
|
95
|
+
attributes["type"] = type;
|
|
96
|
+
return { name, attributes, children: [text.value] };
|
|
97
|
+
}
|