const std = @import("std"); const Allocator = std.mem.Allocator; const ArrayList = std.ArrayList; const zeit = @import("zeit"); const Release = @import("main.zig").Release; const markdown = @import("markdown.zig"); fn escapeXml(writer: anytype, input: []const u8) !void { var i: usize = 0; var open_spans: u8 = 0; // Track number of open spans while (i < input.len) { const char = input[i]; // Handle ANSI escape sequences if (char == 0x1B and i + 1 < input.len and input[i + 1] == '[') { // Found ANSI escape sequence, convert to HTML i += 2; // Skip ESC and [ const code_start = i; // Find the end of the ANSI sequence while (i < input.len) { const c = input[i]; i += 1; // ANSI sequences end with a letter (A-Z, a-z) if ((c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z')) { // Extract the numeric codes const codes = input[code_start .. i - 1]; try convertAnsiToHtml(writer, codes, c, &open_spans); break; } } continue; } switch (char) { '<' => try writer.writeAll("<"), '>' => try writer.writeAll(">"), '&' => try writer.writeAll("&"), '"' => try writer.writeAll("""), '\'' => try writer.writeAll("'"), // Valid XML characters: #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF] 0x09, 0x0A, 0x0D => try writer.writeByte(char), // Tab, LF, CR else => { if (char >= 0x20 and char <= 0x7F) { // Printable ASCII (excluding already handled special chars) try writer.writeByte(char); } else if (char >= 0x80) { // Extended ASCII (will be handled as UTF-8) try writer.writeByte(char); } else if (char < 0x20) { // Other control characters - replace with space to preserve spacing try writer.writeByte(' '); } else { // else skip completely invalid characters const start = if (i < 10) 0 else i - 10; std.log.warn("invalid character 0x{x} encountered, skipping. Previous {} chars: {s}", .{ char, i - start, input[start..i] }); } }, } i += 1; } // Close any remaining open spans while (open_spans > 0) { try writer.writeAll(""); open_spans -= 1; } } fn convertAnsiToHtml(writer: anytype, codes: []const u8, end_char: u8, open_spans: *u8) !void { // Only handle SGR (Select Graphic Rendition) sequences that end with 'm' if (end_char != 'm') { return; // Skip non-color sequences } // Parse semicolon-separated codes var code_iter = std.mem.splitScalar(u8, codes, ';'); var has_styles = false; // Use a fixed buffer for styles to avoid allocation var styles_buf: [256]u8 = undefined; var styles_len: usize = 0; while (code_iter.next()) |code_str| { const code = std.fmt.parseInt(u8, std.mem.trim(u8, code_str, " "), 10) catch continue; switch (code) { 0 => { // Reset - close all open spans while (open_spans.* > 0) { try writer.writeAll(""); open_spans.* -= 1; } return; }, 1 => { // Bold const style = if (has_styles) ";font-weight:bold" else "font-weight:bold"; if (styles_len + style.len < styles_buf.len) { @memcpy(styles_buf[styles_len .. styles_len + style.len], style); styles_len += style.len; has_styles = true; } }, 22 => { // Normal intensity (turn off bold) - close current span and open new one without bold if (open_spans.* > 0) { try writer.writeAll(""); open_spans.* -= 1; } // Don't add font-weight:normal as a new style, just close the bold span return; }, 30 => try appendColorToBuffer(&styles_buf, &styles_len, &has_styles, "color:#000000"), // Black 31 => try appendColorToBuffer(&styles_buf, &styles_len, &has_styles, "color:#800000"), // Red 32 => try appendColorToBuffer(&styles_buf, &styles_len, &has_styles, "color:#008000"), // Green 33 => try appendColorToBuffer(&styles_buf, &styles_len, &has_styles, "color:#808000"), // Yellow 34 => try appendColorToBuffer(&styles_buf, &styles_len, &has_styles, "color:#000080"), // Blue 35 => try appendColorToBuffer(&styles_buf, &styles_len, &has_styles, "color:#800080"), // Magenta 36 => try appendColorToBuffer(&styles_buf, &styles_len, &has_styles, "color:#008080"), // Cyan 37 => try appendColorToBuffer(&styles_buf, &styles_len, &has_styles, "color:#c0c0c0"), // White 39 => try appendColorToBuffer(&styles_buf, &styles_len, &has_styles, "color:inherit"), // Default foreground 90 => try appendColorToBuffer(&styles_buf, &styles_len, &has_styles, "color:#808080"), // Bright Black (Gray) 91 => try appendColorToBuffer(&styles_buf, &styles_len, &has_styles, "color:#ff0000"), // Bright Red 92 => try appendColorToBuffer(&styles_buf, &styles_len, &has_styles, "color:#00ff00"), // Bright Green 93 => try appendColorToBuffer(&styles_buf, &styles_len, &has_styles, "color:#ffff00"), // Bright Yellow 94 => try appendColorToBuffer(&styles_buf, &styles_len, &has_styles, "color:#0000ff"), // Bright Blue 95 => try appendColorToBuffer(&styles_buf, &styles_len, &has_styles, "color:#ff00ff"), // Bright Magenta 96 => try appendColorToBuffer(&styles_buf, &styles_len, &has_styles, "color:#00ffff"), // Bright Cyan 97 => try appendColorToBuffer(&styles_buf, &styles_len, &has_styles, "color:#ffffff"), // Bright White else => {}, // Ignore unknown codes } } if (has_styles) { try writer.writeAll(""); open_spans.* += 1; } } fn appendColorToBuffer(styles_buf: *[256]u8, styles_len: *usize, has_styles: *bool, color: []const u8) !void { const prefix = if (has_styles.*) ";" else ""; const total_len = prefix.len + color.len; if (styles_len.* + total_len < styles_buf.len) { if (prefix.len > 0) { @memcpy(styles_buf[styles_len.* .. styles_len.* + prefix.len], prefix); styles_len.* += prefix.len; } @memcpy(styles_buf[styles_len.* .. styles_len.* + color.len], color); styles_len.* += color.len; has_styles.* = true; } } pub fn generateFeed(allocator: Allocator, releases: []const Release) ![]u8 { var buffer = ArrayList(u8).init(allocator); defer buffer.deinit(); const writer = buffer.writer(); // Atom header try writer.writeAll( \\ \\ \\Repository Releases \\New releases from starred repositories \\ \\ \\https://releases.lerch.org \\ ); // Add current timestamp in proper ISO 8601 format using zeit const now = zeit.instant(.{}) catch zeit.instant(.{ .source = .now }) catch @panic("Failed to get current time"); const time = now.time(); var buf: [64]u8 = undefined; const updated_str = try time.bufPrint(&buf, .rfc3339); try writer.print("{s}\n", .{updated_str}); // Add entries for (releases) |release| { try writer.writeAll("\n"); try writer.writeAll(" "); try escapeXml(writer, release.repo_name); try writer.writeAll(" - "); try escapeXml(writer, release.tag_name); try writer.writeAll("\n"); try writer.writeAll(" \n"); try writer.writeAll(" "); try escapeXml(writer, release.html_url); try writer.writeAll("\n"); try writer.writeAll(" "); const published = zeit.Instant{ .timestamp = release.published_at * std.time.ns_per_s, .timezone = &zeit.utc, }; // try escapeXml(writer, release.published_at); // try std.testing.expect(std.mem.indexOf(u8, atom_content, "2024-01-01T00:00:00Z") != null); try published.time().strftime(writer, "%Y-%m-%dT%H:%M:%SZ"); try writer.writeAll("\n"); try writer.writeAll(" "); try escapeXml(writer, release.provider); try writer.writeAll("\n"); // Convert markdown to HTML const conversion_result = try markdown.convertMarkdownToHtml(allocator, release.description); defer conversion_result.deinit(allocator); // Add content with proper type attribute and XML-escaped HTML try writer.writeAll(" "); try escapeXml(writer, conversion_result.html); try writer.writeAll("\n"); // Add fallback metadata if markdown conversion used fallback if (conversion_result.has_fallback) { try writer.writeAll(" \n"); } try writer.writeAll(" \n"); try writer.writeAll("\n"); } try writer.writeAll("\n"); return buffer.toOwnedSlice(); } test "XML escaping with ANSI sequences" { const allocator = std.testing.allocator; var buffer = ArrayList(u8).init(allocator); defer buffer.deinit(); // Test input with ANSI color codes like those found in terminal output const input = "Test \x1B[36mcolored\x1B[0m text and \x1B[1mbold\x1B[22m formatting"; try escapeXml(buffer.writer(), input); const result = try buffer.toOwnedSlice(); defer allocator.free(result); // ANSI sequences should be converted to HTML spans try std.testing.expect(std.mem.indexOf(u8, result, "") != null); try std.testing.expect(std.mem.indexOf(u8, result, "") != null); try std.testing.expect(std.mem.indexOf(u8, result, "colored") != null); } test "XML escaping" { const allocator = std.testing.allocator; var buffer = ArrayList(u8).init(allocator); defer buffer.deinit(); const input = "Test & \"quotes\" & 'apostrophes'"; try escapeXml(buffer.writer(), input); const result = try buffer.toOwnedSlice(); defer allocator.free(result); const expected = "Test <tag> & "quotes" & 'apostrophes'"; try std.testing.expectEqualStrings(expected, result); } test "Atom feed generation with markdown" { const allocator = std.testing.allocator; const releases = [_]Release{ Release{ .repo_name = "test/repo", .tag_name = "v1.0.0", .published_at = @intCast(@divTrunc( (try zeit.instant(.{ .source = .{ .iso8601 = "2024-01-01T00:00:00Z" } })).timestamp, std.time.ns_per_s, )), .html_url = "https://github.com/test/repo/releases/tag/v1.0.0", .description = "## What's Changed\n* Fixed bug\n* Added feature", .provider = "github", }, }; const atom_content = try generateFeed(allocator, &releases); defer allocator.free(atom_content); try std.testing.expect(std.mem.indexOf(u8, atom_content, "test/repo") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "v1.0.0") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "<h2>What&apos;s Changed</h2>") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "<ul>") != null); } test "Atom feed with fenced code blocks" { const allocator = std.testing.allocator; const releases = [_]Release{ Release{ .repo_name = "test/repo", .tag_name = "v1.0.0", .published_at = @intCast(@divTrunc( (try zeit.instant(.{ .source = .{ .iso8601 = "2024-01-01T00:00:00Z" } })).timestamp, std.time.ns_per_s, )), .html_url = "https://github.com/test/repo/releases/tag/v1.0.0", .description = "Here's some code:\n```javascript\nconst greeting = 'Hello World';\nconsole.log(greeting);\n```\nEnd of example.", .provider = "github", }, }; const atom_content = try generateFeed(allocator, &releases); defer allocator.free(atom_content); // Should NOT contain fallback metadata since fenced code blocks are now supported try std.testing.expect(std.mem.indexOf(u8, atom_content, "markdown-fallback") == null); // Should contain proper HTML code block structure try std.testing.expect(std.mem.indexOf(u8, atom_content, "<pre><code class="language-javascript">") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "</code></pre>") != null); // Should contain the escaped code content try std.testing.expect(std.mem.indexOf(u8, atom_content, "const greeting = &apos;Hello World&apos;;") != null); } test "Atom feed with fallback markdown" { const allocator = std.testing.allocator; const releases = [_]Release{ Release{ .repo_name = "test/repo", .tag_name = "v1.0.0", .published_at = @intCast(@divTrunc( (try zeit.instant(.{ .source = .{ .iso8601 = "2024-01-01T00:00:00Z" } })).timestamp, std.time.ns_per_s, )), .html_url = "https://github.com/test/repo/releases/tag/v1.0.0", .description = "| Column 1 | Column 2 |\n|----------|----------|\n| Value 1 | Value 2 |", .provider = "github", }, }; const atom_content = try generateFeed(allocator, &releases); defer allocator.free(atom_content); // Should contain fallback metadata try std.testing.expect(std.mem.indexOf(u8, atom_content, "markdown-fallback") != null); try std.testing.expect(std.mem.indexOf(u8, atom_content, "<pre>") != null); } test "Atom feed with special characters" { const allocator = std.testing.allocator; const releases = [_]Release{ Release{ .repo_name = "test/repo