feedsmith
Version:
Fast, all‑in‑one feed parser and generator for RSS, Atom, RDF, and JSON Feed, with support for Podcast, iTunes, Dublin Core, and OPML files.
468 lines (467 loc) • 17 kB
JavaScript
let entities = require("entities");
//#region src/common/utils.ts
const isPresent = (value) => {
return value != null;
};
const isObject = (value) => {
return value != null && typeof value === "object" && !Array.isArray(value) && value.constructor === Object;
};
const whitespaceOnlyRegex = /^\p{White_Space}*$/u;
const isNonEmptyString = (value) => {
return typeof value === "string" && value !== "" && !whitespaceOnlyRegex.test(value);
};
const isNonEmptyStringOrNumber = (value) => {
return typeof value === "number" || isNonEmptyString(value);
};
const retrieveText = (value) => {
return value?.["#text"] ?? value;
};
const retrieveRdfResourceOrText = (value, parse) => {
if (isObject(value)) {
const rdfResource = parse(value["@rdf:resource"]);
if (isPresent(rdfResource)) return rdfResource;
const resource = parse(value["@resource"]);
if (isPresent(resource)) return resource;
}
return parse(retrieveText(value));
};
const trimObject = (object) => {
let hasPresent = false;
let hasAbsent = false;
for (const key in object) {
if (isPresent(object[key])) hasPresent = true;
else hasAbsent = true;
if (hasPresent && hasAbsent) break;
}
if (!hasPresent) return;
if (!hasAbsent) return object;
const result = {};
for (const key in object) {
const value = object[key];
if (isPresent(value)) result[key] = value;
}
return result;
};
const trimArray = (value, parse) => {
if (!Array.isArray(value) || value.length === 0) return;
if (!parse) {
let needsTrimming = false;
for (const item of value) if (!isPresent(item)) {
needsTrimming = true;
break;
}
if (!needsTrimming) return value;
}
const result = [];
for (const element of value) {
const item = parse ? parse(element) : element;
if (isPresent(item)) result.push(item);
}
return result.length > 0 ? result : void 0;
};
const generateArrayOrSingular = (pluralValues, singularValue, generator) => {
if (isPresent(pluralValues)) return trimArray(pluralValues.map(generator));
if (isPresent(singularValue)) return generator(singularValue);
};
const generateSingularOrArray = (singularValue, pluralValues, generator) => {
if (isPresent(singularValue)) return generator(singularValue);
if (isPresent(pluralValues)) return trimArray(pluralValues.map(generator));
};
const commentStartTag = "<!--";
const commentEndTag = "-->";
const cdataStartTag = "<![CDATA[";
const cdataEndTag = "]]>";
const hasEntities = (text) => {
const ampIndex = text.indexOf("&");
return ampIndex !== -1 && text.indexOf(";", ampIndex) !== -1;
};
const stripComments = (text) => {
let currentIndex = text.indexOf(commentStartTag);
if (currentIndex === -1) return text;
let result = "";
let lastIndex = 0;
while (currentIndex !== -1) {
result += text.slice(lastIndex, currentIndex);
const endIndex = text.indexOf(commentEndTag, currentIndex + 4);
if (endIndex === -1) return text;
lastIndex = endIndex + 3;
currentIndex = text.indexOf(commentStartTag, lastIndex);
}
result += text.slice(lastIndex);
return result;
};
const decodeWithCdata = (text) => {
let currentIndex = text.indexOf(cdataStartTag);
if (currentIndex === -1) return hasEntities(text) ? (0, entities.decodeHTML)(text) : text;
let result = "";
let lastIndex = 0;
while (currentIndex !== -1) {
const textBefore = text.slice(lastIndex, currentIndex);
result += hasEntities(textBefore) ? (0, entities.decodeHTML)(textBefore) : textBefore;
const endIndex = text.indexOf(cdataEndTag, currentIndex + 9);
if (endIndex === -1) return hasEntities(text) ? (0, entities.decodeHTML)(text) : text;
result += text.slice(currentIndex + 9, endIndex);
lastIndex = endIndex + 3;
currentIndex = text.indexOf(cdataStartTag, lastIndex);
}
const textAfter = text.slice(lastIndex);
result += hasEntities(textAfter) ? (0, entities.decodeHTML)(textAfter) : textAfter;
return result;
};
const parseString = (value) => {
if (typeof value === "string") {
if (value === "") return;
return decodeWithCdata(stripComments(value)).trim() || void 0;
}
if (typeof value === "number") return value.toString();
};
const parseJsonString = (value) => {
if (typeof value === "string") {
if (value === "") return;
return value.trim() || void 0;
}
if (typeof value === "number") return value.toString();
};
const parseNumber = (value) => {
if (typeof value === "number") return value;
if (isNonEmptyString(value)) {
const numeric = +value;
return Number.isNaN(numeric) ? void 0 : numeric;
}
};
const trueRegex = /^\p{White_Space}*true\p{White_Space}*$/iu;
const falseRegex = /^\p{White_Space}*false\p{White_Space}*$/iu;
const yesRegex = /^\p{White_Space}*yes\p{White_Space}*$/iu;
const parseBoolean = (value) => {
if (typeof value === "boolean") return value;
if (isNonEmptyString(value)) {
if (trueRegex.test(value)) return true;
if (falseRegex.test(value)) return false;
}
};
const parseYesNoBoolean = (value) => {
const boolean = parseBoolean(value);
if (boolean !== void 0) return boolean;
if (isNonEmptyString(value)) return yesRegex.test(value);
};
const parseDate = (value) => {
return parseString(value);
};
const parseArray = (value) => {
if (Array.isArray(value)) return value;
if (!isObject(value)) return;
if (value.length) return Array.from(value);
const keys = Object.keys(value);
if (keys.length === 0) return;
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const n = Number(key);
if (!Number.isInteger(n) || n !== i) return;
}
return Object.values(value);
};
const parseArrayOf = (value, parse, limit) => {
let array = parseArray(value);
if (!array && isPresent(value)) array = [value];
if (array) return trimArray(limitArray(array, limit), parse);
};
const limitArray = (array, limit) => {
if (limit === void 0 || limit < 0) return array;
if (limit === 0) return [];
return array.slice(0, limit);
};
const parseSingular = (value) => {
return Array.isArray(value) ? value[0] : value;
};
const parseSingularOf = (value, parse) => {
return parse(parseSingular(value));
};
const parseCsvOf = (value, parse) => {
if (!isNonEmptyStringOrNumber(value)) return;
const items = parseString(value)?.split(",");
if (items) return trimArray(items, parse);
};
const generateCsvOf = (value, generate) => {
if (!Array.isArray(value) || value.length === 0) return;
return trimArray(value, generate)?.join();
};
const generateXmlStylesheet = (stylesheet) => {
const generated = trimObject({
type: generatePlainString(stylesheet.type),
href: generatePlainString(stylesheet.href),
title: generatePlainString(stylesheet.title),
media: generatePlainString(stylesheet.media),
charset: generatePlainString(stylesheet.charset),
alternate: generateYesNoBoolean(stylesheet.alternate)
});
if (!generated) return;
let attributes = "";
for (const key in generated) {
const value = generated[key];
if (value !== void 0) attributes += ` ${key}="${value}"`;
}
return `<?xml-stylesheet${attributes}?>`;
};
const generateXml = (builder, value, options) => {
let body = builder.build(value);
if (body.includes("'")) body = body.replace(/'/g, "'");
let declaration = "<?xml version=\"1.0\" encoding=\"utf-8\"?>";
if (options?.stylesheets?.length) for (const stylesheetObject of options.stylesheets) {
const stylesheetString = generateXmlStylesheet(stylesheetObject);
if (stylesheetString) declaration += `\n${stylesheetString}`;
}
return `${declaration}\n${body}`;
};
const generateRfc822Date = (value) => {
if (!isPresent(value)) return;
const isString = typeof value === "string";
if (isString && !isNonEmptyString(value)) return;
const date = isString ? new Date(value) : value;
if (!Number.isNaN(date.getTime())) return date.toUTCString();
if (isString) return value;
};
const generateRfc3339Date = (value) => {
if (!isPresent(value)) return;
const isString = typeof value === "string";
if (isString && !isNonEmptyString(value)) return;
const date = isString ? new Date(value) : value;
if (!Number.isNaN(date.getTime())) return date.toISOString();
if (isString) return value;
};
const generateBoolean = (value) => {
if (typeof value === "boolean") return value;
};
const generateYesNoBoolean = (value) => {
if (typeof value !== "boolean") return;
return value ? "yes" : "no";
};
const detectNamespaces = (value, recursive = false) => {
const namespaces = /* @__PURE__ */ new Set();
const seenKeys = recursive ? /* @__PURE__ */ new Set() : void 0;
const traverse = (current) => {
if (Array.isArray(current)) {
for (const item of current) traverse(item);
return;
}
if (isObject(current)) for (const key in current) {
if (seenKeys?.has(key)) continue;
seenKeys?.add(key);
const keyWithoutAt = key.charCodeAt(0) === 64 ? key.slice(1) : key;
const colonIndex = keyWithoutAt.indexOf(":");
if (colonIndex > 0) namespaces.add(keyWithoutAt.slice(0, colonIndex));
if (recursive) traverse(current[key]);
}
};
traverse(value);
return namespaces;
};
const cdataSpecialCharsRegex = /[<>&]|]]>/;
const generateCdataString = (value) => {
if (!isNonEmptyString(value)) return;
if (cdataSpecialCharsRegex.test(value)) return { "#cdata": value.trim() };
return value.trim();
};
const generateTextOrCdataString = (value) => {
const result = generateCdataString(value);
if (!result || isObject(result)) return result;
return { "#text": result };
};
const generatePlainString = (value) => {
if (!isNonEmptyString(value)) return;
return value.trim();
};
const generateNumber = (value) => {
if (typeof value === "number" && Number.isFinite(value)) return value;
};
const generateRdfResource = (value, generate) => {
const rdfResource = generate(value);
if (!isPresent(rdfResource)) return;
return { "@rdf:resource": rdfResource };
};
const generateNamespaceAttrs = (value, namespaceUris) => {
if (!isObject(value)) return;
let namespaceAttrs;
const valueNamespaces = detectNamespaces(value, true);
for (const prefix in namespaceUris) {
if (!valueNamespaces.has(prefix)) continue;
if (!namespaceAttrs) namespaceAttrs = {};
namespaceAttrs[`:${prefix}`] = namespaceUris[prefix][0];
}
return namespaceAttrs;
};
const createNamespaceNormalizator = (namespaceUris, namespacePrefixes, primaryNamespaces) => {
const normalizedUriCache = /* @__PURE__ */ new Map();
const normalizeNamespaceUri = (uri) => {
if (typeof uri !== "string") return uri;
let normalized = normalizedUriCache.get(uri);
if (normalized === void 0) {
normalized = uri.trim().toLowerCase();
normalizedUriCache.set(uri, normalized);
}
return normalized;
};
const primaryNamespaceUris = primaryNamespaces?.length ? primaryNamespaces.flatMap((key) => namespaceUris[key].map(normalizeNamespaceUri)) : void 0;
const resolveNamespacePrefix = (uri, localName, fallback) => {
const normalizedUri = normalizeNamespaceUri(uri);
if (primaryNamespaceUris?.includes(normalizedUri)) return localName;
const standardPrefix = namespacePrefixes[normalizedUri];
if (standardPrefix) return `${standardPrefix}:${localName}`;
return fallback;
};
const extractNamespaceDeclarations = (element) => {
const declarations = {};
if (isObject(element)) {
for (const key in element) if (key === "@xmlns") declarations[""] = normalizeNamespaceUri(element[key]);
else if (key.indexOf("@xmlns:") === 0) {
const prefix = key.slice(7);
declarations[prefix] = normalizeNamespaceUri(element[key]);
}
}
return declarations;
};
const normalizeWithContext = (name, context, useDefault = false) => {
const colonIndex = name.indexOf(":");
if (colonIndex === -1) {
if (useDefault && context[""]) return resolveNamespacePrefix(context[""], name, name);
return name;
}
const prefix = name.slice(0, colonIndex);
const unprefixedName = name.slice(colonIndex + 1);
const uri = context[prefix];
if (uri) return resolveNamespacePrefix(uri, unprefixedName, name);
return name;
};
const normalizeKey = (key, context) => {
if (key.charCodeAt(0) === 64) {
const attrName = key.slice(1);
return `@${normalizeWithContext(attrName, context, false)}`;
}
return normalizeWithContext(key, context, true);
};
const traverseAndNormalize = (object, parentContext = {}) => {
if (Array.isArray(object)) return object.map((item) => traverseAndNormalize(item, parentContext));
if (!isObject(object)) return object;
const declarations = extractNamespaceDeclarations(object);
let hasDeclarations = false;
for (const _ in declarations) {
hasDeclarations = true;
break;
}
const currentContext = hasDeclarations ? {
...parentContext,
...declarations
} : parentContext;
const normalizedObject = {};
for (const key in object) {
const value = object[key];
if (key.indexOf("@xmlns") === 0) {
normalizedObject[key] = value;
continue;
}
const normalizedKey = normalizeKey(key, currentContext);
const normalizedValue = traverseAndNormalize(value, currentContext);
if (normalizedKey in normalizedObject) {
const existing = normalizedObject[normalizedKey];
if (Array.isArray(existing)) existing.push(normalizedValue);
else normalizedObject[normalizedKey] = [existing, normalizedValue];
} else normalizedObject[normalizedKey] = normalizedValue;
}
return normalizedObject;
};
const needsNormalization = (object) => {
const check = (value) => {
if (Array.isArray(value)) {
for (const item of value) if (check(item)) return true;
return false;
}
if (!isObject(value)) return false;
for (const key in value) {
if (key[0] === "@") {
if (key === "@xmlns") {
const normalizedUri = normalizeNamespaceUri(value[key]);
if (primaryNamespaceUris?.includes(normalizedUri)) continue;
if (namespacePrefixes[normalizedUri]) return true;
} else if (key.startsWith("@xmlns:")) {
const prefix = key.slice(7);
const normalizedUri = normalizeNamespaceUri(value[key]);
const standardPrefix = namespacePrefixes[normalizedUri];
if (standardPrefix) {
if (primaryNamespaceUris?.includes(normalizedUri)) return true;
if (standardPrefix !== prefix) return true;
}
}
continue;
}
if (key[0] === "#") continue;
if (check(value[key])) return true;
}
return false;
};
return check(object);
};
const normalizeRoot = (object) => {
if (!isObject(object)) return object;
if (!needsNormalization(object)) return object;
const normalizedObject = {};
for (const key in object) {
const value = object[key];
const declarations = extractNamespaceDeclarations(value);
const normalizedKey = Object.keys(declarations).length ? normalizeKey(key, declarations) : key;
normalizedObject[normalizedKey] = traverseAndNormalize(value);
}
return normalizedObject;
};
return normalizeRoot;
};
const startsWithBraceRegex = /^\s*\{/;
const endsWithBraceRegex = /\}\s*$/;
const parseJsonObject = (value) => {
if (isObject(value)) return value;
if (!isNonEmptyString(value) || value.length < 2) return;
const startsWithBrace = value.charAt(0) === "{" || startsWithBraceRegex.test(value);
const endsWithBrace = value.charAt(value.length - 1) === "}" || endsWithBraceRegex.test(value);
if (!startsWithBrace || !endsWithBrace) return;
try {
return JSON.parse(value);
} catch {}
};
//#endregion
exports.createNamespaceNormalizator = createNamespaceNormalizator;
exports.detectNamespaces = detectNamespaces;
exports.generateArrayOrSingular = generateArrayOrSingular;
exports.generateBoolean = generateBoolean;
exports.generateCdataString = generateCdataString;
exports.generateCsvOf = generateCsvOf;
exports.generateNamespaceAttrs = generateNamespaceAttrs;
exports.generateNumber = generateNumber;
exports.generatePlainString = generatePlainString;
exports.generateRdfResource = generateRdfResource;
exports.generateRfc3339Date = generateRfc3339Date;
exports.generateRfc822Date = generateRfc822Date;
exports.generateSingularOrArray = generateSingularOrArray;
exports.generateTextOrCdataString = generateTextOrCdataString;
exports.generateXml = generateXml;
exports.generateXmlStylesheet = generateXmlStylesheet;
exports.generateYesNoBoolean = generateYesNoBoolean;
exports.hasEntities = hasEntities;
exports.isNonEmptyString = isNonEmptyString;
exports.isNonEmptyStringOrNumber = isNonEmptyStringOrNumber;
exports.isObject = isObject;
exports.isPresent = isPresent;
exports.limitArray = limitArray;
exports.parseArray = parseArray;
exports.parseArrayOf = parseArrayOf;
exports.parseBoolean = parseBoolean;
exports.parseCsvOf = parseCsvOf;
exports.parseDate = parseDate;
exports.parseJsonObject = parseJsonObject;
exports.parseJsonString = parseJsonString;
exports.parseNumber = parseNumber;
exports.parseSingular = parseSingular;
exports.parseSingularOf = parseSingularOf;
exports.parseString = parseString;
exports.parseYesNoBoolean = parseYesNoBoolean;
exports.retrieveRdfResourceOrText = retrieveRdfResourceOrText;
exports.retrieveText = retrieveText;
exports.trimArray = trimArray;
exports.trimObject = trimObject;