Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

atom-feed.tsBlame173 lines · 1 contributor
26484ebClaude1/**
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
14export interface AtomAuthor {
15 name: string;
16 email?: string;
17}
18
19export 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
34export 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 */
52export function escapeXml(input: string): string {
53 if (!input) return "";
54 return input
55 .replace(/&/g, "&amp;")
56 .replace(/</g, "&lt;")
57 .replace(/>/g, "&gt;")
58 .replace(/"/g, "&quot;")
59 .replace(/'/g, "&apos;");
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 */
67export 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
79function 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
93function 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
99function 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 */
137export 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. */
163export const ATOM_CONTENT_TYPE = "application/atom+xml; charset=utf-8";
164
165export const __internal = {
166 escapeXml,
167 toIsoUtc,
168 pickFeedUpdated,
169 renderAuthor,
170 renderEntry,
171 renderAtomFeed,
172 ATOM_CONTENT_TYPE,
173};