UNPKG

microformats-parser

Version:

A JavaScript microformats v2 parser for the browser and node.js

1 lines 78.3 kB
{"version":3,"file":"index.cjs","sources":["../src/helpers/attributes.ts","../src/helpers/array.ts","../src/backcompat/index.ts","../src/backcompat/adr.ts","../src/backcompat/geo.ts","../src/backcompat/hentry.ts","../src/backcompat/hfeed.ts","../src/backcompat/hnews.ts","../src/backcompat/hproduct.ts","../src/backcompat/hreview.ts","../src/backcompat/vcard.ts","../src/backcompat/hresume.ts","../src/backcompat/vevent.ts","../src/backcompat/item.ts","../src/backcompat/hreview-aggregate.ts","../src/helpers/nodeMatchers.ts","../src/helpers/findChildren.ts","../src/helpers/experimental.ts","../src/helpers/textContent.ts","../src/implied/name.ts","../src/implied/url.ts","../src/helpers/images.ts","../src/implied/photo.ts","../src/helpers/valueClassPattern.ts","../src/helpers/url.ts","../src/microformats/property.ts","../src/microformats/properties.ts","../src/helpers/includes.ts","../src/microformats/parse.ts","../src/validator.ts","../src/rels/rels.ts","../src/helpers/documentSetup.ts","../src/helpers/metaformats.ts","../src/index.ts","../src/parser.ts"],"sourcesContent":["import { Attribute, Element } from \"../types\";\n\nexport const getAttribute = (\n node: Element,\n name: string,\n): Attribute | undefined => node.attrs.find((attr) => attr.name === name);\n\nexport const getAttributeValue = (\n node: Element,\n name: string,\n): string | undefined => {\n const attr = getAttribute(node, name)?.value;\n return attr?.length ? attr : undefined;\n};\n\nexport const getClassNames = (\n node: Element,\n matcher?: RegExp | string,\n): string[] => {\n const classNames = getAttributeValue(node, \"class\")?.split(\" \") || [];\n\n return matcher\n ? classNames.filter((name) =>\n typeof matcher === \"string\"\n ? name.startsWith(matcher)\n : name.match(matcher),\n )\n : classNames;\n};\n\nexport const getClassNameIntersect = <T extends string>(\n node: Element,\n toCompare: T[],\n): T[] =>\n getClassNames(node).filter((name: string): name is T =>\n toCompare.includes(name as T),\n );\n\nexport const hasClassName = (node: Element, className: string): boolean =>\n getClassNames(node).some((name) => name === className);\n\nexport const hasClassNameIntersect = (\n node: Element,\n toCompare: string[],\n): boolean => getClassNames(node).some((name) => toCompare.includes(name));\n\nexport const getAttributeIfTag = (\n node: Element,\n tagNames: string[],\n attr: string,\n): string | undefined =>\n tagNames.includes(node.tagName) ? getAttributeValue(node, attr) : undefined;\n\nexport const hasRelIntersect = (node: Element, toCompare: string[]): boolean =>\n Boolean(\n getAttributeValue(node, \"rel\")\n ?.split(\" \")\n .some((name) => toCompare.includes(name)),\n );\n\nexport const getRelIntersect = (node: Element, toCompare: string[]): string[] =>\n getAttributeValue(node, \"rel\")\n ?.split(\" \")\n .filter((name) => toCompare.includes(name)) || [];\n","export const flatten = <T>(prev: T[], curr: T[]): T[] => [...prev, ...curr];\n","import { Element } from \"../types\";\nimport { adr } from \"./adr\";\nimport { geo } from \"./geo\";\nimport { hentry } from \"./hentry\";\nimport { hfeed } from \"./hfeed\";\nimport { hnews } from \"./hnews\";\nimport { hproduct } from \"./hproduct\";\nimport { hreview } from \"./hreview\";\nimport { vcard } from \"./vcard\";\nimport {\n getClassNameIntersect,\n hasClassNameIntersect,\n getRelIntersect,\n hasRelIntersect,\n getAttributeValue,\n getClassNames,\n} from \"../helpers/attributes\";\nimport { hreviewAggregate } from \"./hreview-aggregate\";\nimport { hresume } from \"./hresume\";\nimport { vevent } from \"./vevent\";\nimport { item } from \"./item\";\nimport { flatten } from \"../helpers/array\";\n\nexport const backcompat = {\n adr,\n geo,\n hentry,\n hfeed,\n hnews,\n hproduct,\n hreview,\n vcard,\n hresume,\n vevent,\n item,\n \"hreview-aggregate\": hreviewAggregate,\n};\n\nexport type BackcompatRoot = keyof typeof backcompat;\n\nexport const backcompatRoots = Object.keys(backcompat) as BackcompatRoot[];\n\nexport const getBackcompatRootClassNames = (node: Element): BackcompatRoot[] =>\n getClassNameIntersect(node, backcompatRoots);\n\nexport const convertV1RootClassNames = (node: Element): string[] => {\n const classNames = getBackcompatRootClassNames(node)\n .map((cl) => backcompat[cl].type)\n .reduce(flatten);\n\n return classNames.length > 1\n ? classNames.filter((cl) => cl !== \"h-item\")\n : classNames;\n};\n\nexport const hasBackcompatMicroformatProperty = (\n node: Element,\n roots: BackcompatRoot[],\n): boolean =>\n roots.some((root) => {\n const { properties, rels } = backcompat[root];\n return (\n hasClassNameIntersect(node, Object.keys(properties)) ||\n (rels && hasRelIntersect(node, Object.keys(rels)))\n );\n });\n\nexport const convertV1PropertyClassNames = (\n node: Element,\n roots: BackcompatRoot[],\n): string[] => [\n ...new Set(\n roots\n .map((root) => {\n const { properties, rels } = backcompat[root];\n\n const classes = getClassNameIntersect(\n node,\n Object.keys(properties),\n ).map((cl) => properties[cl]);\n\n const relClasses =\n (rels &&\n getRelIntersect(node, Object.keys(rels)).map((cl) => rels[cl])) ||\n [];\n\n return [...classes, ...relClasses];\n })\n .reduce(flatten),\n ),\n];\n\nexport const getV1IncludeNames = (node: Element): string[] => {\n const itemref = getAttributeValue(node, \"itemref\");\n\n if (itemref) {\n return itemref.split(\" \");\n }\n\n if (getClassNames(node).includes(\"include\")) {\n const hrefAttr = node.tagName === \"object\" ? \"data\" : \"href\";\n\n const href = getAttributeValue(node, hrefAttr);\n\n if (href && href.startsWith(\"#\")) {\n return [href.substring(1)];\n }\n }\n\n const headers = node.tagName === \"td\" && getAttributeValue(node, \"headers\");\n\n if (headers) {\n return [headers];\n }\n\n return [];\n};\n","import { Backcompat } from \"../types\";\n\nexport const adr: Backcompat = {\n type: [\"h-adr\"],\n properties: {\n \"country-name\": \"p-country-name\",\n locality: \"p-locality\",\n region: \"p-region\",\n \"street-address\": \"p-street-address\",\n \"postal-code\": \"p-postal-code\",\n \"extended-address\": \"p-extended-address\",\n },\n};\n","import { Backcompat } from \"../types\";\n\nexport const geo: Backcompat = {\n type: [\"h-geo\"],\n properties: {\n latitude: \"p-latitude\",\n longitude: \"p-longitude\",\n },\n};\n","import { Backcompat } from \"../types\";\n\nexport const hentry: Backcompat = {\n type: [\"h-entry\"],\n properties: {\n author: \"p-author\",\n \"entry-content\": \"e-content\",\n \"entry-summary\": \"p-summary\",\n \"entry-title\": \"p-name\",\n updated: \"dt-updated\",\n },\n rels: {\n bookmark: \"u-url\",\n tag: \"p-category\",\n },\n};\n","import { Backcompat } from \"../types\";\n\nexport const hfeed: Backcompat = {\n type: [\"h-feed\"],\n properties: {\n author: \"p-author\",\n photo: \"u-photo\",\n url: \"u-url\",\n },\n rels: {\n tag: \"p-category\",\n },\n};\n","import { Backcompat } from \"../types\";\n\nexport const hnews: Backcompat = {\n type: [\"h-news\"],\n properties: {\n entry: \"p-entry\",\n \"source-org\": \"p-source-org\",\n dateline: \"p-dateline\",\n geo: \"p-geo\",\n },\n rels: {\n principles: \"u-principles\",\n },\n};\n","import { Backcompat } from \"../types\";\n\nexport const hproduct: Backcompat = {\n type: [\"h-product\"],\n properties: {\n price: \"p-price\",\n description: \"p-description\",\n fn: \"p-name\",\n review: \"p-review\",\n brand: \"p-brand\",\n url: \"u-url\",\n photo: \"u-photo\",\n },\n rels: {\n tag: \"p-category\",\n },\n};\n","import { Backcompat } from \"../types\";\n\nexport const hreview: Backcompat = {\n type: [\"h-review\"],\n properties: {\n item: \"p-item\",\n rating: \"p-rating\",\n reviewer: \"p-author\",\n summary: \"p-name\",\n url: \"u-url\",\n description: \"e-content\",\n },\n rels: {\n bookmark: \"u-url\",\n tag: \"p-category\",\n },\n};\n","import { Backcompat } from \"../types\";\n\nexport const vcard: Backcompat = {\n type: [\"h-card\"],\n properties: {\n fn: \"p-name\",\n url: \"u-url\",\n org: \"p-org\",\n adr: \"p-adr\",\n tel: \"p-tel\",\n title: \"p-job-title\",\n email: \"u-email\",\n photo: \"u-photo\",\n agent: \"p-agent\",\n \"family-name\": \"p-family-name\",\n \"given-name\": \"p-given-name\",\n \"additional-name\": \"p-additional-name\",\n \"honorific-prefix\": \"p-honorific-prefix\",\n \"honorific-suffix\": \"p-honorific-suffix\",\n key: \"p-key\",\n label: \"p-label\",\n logo: \"u-logo\",\n mailer: \"p-mailer\",\n nickname: \"p-nickname\",\n note: \"p-note\",\n sound: \"u-sound\",\n geo: \"p-geo\",\n bday: \"dt-bday\",\n class: \"p-class\",\n rev: \"p-rev\",\n role: \"p-role\",\n \"sort-string\": \"p-sort-string\",\n tz: \"p-tz\",\n uid: \"u-uid\",\n },\n rels: {\n tag: \"p-category\",\n },\n};\n","import { Backcompat } from \"../types\";\n\nexport const hresume: Backcompat = {\n type: [\"h-resume\"],\n properties: {\n contact: \"p-contact\",\n experience: \"p-experience\",\n summary: \"p-summary\",\n skill: \"p-skill\",\n education: \"p-education\",\n affiliation: \"p-affiliation\",\n },\n};\n","import { Backcompat } from \"../types\";\n\nexport const vevent: Backcompat = {\n type: [\"h-event\"],\n properties: {\n summary: \"p-name\",\n dtstart: \"dt-start\",\n dtend: \"dt-end\",\n duration: \"dt-duration\",\n description: \"p-description\",\n attendee: \"p-attendee\",\n location: \"p-location\",\n url: \"u-url\",\n },\n};\n","import { Backcompat } from \"../types\";\n\nexport const item: Backcompat = {\n type: [\"h-item\"],\n properties: {\n fn: \"p-name\",\n photo: \"u-photo\",\n url: \"u-url\",\n },\n};\n","import { Backcompat } from \"../types\";\n\nexport const hreviewAggregate: Backcompat = {\n type: [\"h-review-aggregate\"],\n properties: {\n rating: \"p-rating\",\n average: \"p-average\",\n best: \"p-best\",\n count: \"p-count\",\n item: \"p-item\",\n url: \"u-url\",\n fn: \"p-name\",\n },\n};\n","import { TextNode, Node, Element } from \"../types\";\nimport {\n getAttribute,\n hasClassNameIntersect,\n getClassNames,\n} from \"./attributes\";\nimport {\n backcompatRoots,\n hasBackcompatMicroformatProperty,\n BackcompatRoot,\n} from \"../backcompat\";\n\nconst classRegex = (prefix: string): RegExp =>\n new RegExp(`^${prefix}-([a-z0-9]+-)?([a-z]+-)*[a-z]+$`);\n\nconst rootClassRegex = classRegex(\"h\");\nconst propClassRegex = classRegex(\"(p|e|u|dt)\");\n\nexport const isElement = (node: Node): node is Element =>\n \"tagName\" in node && \"childNodes\" in node;\n\nexport const isTag =\n (tagName: string) =>\n (node: Node): node is Element =>\n isElement(node) && node.tagName === tagName;\n\nexport const isTextNode = (node: Node): node is TextNode => \"value\" in node;\n\nexport const isMicroformatV2Root = (node: Element): boolean =>\n getClassNames(node).some((cl) => cl.match(rootClassRegex));\n\nconst isMicroformatV1Root = (node: Element): boolean =>\n hasClassNameIntersect(node, backcompatRoots);\n\nexport const isMicroformatRoot = (node: Element): boolean =>\n isMicroformatV2Root(node) || isMicroformatV1Root(node);\n\nexport const isMicroformatV1Property = (\n node: Element,\n roots: BackcompatRoot[],\n): boolean => hasBackcompatMicroformatProperty(node, roots);\n\nexport const isMicroformatV2Property = (node: Element): boolean =>\n getClassNames(node, propClassRegex).length > 0;\n\nexport const isMicroformatChild = (\n node: Element,\n roots: BackcompatRoot[],\n): boolean =>\n !isMicroformatV2Property(node) &&\n !isMicroformatV1Property(node, roots) &&\n isMicroformatRoot(node);\n\nexport const isBase = (node: Element): boolean =>\n Boolean(\n isElement(node) && node.tagName === \"base\" && getAttribute(node, \"href\"),\n );\n\nexport const isValueClass = (node: Element): boolean =>\n isElement(node) && hasClassNameIntersect(node, [\"value\", \"value-title\"]);\n\nexport const isRel = (node: Element): boolean =>\n Boolean(\n isElement(node) &&\n node.attrs.some((attr) => attr.name === \"rel\") &&\n node.attrs.some((attr) => attr.name === \"href\"),\n );\n","import { Document, Element } from \"../types\";\nimport { isMicroformatRoot, isElement } from \"./nodeMatchers\";\nimport { BackcompatRoot, getBackcompatRootClassNames } from \"../backcompat\";\n\ntype Matcher =\n | ((node: Element) => boolean)\n | ((node: Element, roots: BackcompatRoot[]) => boolean);\n\ninterface ReducerOptions {\n matcher: Matcher;\n roots: BackcompatRoot[];\n}\n\nconst getElementChildren = (node: Element | Document): Element[] =>\n node.childNodes.filter(Boolean).filter(isElement);\n\nconst reducer = (\n microformats: Element[],\n node: Element,\n options: ReducerOptions,\n): Element[] => {\n const { matcher, roots } = options;\n const match = matcher(node, roots) && node;\n\n // if we have a match and it's a h- element, stop looking\n if (match && isMicroformatRoot(node)) {\n return [...microformats, node];\n }\n\n if (isMicroformatRoot(node)) {\n return microformats;\n }\n\n const childMicroformats = getElementChildren(node).reduce<Element[]>(\n (prev, curr) => reducer(prev, curr, options),\n match ? [match] : [],\n );\n\n return [...microformats, ...childMicroformats];\n};\n\nexport const findChildren = (\n parent: Element | Document,\n matcher: Matcher,\n): Element[] => {\n const findOptions = {\n roots: isElement(parent) ? getBackcompatRootClassNames(parent) : [],\n stopAtRoot: true,\n matcher,\n };\n\n return getElementChildren(parent).reduce<Element[]>(\n (prev, curr) => reducer(prev, curr, findOptions),\n [],\n );\n};\n","import { ExperimentalName, ParserOptions } from \"../types\";\n\nexport const isEnabled = (\n options: ParserOptions,\n flag: ExperimentalName,\n): boolean => {\n if (!options || !options.experimental) {\n return false;\n }\n\n return options.experimental[flag] || false;\n};\n","import { getAttributeValue } from \"./attributes\";\nimport { isElement, isTextNode } from \"./nodeMatchers\";\nimport { ParserOptions, Node, Element } from \"../types\";\nimport { isEnabled } from \"./experimental\";\n\nconst imageValue = (node: Element): string | undefined =>\n getAttributeValue(node, \"alt\")?.trim() ??\n getAttributeValue(node, \"src\")?.trim();\n\nconst walk = (current: string, node: Node): string => {\n if (isElement(node)) {\n if ([\"style\", \"script\"].includes(node.tagName)) {\n return current;\n }\n\n if (node.tagName === \"img\") {\n const value = imageValue(node);\n\n if (value) {\n return `${current} ${value} `;\n }\n }\n\n return node.childNodes.reduce<string>(walk, current);\n } else if (isTextNode(node)) {\n return `${current}${node.value}`;\n }\n\n return current;\n};\n\nconst impliedWalk = (current: string, node: Node): string => {\n if (isElement(node)) {\n if ([\"style\", \"script\"].includes(node.tagName)) {\n return current;\n }\n\n if (node.tagName === \"img\") {\n const value = getAttributeValue(node, \"alt\") || \"\";\n return `${current}${value}`;\n }\n\n return node.childNodes.reduce<string>(impliedWalk, current);\n } else if (isTextNode(node)) {\n return `${current}${node.value}`;\n }\n\n return current;\n};\n\nconst experimentalWalk = (current: string, node: Node): string => {\n if (isElement(node)) {\n if ([\"style\", \"script\"].includes(node.tagName)) {\n return current;\n }\n\n if (node.tagName === \"img\") {\n const value = imageValue(node);\n\n if (value) {\n return `${current} ${value} `;\n }\n }\n\n if (node.tagName === \"br\") {\n return `${current}\\n`;\n }\n\n if (node.tagName === \"p\") {\n return node.childNodes.reduce<string>(experimentalWalk, `${current}\\n`);\n }\n\n return node.childNodes.reduce<string>(experimentalWalk, current);\n } else if (isTextNode(node)) {\n const value = node.value.replace(/[\\t\\n\\r]/g, \" \");\n if (value) {\n return `${current}${value}`;\n }\n }\n\n return current;\n};\n\nconst experimentalTextContent = (node: Element): string =>\n node.childNodes\n .reduce<string>(experimentalWalk, \"\")\n .replace(/ +/g, \" \")\n .replace(/ ?\\n ?/g, \"\\n\")\n .trim();\n\nexport const textContent = (node: Element, options: ParserOptions): string => {\n if (isEnabled(options, \"textContent\")) {\n return experimentalTextContent(node);\n }\n\n return node.childNodes.reduce<string>(walk, \"\").trim();\n};\n\nexport const impliedTextContent = (\n node: Element,\n options: ParserOptions,\n): string => {\n if (isEnabled(options, \"textContent\")) {\n return experimentalTextContent(node);\n }\n\n return node.childNodes.reduce<string>(impliedWalk, \"\").trim();\n};\n\nexport const relTextContent = (\n node: Element,\n options: ParserOptions,\n): string => {\n if (isEnabled(options, \"textContent\")) {\n return experimentalTextContent(node);\n }\n\n return node.childNodes.reduce<string>(impliedWalk, \"\");\n};\n","import { impliedTextContent } from \"../helpers/textContent\";\nimport { isElement } from \"../helpers/nodeMatchers\";\nimport { getClassNames, getAttributeIfTag } from \"../helpers/attributes\";\nimport { ParsingOptions, Element } from \"../types\";\n\nconst parseNode = (node: Element): string | undefined =>\n getAttributeIfTag(node, [\"img\", \"area\"], \"alt\") ??\n getAttributeIfTag(node, [\"abbr\"], \"title\");\n\nconst parseChild = (node: Element): string | undefined => {\n const children = node.childNodes.filter(isElement);\n return children.length ? parseNode(children[0]) : undefined;\n};\n\nconst parseGrandchild = (node: Element): string | undefined => {\n const children = node.childNodes.filter(isElement);\n return children.length === 1 ? parseChild(children[0]) : undefined;\n};\n\nexport const impliedName = (\n node: Element,\n children: Element[],\n options: ParsingOptions,\n): string | undefined => {\n if (children.some((child) => getClassNames(child, /^(p|e|h)-/).length)) {\n return;\n }\n\n return (\n parseNode(node) ??\n parseChild(node) ??\n parseGrandchild(node) ??\n impliedTextContent(node, options)\n );\n};\n","import { Element } from \"../types\";\n\nimport { getClassNames, getAttributeIfTag } from \"../helpers/attributes\";\nimport { isElement, isMicroformatV2Root } from \"../helpers/nodeMatchers\";\n\nconst parseNode = (node: Element): string | undefined =>\n getAttributeIfTag(node, [\"a\", \"area\"], \"href\");\n\nconst parseChild = (node: Element): string | undefined => {\n const children = node.childNodes.filter(isElement);\n const a = children.filter((child) => child.tagName === \"a\");\n const area = children.filter((child) => child.tagName === \"area\");\n\n for (const list of [a, area]) {\n if (list.length === 1 && !isMicroformatV2Root(list[0])) {\n return parseNode(list[0]);\n }\n }\n\n return;\n};\n\nconst parseGrandchild = (node: Element): string | undefined => {\n const children = node.childNodes.filter(isElement);\n return children.length === 1 ? parseChild(children[0]) : undefined;\n};\n\nexport const impliedUrl = (\n node: Element,\n children: Element[],\n): string | undefined => {\n if (children.some((child) => getClassNames(child, \"u-\").length)) {\n return;\n }\n\n return parseNode(node) ?? parseChild(node) ?? parseGrandchild(node);\n};\n","import { Element } from \"../types\";\n\nimport { getAttributeValue } from \"./attributes\";\nimport { Image, ParsingOptions } from \"../types\";\n\nexport const parseImage = (\n node: Element,\n { inherited }: Partial<ParsingOptions> = {},\n): Image | string | undefined => {\n if (node.tagName !== \"img\") {\n return;\n }\n\n const alt =\n (!inherited || !inherited.roots || !inherited.roots.length) &&\n getAttributeValue(node, \"alt\");\n const value = getAttributeValue(node, \"src\");\n return alt ? { alt, value } : value;\n};\n","import { Image, Element } from \"../types\";\nimport { parseImage } from \"../helpers/images\";\nimport { getAttributeValue, getClassNames } from \"../helpers/attributes\";\nimport { isElement, isMicroformatV2Root } from \"../helpers/nodeMatchers\";\n\nconst parseNode = (node: Element): Image | string | undefined => {\n if (node.tagName === \"img\") {\n return parseImage(node);\n }\n\n if (node.tagName === \"object\") {\n return getAttributeValue(node, \"data\");\n }\n\n return;\n};\n\nconst parseChild = (node: Element): Image | string | undefined => {\n const children = node.childNodes.filter(isElement);\n const imgs = children.filter((child) => child.tagName === \"img\");\n const objects = children.filter((child) => child.tagName === \"object\");\n\n for (const list of [imgs, objects]) {\n if (list.length === 1 && !isMicroformatV2Root(list[0])) {\n return parseNode(list[0]);\n }\n }\n\n return;\n};\n\nconst parseGrandchild = (node: Element): string | Image | undefined => {\n const children = node.childNodes.filter(isElement);\n return children.length === 1 ? parseChild(children[0]) : undefined;\n};\n\nexport const impliedPhoto = (\n node: Element,\n children: Element[],\n): Image | string | undefined => {\n if (children.some((child) => getClassNames(child, \"u-\").length)) {\n return;\n }\n\n return parseNode(node) ?? parseChild(node) ?? parseGrandchild(node);\n};\n","import { getAttributeValue, hasClassName } from \"./attributes\";\nimport { textContent } from \"./textContent\";\nimport { findChildren } from \"./findChildren\";\nimport { isValueClass } from \"./nodeMatchers\";\nimport { ParsingOptions, Element } from \"../types\";\n\ninterface Options {\n datetime: boolean;\n}\n\nconst datetimeProp = (node: Element): string | undefined =>\n getAttributeValue(node, \"datetime\");\n\nconst valueTitle = (node: Element): string | undefined => {\n if (hasClassName(node, \"value-title\")) {\n return getAttributeValue(node, \"title\");\n }\n\n return;\n};\n\nconst handleDate = (dateStrings: string[]): string | undefined =>\n dateStrings\n .sort((a) =>\n // Sort the date elements to move date components to the start\n a.match(/^[0-9]{4}/) ? -1 : 1,\n )\n .join(\" \")\n .trim()\n .replace(\n // remove \":\" from timezones\n /((\\+|-)[0-2][0-9]):([0-5][0-9])$/,\n (s) => s.replace(\":\", \"\"),\n )\n .replace(\n // handle am and pm times\n /([0-2]?[0-9])(:[0-5][0-9])?(:[0-5][0-9])?(a\\.?m\\.?|p\\.?m\\.?)/i,\n (_s, hour, min, sec, ampm) => {\n const isAm = /a/i.test(ampm);\n\n // if the time is:\n // - am, zero pad\n // - pm, add 12 hours\n const newHour = isAm\n ? hour.padStart(2, \"0\")\n : `${parseInt(hour, 10) + 12}`;\n\n // reconstruct, and add mins if any are missing\n return `${newHour}${min ? min : \":00\"}${sec || \"\"}`;\n },\n )\n .toUpperCase();\n\nexport const valueClassPattern = (\n node: Element,\n options: ParsingOptions & Partial<Options>,\n): string | undefined => {\n const values = findChildren(node, isValueClass);\n\n if (!values.length) {\n return;\n }\n\n if (options.datetime) {\n const date = values.map(\n (node) =>\n datetimeProp(node) ?? valueTitle(node) ?? textContent(node, options),\n );\n return handleDate(date);\n }\n\n return values\n .map((node) => valueTitle(node) ?? textContent(node, options))\n .join(\"\")\n .trim();\n};\n","export const isLocalLink = (link: string): boolean =>\n !link.includes(\"://\") && !link.startsWith(\"#\");\n\nexport const applyBaseUrl = (link: string, baseUrl: string): string =>\n new URL(link, baseUrl).toString();\n","import { serialize } from \"parse5\";\n\nimport {\n getAttributeIfTag,\n getClassNames,\n getAttributeValue,\n} from \"../helpers/attributes\";\nimport {\n ParsedProperty,\n MicroformatProperty,\n Html,\n PropertyType,\n ParsingOptions,\n Element,\n} from \"../types\";\nimport { isMicroformatRoot } from \"../helpers/nodeMatchers\";\nimport { parseMicroformat } from \"./parse\";\nimport { valueClassPattern } from \"../helpers/valueClassPattern\";\nimport { textContent, impliedTextContent } from \"../helpers/textContent\";\nimport { parseImage } from \"../helpers/images\";\nimport { isLocalLink, applyBaseUrl } from \"../helpers/url\";\nimport { convertV1PropertyClassNames } from \"../backcompat\";\nimport { isEnabled } from \"../helpers/experimental\";\n\nconst propertyRegexp = /^(p|u|e|dt)-/;\n\nconst getType = (className: string): PropertyType =>\n (className.startsWith(\"p-\") && \"p\") ||\n (className.startsWith(\"u-\") && \"u\") ||\n (className.startsWith(\"e-\") && \"e\") ||\n \"dt\";\n\nexport const parseP = (node: Element, options: ParsingOptions): string =>\n valueClassPattern(node, options) ??\n getAttributeIfTag(node, [\"abbr\", \"link\"], \"title\") ??\n getAttributeIfTag(node, [\"data\"], \"value\") ??\n getAttributeIfTag(node, [\"img\", \"area\"], \"alt\") ??\n getAttributeIfTag(node, [\"meta\"], \"content\") ??\n impliedTextContent(node, options);\n\nexport const parseU = (\n node: Element,\n options: ParsingOptions,\n): MicroformatProperty => {\n const url =\n getAttributeIfTag(node, [\"a\", \"area\", \"link\"], \"href\") ??\n parseImage(node, options) ??\n getAttributeIfTag(node, [\"audio\", \"source\", \"iframe\", \"video\"], \"src\") ??\n getAttributeIfTag(node, [\"video\"], \"poster\") ??\n getAttributeIfTag(node, [\"object\"], \"data\") ??\n valueClassPattern(node, options) ??\n getAttributeIfTag(node, [\"abbr\"], \"title\") ??\n getAttributeIfTag(node, [\"data\", \"input\"], \"value\") ??\n getAttributeIfTag(node, [\"meta\"], \"content\") ??\n textContent(node, options);\n\n if (typeof url === \"string\" && isLocalLink(url)) {\n return applyBaseUrl(url, options.baseUrl);\n }\n\n return typeof url === \"string\" ? url.trim() : url;\n};\n\nexport const parseDt = (node: Element, options: ParsingOptions): string =>\n valueClassPattern(node, { ...options, datetime: true }) ??\n getAttributeIfTag(node, [\"time\", \"ins\", \"del\"], \"datetime\") ??\n getAttributeIfTag(node, [\"abbr\"], \"title\") ??\n getAttributeIfTag(node, [\"data\", \"input\"], \"value\") ??\n getAttributeIfTag(node, [\"meta\"], \"content\") ??\n textContent(node, options);\n\nexport const parseE = (node: Element, options: ParsingOptions): Html => {\n const value = {\n value: textContent(node, options),\n html: serialize(node).trim(),\n };\n\n const lang =\n isEnabled(options, \"lang\") &&\n (getAttributeValue(node, \"lang\") || options.inherited.lang);\n\n return lang ? { ...value, lang } : value;\n};\n\nconst getPropertyClassNames = (\n node: Element,\n { inherited }: ParsingOptions,\n): string[] => {\n if (inherited.roots.length) {\n return convertV1PropertyClassNames(node, inherited.roots);\n }\n\n return getClassNames(node, /^(p|u|e|dt)-/);\n};\n\nconst handleProperty = (\n node: Element,\n type: PropertyType,\n options: ParsingOptions,\n): MicroformatProperty => {\n if (type === \"p\") {\n return parseP(node, options);\n }\n\n if (type === \"e\") {\n return parseE(node, options);\n }\n\n if (type === \"u\") {\n return parseU(node, options);\n }\n\n return parseDt(node, options);\n};\n\nexport const parseProperty = (\n child: Element,\n options: ParsingOptions,\n): ParsedProperty[] =>\n getPropertyClassNames(child, options)\n .map((className): ParsedProperty | undefined => {\n const type = getType(className);\n const key = className.replace(propertyRegexp, \"\");\n const value =\n [\"u\", \"p\", \"e\", \"dt\"].includes(type) && isMicroformatRoot(child)\n ? parseMicroformat(child, {\n ...options,\n valueType: type,\n valueKey: key,\n })\n : handleProperty(child, type, options);\n\n return { type, key, value };\n })\n .filter((p): p is ParsedProperty => Boolean(p));\n\n/**\n * Some properties require knowledge of other properties to be parsed correctly\n * Apply known post-initial-parse rules here:\n * - dt-end should be dt-start aware\n */\nexport const postParseNode = (\n prop: ParsedProperty,\n _i: number,\n all: ParsedProperty[],\n): ParsedProperty => {\n // Imply an end date if only time specified\n if (\n prop.type === \"dt\" &&\n prop.key === \"end\" &&\n typeof prop.value === \"string\" &&\n !prop.value.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/) &&\n prop.value.match(/^[0-9]{2}:[0-9]{2}/)\n ) {\n const value = all.find(\n (p) =>\n p.type === \"dt\" && p.key === \"start\" && typeof prop.value === \"string\",\n )?.value as string;\n\n if (value) {\n const date = value.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}/);\n\n return { ...prop, value: `${date} ${prop.value}` };\n }\n }\n\n return prop;\n};\n","import {\n ParsedProperty,\n MicroformatProperties,\n ParsingOptions,\n Element,\n} from \"../types\";\nimport { findChildren } from \"../helpers/findChildren\";\nimport { impliedName } from \"../implied/name\";\nimport { impliedUrl } from \"../implied/url\";\nimport {\n isMicroformatV1Property,\n isMicroformatV2Property,\n} from \"../helpers/nodeMatchers\";\nimport { impliedPhoto } from \"../implied/photo\";\nimport { parseProperty, postParseNode } from \"./property\";\nimport { flatten } from \"../helpers/array\";\n\nconst addProperty = (\n properties: MicroformatProperties,\n { key, value }: Pick<ParsedProperty, \"key\" | \"value\">,\n): void => {\n if (typeof value === \"undefined\") {\n return;\n }\n\n if (!properties[key] && !Array.isArray(properties[key])) {\n properties[key] = [value];\n return;\n }\n\n properties[key].push(value);\n};\n\nconst getPropertyNodes = (node: Element, options: ParsingOptions): Element[] =>\n !options.inherited.roots.length\n ? findChildren(node, isMicroformatV2Property)\n : findChildren(node, isMicroformatV1Property);\n\nexport const microformatProperties = (\n node: Element,\n options: ParsingOptions,\n): MicroformatProperties => {\n const properties: MicroformatProperties = {};\n\n const propertyNodes = getPropertyNodes(node, options);\n\n propertyNodes\n .map((child) => parseProperty(child, options))\n .reduce(flatten, [])\n .map(postParseNode)\n .forEach((prop) => addProperty(properties, prop));\n\n if (options.implyProperties && !options.inherited.roots.length) {\n if (typeof properties.name === \"undefined\") {\n addProperty(properties, {\n key: \"name\",\n value: impliedName(node, propertyNodes, options),\n });\n }\n\n if (typeof properties.url === \"undefined\") {\n addProperty(properties, {\n key: \"url\",\n value: impliedUrl(node, propertyNodes),\n });\n }\n\n if (typeof properties.photo === \"undefined\") {\n addProperty(properties, {\n key: \"photo\",\n value: impliedPhoto(node, propertyNodes),\n });\n }\n }\n\n return properties;\n};\n","import { Element } from \"../types\";\n\nimport {\n isMicroformatV2Root,\n isElement,\n isMicroformatRoot,\n} from \"./nodeMatchers\";\nimport { ParsingOptions } from \"../types\";\nimport { getV1IncludeNames } from \"../backcompat\";\n\nconst applyIncludes = (node: Element, options: ParsingOptions): void => {\n const includeNames = getV1IncludeNames(node);\n\n includeNames.forEach((name) => {\n const include = options.idRefs[name];\n if (include) {\n node.childNodes.push(include);\n }\n });\n\n node.childNodes.forEach(\n (child) =>\n isElement(child) &&\n !isMicroformatRoot(child) &&\n applyIncludes(child, options),\n );\n};\n\nexport const applyIncludesToRoot = (\n node: Element,\n options: ParsingOptions,\n): void => {\n if (isMicroformatV2Root(node)) {\n return;\n }\n\n applyIncludes(node, options);\n};\n","import {\n MicroformatRoot,\n PropertyType,\n ParsingOptions,\n Element,\n} from \"../types\";\nimport { microformatProperties } from \"./properties\";\nimport { textContent } from \"../helpers/textContent\";\nimport { getAttributeValue, getClassNames } from \"../helpers/attributes\";\nimport { findChildren } from \"../helpers/findChildren\";\nimport {\n isMicroformatChild,\n isMicroformatRoot,\n isMicroformatV2Root,\n} from \"../helpers/nodeMatchers\";\nimport {\n convertV1RootClassNames,\n getBackcompatRootClassNames,\n BackcompatRoot,\n} from \"../backcompat\";\nimport { applyIncludesToRoot } from \"../helpers/includes\";\nimport { parseE, parseDt } from \"./property\";\nimport { isEnabled } from \"../helpers/experimental\";\n\ninterface ParseMicroformatOptions extends ParsingOptions {\n valueType?: PropertyType;\n valueKey?: string;\n}\n\nconst getMicroformatType = (node: Element): string[] => {\n const v2 = getClassNames(node, \"h-\");\n return v2.length ? v2 : convertV1RootClassNames(node);\n};\n\nconst getRoots = (node: Element): BackcompatRoot[] =>\n isMicroformatV2Root(node) ? [] : getBackcompatRootClassNames(node);\n\nconst getId = (node: Element): string | undefined =>\n isMicroformatV2Root(node) ? getAttributeValue(node, \"id\") : undefined;\n\nexport const parseMicroformat = (\n node: Element,\n options: ParseMicroformatOptions,\n): MicroformatRoot => {\n applyIncludesToRoot(node, options);\n\n const roots = getRoots(node);\n const id = getId(node);\n const lang = getAttributeValue(node, \"lang\") || options.inherited.lang;\n const children = findChildren(node, isMicroformatChild);\n const inherited = { lang, roots };\n\n const item: MicroformatRoot = {\n type: getMicroformatType(node).sort(),\n properties: microformatProperties(node, {\n ...options,\n implyProperties: !findChildren(node, isMicroformatRoot).length,\n inherited,\n }),\n };\n\n if (id) {\n item.id = id;\n }\n\n if (isEnabled(options, \"lang\") && lang) {\n item.lang = lang;\n }\n\n if (children.length) {\n item.children = children.map((child) =>\n parseMicroformat(child, { ...options, inherited }),\n );\n }\n\n if (options.valueType === \"p\") {\n item.value =\n (item.properties.name && item.properties.name[0]) ??\n getAttributeValue(node, \"title\") ??\n textContent(node, options);\n }\n\n if (options.valueType === \"u\") {\n item.value =\n (item.properties.url && item.properties.url[0]) ??\n textContent(node, options);\n }\n\n /**\n * The `value` is set as per default parsing as nothing else has been added\n * to the spec. A proposal has been made:\n * https://github.com/microformats/microformats2-parsing/issues/71\n */\n if (options.valueType === \"dt\") {\n item.value = parseDt(node, options);\n }\n\n /**\n * There is some ambigutity on how this should be handled.\n * At the moment, we're following other parsers and keeping `value` a string\n * and adding `html` as an undocumented property.\n */\n if (options.valueType === \"e\") {\n return { ...parseE(node, options), ...item };\n }\n\n if (options.valueKey && !item.value) {\n /**\n * There's a lot of complexity and ambiguity on how this case should be handled.\n * We should fall back to the `value` property of the nested MicroformatRoot or Image\n */\n const value =\n item.properties[options.valueKey] && item.properties[options.valueKey][0];\n\n if (value) {\n item.value = typeof value === \"string\" ? value : value.value;\n }\n }\n\n return item;\n};\n","import { isElement, isTag } from \"./helpers/nodeMatchers\";\nimport { Document } from \"./types\";\n\nconst assertIsString = (str: unknown, name: string): string => {\n if (typeof str === \"undefined\") {\n throw new TypeError(`Microformats parser: ${name} not provided`);\n }\n\n if (typeof str !== \"string\") {\n throw new TypeError(`Microformats parser: ${name} is not a string`);\n }\n\n if (str === \"\") {\n throw new TypeError(`Microformats parser: ${name} cannot be empty`);\n }\n\n return str;\n};\n\nconst assertIsBoolean = (bool: unknown, name: string): boolean => {\n if (typeof bool !== \"boolean\") {\n throw new TypeError(`Microformats parser: ${name} is not a boolean`);\n }\n\n return bool;\n};\n\nconst assertIsObject = (\n obj: unknown,\n allowedKeys: string[],\n name: string,\n): Record<string, unknown> => {\n if (typeof obj === \"undefined\") {\n throw new TypeError(`Microformats parser: ${name} is not provided`);\n }\n\n if (typeof obj !== \"object\") {\n throw new TypeError(`Microformats parser: ${name} is not an object`);\n }\n\n if (Array.isArray(obj)) {\n throw new TypeError(`Microformats parser: ${name} is not an object`);\n }\n\n if (obj === null) {\n throw new TypeError(`Microformats parser: ${name} cannot be null`);\n }\n\n const unknownKeys = Object.keys(obj).filter(\n (key) => !allowedKeys.includes(key),\n );\n\n if (unknownKeys.length) {\n throw new TypeError(\n `Microformats parser: ${name} contains unknown properties: ${unknownKeys.join(\n \", \",\n )}`,\n );\n }\n\n return obj as Record<string, unknown>;\n};\n\nexport const validator = (\n unknownHtml: unknown,\n unknownOptions: unknown,\n): void => {\n assertIsString(unknownHtml, \"HTML\");\n\n const options = assertIsObject(\n unknownOptions,\n [\"baseUrl\", \"experimental\"],\n \"options\",\n );\n\n const baseUrl = assertIsString(options.baseUrl, \"baseUrl\");\n\n // verify the url provided is valid\n new URL(baseUrl);\n\n if (\"experimental\" in options) {\n const experimental = assertIsObject(\n options.experimental,\n [\"lang\", \"textContent\", \"metaformats\"],\n \"experimental\",\n );\n\n if (\"lang\" in experimental) {\n assertIsBoolean(experimental.lang, \"experimental.lang\");\n }\n\n if (\"textContent\" in experimental) {\n assertIsBoolean(experimental.textContent, \"experimental.textContent\");\n }\n\n if (\"metaformats\" in experimental) {\n assertIsBoolean(experimental.metaformats, \"experimental.metaformats\");\n }\n }\n};\n\nexport const validateParsedHtml = (doc: Document): void => {\n // <html> and <body> are always defined (based on tests)\n // Provide error handling in the event they are ever not defined\n const html = doc.childNodes.find(isTag(\"html\"));\n\n if (!html) {\n throw new Error(\"Microformats parser: No <html> element found\");\n }\n\n const body = html.childNodes.find(isTag(\"body\"));\n\n if (!body) {\n throw new Error(\"Microformats parser: No <body> element found\");\n }\n\n // if we have no body children, it's the result of invalid HTML\n if (!body.childNodes.filter(isElement).length) {\n throw new Error(\"Microformats parser: unable to parse HTML\");\n }\n};\n","import { Rels, RelUrls, ParserOptions, Element } from \"../types\";\nimport { getAttributeValue } from \"../helpers/attributes\";\nimport { relTextContent } from \"../helpers/textContent\";\n\ninterface ParseRelOptions {\n rels: Rels;\n relUrls: RelUrls;\n}\n\nexport const parseRel = (\n child: Element,\n { rels, relUrls }: ParseRelOptions,\n options: ParserOptions,\n): void => {\n /**\n * Ignores used as this method is only ever called if they are defined\n * But required for TS typechecking\n */\n const text = relTextContent(child, options);\n const rel = getAttributeValue(child, \"rel\");\n const href = getAttributeValue(child, \"href\")?.trim();\n const title = getAttributeValue(child, \"title\");\n const media = getAttributeValue(child, \"media\");\n const hreflang = getAttributeValue(child, \"hreflang\");\n const type = getAttributeValue(child, \"type\");\n\n if (!rel || !href) {\n return;\n }\n\n rel.split(\" \").forEach((rel) => {\n if (!rels[rel]) {\n rels[rel] = [];\n }\n\n if (!rels[rel].includes(href)) {\n rels[rel].push(href);\n }\n\n if (!relUrls[href]) {\n relUrls[href] = { rels: [rel], text };\n } else if (!relUrls[href].rels.includes(rel)) {\n relUrls[href].rels.push(rel);\n relUrls[href].rels.sort();\n }\n\n if (text && !relUrls[href].text) {\n relUrls[href].text = text;\n }\n\n if (title && !relUrls[href].title) {\n relUrls[href].title = title;\n }\n\n if (media && !relUrls[href].media) {\n relUrls[href].media = media;\n }\n\n if (hreflang && !relUrls[href].hreflang) {\n relUrls[href].hreflang = hreflang;\n }\n\n if (type && !relUrls[href].type) {\n relUrls[href].type = type;\n }\n });\n};\n","import { ParserOptions, IdRefs, Rels, RelUrls } from \"../types\";\nimport { getAttribute, getAttributeValue } from \"./attributes\";\nimport { isLocalLink, applyBaseUrl } from \"./url\";\nimport { isElement, isRel, isBase } from \"./nodeMatchers\";\nimport { parseRel } from \"../rels/rels\";\nimport { Document, Element } from \"../types\";\n\ninterface DocumentSetupResult {\n idRefs: IdRefs;\n rels: Rels;\n relUrls: RelUrls;\n baseUrl: string;\n lang?: string;\n}\n\nexport const findBase = (node: Element | Document): string | undefined => {\n for (const child of node.childNodes) {\n if (!isElement(child)) {\n continue;\n }\n\n if (isBase(child)) {\n return getAttributeValue(child, \"href\");\n }\n\n const base = findBase(child);\n\n if (base) {\n return base;\n }\n }\n\n return;\n};\n\n// this is mutating the object, and will mutate it for everything else :-/\n\nconst handleNode = (\n node: Element | Document,\n result: DocumentSetupResult,\n options: ParserOptions,\n): void => {\n for (const i in node.childNodes) {\n const child = node.childNodes[i];\n\n if (!isElement(child)) {\n continue;\n }\n\n /**\n * Delete <template> tags from the document\n */\n if (child.tagName === \"template\") {\n delete node.childNodes[i];\n }\n\n /**\n * Extract 'lang' from the <html> or a <meta> tag\n * Always take the first value found\n */\n if (!result.lang) {\n if (child.tagName === \"html\") {\n result.lang = getAttributeValue(child, \"lang\");\n }\n\n if (\n child.tagName === \"meta\" &&\n getAttributeValue(child, \"http-equiv\") === \"Content-Language\"\n ) {\n result.lang = getAttributeValue(child, \"content\");\n }\n }\n\n /**\n * Apply the baseUrl to all [href], [src] and object[data] attributes\n */\n const attrsToApplyBaseUrl =\n child.tagName === \"object\" ? [\"data\"] : [\"href\", \"src\"];\n\n attrsToApplyBaseUrl.forEach((attrName) => {\n const attr = getAttribute(child, attrName);\n\n if (attr && isLocalLink(attr.value)) {\n attr.value = applyBaseUrl(attr.value, result.baseUrl);\n } else if (attr) {\n attr.value = attr.value.trim();\n }\n });\n\n /**\n * If we have an ID, add this node to the ID reference map\n */\n const id = getAttributeValue(child, \"id\");\n\n if (id && !result.idRefs[id]) {\n result.idRefs[id] = child;\n }\n\n if (isRel(child)) {\n parseRel(child, result, options);\n }\n\n /**\n * Repeat this process for this node's children\n */\n handleNode(child, result, options);\n }\n};\n\nexport const documentSetup = (\n node: Document,\n options: ParserOptions,\n): DocumentSetupResult => {\n const result = {\n idRefs: {},\n rels: {},\n relUrls: {},\n baseUrl: findBase(node) ?? options.baseUrl,\n lang: undefined,\n };\n\n handleNode(node, result, options);\n\n return result;\n};\n","import { Document, Element } from \"../types\";\nimport { MicroformatRoot, ParsingOptions } from \"../types\";\nimport {\n getAttributeIfTag,\n getAttributeValue,\n hasRelIntersect,\n} from \"./attributes\";\nimport { isEnabled } from \"./experimental\";\nimport { isElement, isTag } from \"./nodeMatchers\";\n\n/** Special key for title tag in meta collection */\nconst TITLE_TAG_KEY = \"<title>\";\nconst CANONICAL_URL_KEY = \"<canonical>\";\nconst MEDIA_TYPES = [\"image\", \"video\", \"audio\"];\n\ninterface ComplexMediaMeta {\n value: string;\n alt: string;\n}\ntype MetaTagContent = string | ComplexMediaMeta;\n\n/**\n * Creates a normalized store for meta tags\n */\nconst initializeMetaContentCollection = (): MetaContentCollection => {\n /**\n * Collection of all relevant meta tag content\n * Since tag order isn't guaranteed, need to collect all value before applying defaults\n */\n const metaContent: Record<string, MetaTagContent[]> = {};\n\n /**\n * Gets the values of the first property found\n * @param properties Array of properties to look for, preferred item first\n */\n const get = (properties: string[]) => {\n for (const key of properties) {\n if (metaContent[key]) {\n return metaContent[key];\n }\n }\n return;\n };\n\n /**\n * Stores meta tag values.\n *\n * Includes following normalization rules:\n * - Duplicates are removed from repeated (array) tags\n * - src, url, and secure_url media tags are treated same as base (e.g. og:image:url -> og:image)\n * - Alt text is added as property on last image url\n */\n const set = (key: string, value: string) => {\n // Split tag name to normalize values like \"og:video:url\"\n const [domain, type, subtype] = key.split(\":\");\n\n // Media tags specific parsing\n if (\n (domain === \"og\" || domain === \"twitter\") &&\n MEDIA_TYPES.includes(type)\n ) {\n if (subtype === \"alt\") {\n const existingMedia = metaContent[`${domain}:${type}`];\n\n if (existingMedia?.length) {\n const last = existingMedia.pop();\n\n if (typeof last === \"string\") {\n existingMedia.push({ value: last, alt: value });\n } else if (last) {\n // Found duplicate alt text tag so re-inserting existing\n // last should always be object. if condition added for types\n existingMedia.push(last);\n }\n }\n\n return; // Stop as alt text is already added\n } else if ([\"url\", \"secure_url\"].includes(subtype)) {\n // Mutate key to normalize different url values\n // Duplicates will be cleaned up on insertion\n key = `${domain}:${type}`;\n }\n }\n const existing = metaContent[key];\n\n if (existing) {\n const isDuplicate = existing\n .map((existingValue) =>\n typeof existingValue === \"string\"\n ? existingValue\n : existingValue.value,\n )\n .some((existingValue) => value === existingValue);\n\n if (!isDuplicate) {\n metaContent[key].push(value);\n } // Else ignore duplicates\n } else {\n metaContent[key] = [value];\n }\n };\n\n return {\n metaContent,\n set,\n get,\n };\n};\n\ninterface MetaContentCollection {\n metaContent: Record<string, MetaTagContent[]>;\n set: (key: string, value: string) => void;\n get: (properties: string[]) => MetaTagContent[] | undefined;\n}\n\nconst collectMetaTags = (head: Element): MetaContentCollection => {\n const metaTags = initializeMetaContentCollection();\n\n for (const i in head.childNodes) {\n const child = head.childNodes[i];\n\n if (!isElement(child)) {\n continue;\n }\n\n const content = getAttributeIfTag(child, [\"meta\"], \"content\");\n if (content) {\n // Tags keys usually use the \"name\" attribute but open graph uses \"property\"\n // Consider them separately in case a meta tag uses both\n // e.g. <meta property=\"og:title\" name=\"author\" content=\"Johnny Complex\" >\n const property = getAttributeValue(child, \"property\");\n if (property) {\n metaTags.set(property, content);\n }\n\n const name = getAttributeValue(child, \"name\");\n if (name && name !== property) {\n metaTags.set(name, content);\n }\n } else if (child.tagName === \"title\" && \"value\" in child.childNodes[0]) {\n metaTags.set(TITLE_TAG_KEY, child.childNodes[0].value);\n } else if (\n child.tagName === \"link\" &&\n hasRelIntersect(child, [\"canonical\"])\n ) {\n const canonicalUrl = getAttributeValue(child, \"href\");\n if (canonicalUrl) {\n metaTags.set(CANONICAL_URL_KEY, canonicalUrl);\n }\n }\n }\n return metaTags;\n};\n\n/**\n * Collect meta content into a microformat object\n * @param metaTags Previously parsed meta tag collection\n * @param options Library parsing options\n */\nconst combineRoot = (\n metaTags: MetaContentCollection,\n options: ParsingOptions,\n): MicroformatRoot[] => {\n const item: MicroformatRoot = { properties: {} };\n\n if (isEnabled(options, \"lang\") && options.inherited.lang) {\n item.lang = options.inherited.lang;\n }\n\n /**\n * Define property on microformat root if values are found\n * @param property Key of microformats property\n * @param value Array of values for the property. Empty and undefined values are not added.\n */\n const setMicroformatProp = (\n property: string,\n value: MetaTagContent[] = [],\n ) => {\n const filteredValue = value.filter(Boolean);\n if (filteredValue.length) {\n item.properties[property] =