UNPKG

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.

430 lines (429 loc) 15.9 kB
import { decodeHTML } from "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) ? decodeHTML(text) : text; let result = ""; let lastIndex = 0; while (currentIndex !== -1) { const textBefore = text.slice(lastIndex, currentIndex); result += hasEntities(textBefore) ? decodeHTML(textBefore) : textBefore; const endIndex = text.indexOf(cdataEndTag, currentIndex + 9); if (endIndex === -1) return hasEntities(text) ? 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) ? 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("&apos;")) body = body.replace(/&apos;/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[`@xmlns:${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 export { createNamespaceNormalizator, detectNamespaces, generateArrayOrSingular, generateBoolean, generateCdataString, generateCsvOf, generateNamespaceAttrs, generateNumber, generatePlainString, generateRdfResource, generateRfc3339Date, generateRfc822Date, generateSingularOrArray, generateTextOrCdataString, generateXml, generateXmlStylesheet, generateYesNoBoolean, hasEntities, isNonEmptyString, isNonEmptyStringOrNumber, isObject, isPresent, limitArray, parseArray, parseArrayOf, parseBoolean, parseCsvOf, parseDate, parseJsonObject, parseJsonString, parseNumber, parseSingular, parseSingularOf, parseString, parseYesNoBoolean, retrieveRdfResourceOrText, retrieveText, trimArray, trimObject };