= 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;