import { z } from 'zod'; /** * xml2js text content helper: handles the common pattern where XML elements * can be plain strings OR objects with `_` (text) plus attributes. * e.g. `text` → `{_: "text", type: "html"}` */ const xmlText = z.union([z.string(), z.object({ _: z.string().optional() }).catchall(z.unknown())]); const xmlTextOptional = z .union([z.string(), z.object({ _: z.string().optional() }).catchall(z.unknown()), z.null()]) .optional(); // OPML outline type for recursive schema annotation export interface OpmlOutline { text: string; title?: string; type?: string; xmlUrl?: string; htmlUrl?: string; description?: string; category?: string; outline?: OpmlOutline[]; } // OPML Schema validation export const OpmlOutlineSchema: z.ZodType = z.object({ text: z.string().default(''), // Allow empty text, will use title as fallback title: z.string().optional(), type: z.string().optional(), // URL validity (including http(s)-only protocol) is enforced per-outline during import so a // single bad feed is rejected individually instead of aborting the whole OPML parse. xmlUrl: z.string().optional(), htmlUrl: z.string().optional(), description: z.string().optional(), category: z.string().optional(), // Nested outlines (for categories) outline: z.array(z.lazy(() => OpmlOutlineSchema)).optional(), }); export const OpmlBodySchema = z.object({ outline: z.array(OpmlOutlineSchema), }); export const OpmlHeadSchema = z.object({ title: z.string().optional(), dateCreated: z.string().optional(), dateModified: z.string().optional(), ownerName: z.string().optional(), // Optional head metadata is best-effort: invalid values degrade to undefined rather than // aborting the entire import of an otherwise-valid OPML file. ownerEmail: z.string().email().optional().catch(undefined), ownerId: z.string().url().optional().catch(undefined), docs: z.string().url().optional().catch(undefined), expansionState: z.string().optional(), vertScrollState: z.number().optional(), windowTop: z.number().optional(), windowLeft: z.number().optional(), windowBottom: z.number().optional(), windowRight: z.number().optional(), }); export const OpmlSchema = z.object({ version: z.string().regex(/^(1\.0|1\.1|2\.0)$/, 'OPML version must be 1.0, 1.1, or 2.0'), head: OpmlHeadSchema.optional(), body: OpmlBodySchema, }); // RSS 2.0 Schema export const RssCategorySchema = z.object({ domain: z.string().optional(), _: z.string().optional(), }); const RssCategoryValueSchema = z.union([z.string(), RssCategorySchema]); export const RssEnclosureSchema = z.object({ url: z.string().optional(), length: z.coerce.number().optional(), type: z.string().optional(), }); export const RssItemSchema = z .object({ title: xmlTextOptional, link: z .union([ z.string(), z.object({ _: z.string() }).catchall(z.unknown()), z.array(z.union([z.string(), z.object({ _: z.string() }).catchall(z.unknown())])), ]) .optional(), description: xmlTextOptional, author: z .union([ z.string(), z.array(z.string()), z.object({ _: z.string().optional() }).catchall(z.unknown()), z.array(z.object({ _: z.string().optional() }).catchall(z.unknown())), z.null(), ]) .optional(), category: z.union([RssCategoryValueSchema, z.array(RssCategoryValueSchema)]).optional(), comments: z.string().optional(), enclosure: z.union([RssEnclosureSchema, z.array(RssEnclosureSchema)]).optional(), guid: z .union([ z.string(), z .object({ _: z.string(), // RSS encodes booleans as the literal strings "true"/"false"; z.coerce.boolean() // would turn "false" into true, so parse the strings explicitly. Also accept a real // boolean for programmatic callers. isPermaLink: z .union([z.enum(['true', 'false']), z.boolean()]) .transform((v) => v === true || v === 'true') .optional(), }) .catchall(z.unknown()), ]) .optional(), // Date validation happens in the parser via parseDate() pubDate: xmlTextOptional, source: z .union([ z.string(), z .object({ url: z.string().optional(), _: z.string(), }) .catchall(z.unknown()), ]) .optional(), content: z.union([z.string(), z.object({ _: z.string() }).catchall(z.unknown())]).optional(), encoded: xmlTextOptional, 'content:encoded': xmlTextOptional, creator: xmlTextOptional, 'dc:creator': xmlTextOptional, date: z.string().optional(), 'dc:date': xmlTextOptional, }) .catchall(z.unknown()) .refine((item) => item.title || item.description, { message: 'RSS item must have at least a title or description', }); export const RssImageSchema = z.object({ url: z.string().optional(), title: z.string().optional(), link: z.string().optional(), width: z.coerce.number().optional(), height: z.coerce.number().optional(), }); export const RssTextInputSchema = z.object({ title: z.string(), description: z.string(), name: z.string(), link: z.string().optional(), }); export const RssChannelSchema = z.object({ title: xmlText, // link kept optional: many real-world feeds omit it despite RSS 2.0 spec requiring it link: z.union([z.string(), z.array(z.string()), z.object({ _: z.string() }).catchall(z.unknown())]).optional(), description: z.union([xmlText, z.null()]).optional(), language: z.string().optional(), copyright: z.string().optional(), managingEditor: z.string().optional(), webMaster: z.string().optional(), pubDate: z.string().optional(), lastBuildDate: z.string().optional(), category: z.union([RssCategoryValueSchema, z.array(RssCategoryValueSchema)]).optional(), generator: z.string().optional(), docs: z.string().optional(), cloud: z .union([ z.object({ domain: z.string().optional(), port: z.coerce.number().optional(), path: z.string().optional(), registerProcedure: z.string().optional(), protocol: z.string().optional(), }), ]) .optional(), ttl: z.coerce.number().optional(), image: z.union([RssImageSchema, z.string().optional()]).optional(), textInput: RssTextInputSchema.optional(), skipHours: z.array(z.coerce.number()).optional(), skipDays: z.array(z.string()).optional(), item: z.union([RssItemSchema, z.array(RssItemSchema)]).optional(), link_atom: z.unknown().optional(), }); export const RssSchema = z.object({ version: z.string().optional(), channel: RssChannelSchema, }); // Atom Schema export const AtomLinkSchema = z.object({ href: z.string().optional(), // Made optional for leniency rel: z.string().optional(), type: z.string().optional(), hreflang: z.string().optional(), title: z.string().optional(), length: z.coerce.number().optional(), // `mergeAttrs` exposes this directly. Keep it so the parser can apply Atom's // inherited base-URI rules before handing links to persistence/deduplication. 'xml:base': z.string().optional(), }); export const AtomPersonSchema = z.object({ name: z.string(), uri: z.string().optional(), // Relaxed URL validation email: z.string().optional(), // Relaxed email validation }); export const AtomCategorySchema = z.object({ term: z.string().optional(), scheme: z.string().optional(), label: z.string().optional(), }); export const AtomContentSchema = z .object({ type: z.string().optional(), src: z.string().optional(), // Relaxed URL validation _: z.string().optional(), // type="xhtml" content surfaces as a nested
node (e.g. {div: {...}}); keep it. div: z.unknown().optional(), }) .catchall(z.unknown()); export const AtomEntrySchema = z .object({ id: xmlTextOptional, title: z.union([xmlText, z.null()]).optional(), updated: z.string().optional(), author: z.union([z.string(), AtomPersonSchema, z.array(z.union([z.string(), AtomPersonSchema]))]).optional(), content: z.union([z.string(), AtomContentSchema]).optional(), summary: z.union([xmlText, z.array(xmlText)]).optional(), category: z.union([z.string(), AtomCategorySchema, z.array(z.union([z.string(), AtomCategorySchema]))]).optional(), contributor: z.union([AtomPersonSchema, z.array(AtomPersonSchema)]).optional(), link: z.union([z.string(), AtomLinkSchema, z.array(z.union([z.string(), AtomLinkSchema]))]).optional(), published: z.string().optional(), rights: z.union([z.string(), z.object({ _: z.string() }).catchall(z.unknown())]).optional(), source: z.unknown().optional(), // Source varies widely, keep permissive 'xml:base': z.string().optional(), }) .catchall(z.unknown()); export const AtomFeedSchema = z.object({ id: xmlTextOptional, title: z.union([xmlText, z.null()]).optional(), updated: z.string().optional(), author: z.union([z.string(), AtomPersonSchema, z.array(z.union([z.string(), AtomPersonSchema]))]).optional(), category: z.union([AtomCategorySchema, z.array(AtomCategorySchema)]).optional(), contributor: z.union([AtomPersonSchema, z.array(AtomPersonSchema)]).optional(), generator: z .union([ z.string(), z.object({ uri: z.string().optional(), version: z.string().optional(), _: z.string(), }), ]) .optional(), icon: z.string().optional(), // Relaxed URL validation logo: z.string().optional(), // Relaxed URL validation // href kept optional: real-world feeds often omit it link: z.union([AtomLinkSchema, z.array(AtomLinkSchema)]).optional(), rights: z.union([z.string(), z.object({ _: z.string() }).catchall(z.unknown())]).optional(), subtitle: xmlTextOptional, language: z.string().optional(), // Atom carries language as the xml:lang attribute; mergeAttrs surfaces it under this key. 'xml:lang': z.string().optional(), 'xml:base': z.string().optional(), entry: z.union([AtomEntrySchema, z.array(AtomEntrySchema)]).optional(), }); // Export types (OpmlOutline is defined above as an interface for recursive schema annotation) export type Opml = z.infer; export type RssFeed = z.infer; export type RssChannel = z.infer; export type RssItem = z.infer; export type AtomFeed = z.infer; export type AtomEntry = z.infer;