CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
atom-feed.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 26484eb | 1 | /** |
| 2 | * Block J19 — Atom feed renderer. | |
| 3 | * | |
| 4 | * Produces a valid Atom 1.0 XML document from a typed description of a | |
| 5 | * feed. Zero-IO, zero dependencies — routes fetch commits/releases/issues | |
| 6 | * from Drizzle and git, shape them into `AtomEntry` rows, then call | |
| 7 | * `renderAtomFeed` to produce the response body. | |
| 8 | * | |
| 9 | * We deliberately avoid pulling in a full XML library: Atom is a small | |
| 10 | * enough format that a careful `escapeXml` + string template is sufficient | |
| 11 | * and leaves no surface for supply-chain surprises. | |
| 12 | */ | |
| 13 | ||
| 14 | export interface AtomAuthor { | |
| 15 | name: string; | |
| 16 | email?: string; | |
| 17 | } | |
| 18 | ||
| 19 | export interface AtomEntry { | |
| 20 | /** Stable globally-unique ID (e.g. `tag:host,2026:repo/owner/name/commit/<sha>`). */ | |
| 21 | id: string; | |
| 22 | title: string; | |
| 23 | /** Canonical permalink for the entry (becomes `<link rel="alternate">`). */ | |
| 24 | href: string; | |
| 25 | /** ISO-8601 UTC timestamp. Used for both `<updated>` and `<published>`. */ | |
| 26 | updatedAt: string; | |
| 27 | /** Short plaintext summary. Rendered inside `<summary type="text">`. */ | |
| 28 | summary?: string; | |
| 29 | /** Optional long-form content. Rendered inside `<content type="html">`. */ | |
| 30 | contentHtml?: string; | |
| 31 | author?: AtomAuthor; | |
| 32 | } | |
| 33 | ||
| 34 | export interface AtomFeedInput { | |
| 35 | /** Feed-wide unique ID. */ | |
| 36 | id: string; | |
| 37 | title: string; | |
| 38 | subtitle?: string; | |
| 39 | /** Absolute URL of the feed itself (becomes `<link rel="self">`). */ | |
| 40 | selfHref: string; | |
| 41 | /** Absolute URL of the HTML page the feed represents. */ | |
| 42 | alternateHref?: string; | |
| 43 | /** ISO-8601 UTC. Defaults to the newest entry's `updatedAt`, or "now". */ | |
| 44 | updatedAt?: string; | |
| 45 | entries: AtomEntry[]; | |
| 46 | } | |
| 47 | ||
| 48 | /** | |
| 49 | * Escape the five XML special characters so they render safely inside | |
| 50 | * element text + attribute values. Accepts arbitrary input. | |
| 51 | */ | |
| 52 | export function escapeXml(input: string): string { | |
| 53 | if (!input) return ""; | |
| 54 | return input | |
| 55 | .replace(/&/g, "&") | |
| 56 | .replace(/</g, "<") | |
| 57 | .replace(/>/g, ">") | |
| 58 | .replace(/"/g, """) | |
| 59 | .replace(/'/g, "'"); | |
| 60 | } | |
| 61 | ||
| 62 | /** | |
| 63 | * Coerce an input to a valid ISO-8601 UTC date string. Falls back to the | |
| 64 | * unix epoch if the input can't be parsed — feeds stay valid even with | |
| 65 | * junk inputs. | |
| 66 | */ | |
| 67 | export function toIsoUtc(input: string | Date | null | undefined): string { | |
| 68 | if (input instanceof Date) { | |
| 69 | const t = input.getTime(); | |
| 70 | return Number.isFinite(t) ? new Date(t).toISOString() : "1970-01-01T00:00:00.000Z"; | |
| 71 | } | |
| 72 | if (typeof input === "string" && input) { | |
| 73 | const t = Date.parse(input); | |
| 74 | if (Number.isFinite(t)) return new Date(t).toISOString(); | |
| 75 | } | |
| 76 | return "1970-01-01T00:00:00.000Z"; | |
| 77 | } | |
| 78 | ||
| 79 | function pickFeedUpdated(input: AtomFeedInput): string { | |
| 80 | if (input.updatedAt) return toIsoUtc(input.updatedAt); | |
| 81 | if (input.entries.length > 0) { | |
| 82 | // Pick the newest entry updatedAt. | |
| 83 | let newest = -Infinity; | |
| 84 | for (const e of input.entries) { | |
| 85 | const t = Date.parse(e.updatedAt); | |
| 86 | if (Number.isFinite(t) && t > newest) newest = t; | |
| 87 | } | |
| 88 | if (newest > -Infinity) return new Date(newest).toISOString(); | |
| 89 | } | |
| 90 | return new Date().toISOString(); | |
| 91 | } | |
| 92 | ||
| 93 | function renderAuthor(a: AtomAuthor): string { | |
| 94 | const lines = [` <name>${escapeXml(a.name || "unknown")}</name>`]; | |
| 95 | if (a.email) lines.push(` <email>${escapeXml(a.email)}</email>`); | |
| 96 | return ` <author>\n${lines.join("\n")}\n </author>`; | |
| 97 | } | |
| 98 | ||
| 99 | function renderEntry(e: AtomEntry): string { | |
| 100 | const parts = [ | |
| 101 | " <entry>", | |
| 102 | ` <id>${escapeXml(e.id)}</id>`, | |
| 103 | ` <title>${escapeXml(e.title || "(untitled)")}</title>`, | |
| 104 | ` <link rel="alternate" href="${escapeXml(e.href)}"/>`, | |
| 105 | ` <updated>${toIsoUtc(e.updatedAt)}</updated>`, | |
| 106 | ` <published>${toIsoUtc(e.updatedAt)}</published>`, | |
| 107 | ]; | |
| 108 | if (e.author) { | |
| 109 | parts.push( | |
| 110 | ` <author>`, | |
| 111 | ` <name>${escapeXml(e.author.name || "unknown")}</name>`, | |
| 112 | ...(e.author.email | |
| 113 | ? [` <email>${escapeXml(e.author.email)}</email>`] | |
| 114 | : []), | |
| 115 | ` </author>` | |
| 116 | ); | |
| 117 | } | |
| 118 | if (e.summary) { | |
| 119 | parts.push( | |
| 120 | ` <summary type="text">${escapeXml(e.summary)}</summary>` | |
| 121 | ); | |
| 122 | } | |
| 123 | if (e.contentHtml) { | |
| 124 | parts.push( | |
| 125 | ` <content type="html">${escapeXml(e.contentHtml)}</content>` | |
| 126 | ); | |
| 127 | } | |
| 128 | parts.push(" </entry>"); | |
| 129 | return parts.join("\n"); | |
| 130 | } | |
| 131 | ||
| 132 | /** | |
| 133 | * Render a full Atom 1.0 document. The output is UTF-8, XML-declaration- | |
| 134 | * prefixed, and safe to return directly with | |
| 135 | * `Content-Type: application/atom+xml; charset=utf-8`. | |
| 136 | */ | |
| 137 | export function renderAtomFeed(input: AtomFeedInput): string { | |
| 138 | const updated = pickFeedUpdated(input); | |
| 139 | const lines: string[] = [ | |
| 140 | '<?xml version="1.0" encoding="utf-8"?>', | |
| 141 | '<feed xmlns="http://www.w3.org/2005/Atom">', | |
| 142 | ` <id>${escapeXml(input.id)}</id>`, | |
| 143 | ` <title>${escapeXml(input.title)}</title>`, | |
| 144 | ]; | |
| 145 | if (input.subtitle) { | |
| 146 | lines.push(` <subtitle>${escapeXml(input.subtitle)}</subtitle>`); | |
| 147 | } | |
| 148 | lines.push(` <updated>${updated}</updated>`); | |
| 149 | lines.push(` <link rel="self" href="${escapeXml(input.selfHref)}"/>`); | |
| 150 | if (input.alternateHref) { | |
| 151 | lines.push( | |
| 152 | ` <link rel="alternate" href="${escapeXml(input.alternateHref)}"/>` | |
| 153 | ); | |
| 154 | } | |
| 155 | for (const entry of input.entries) { | |
| 156 | lines.push(renderEntry(entry)); | |
| 157 | } | |
| 158 | lines.push("</feed>"); | |
| 159 | return lines.join("\n") + "\n"; | |
| 160 | } | |
| 161 | ||
| 162 | /** Mime-type header for Atom responses. */ | |
| 163 | export const ATOM_CONTENT_TYPE = "application/atom+xml; charset=utf-8"; | |
| 164 | ||
| 165 | export const __internal = { | |
| 166 | escapeXml, | |
| 167 | toIsoUtc, | |
| 168 | pickFeedUpdated, | |
| 169 | renderAuthor, | |
| 170 | renderEntry, | |
| 171 | renderAtomFeed, | |
| 172 | ATOM_CONTENT_TYPE, | |
| 173 | }; |