mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat(favicon): serve project favicon reusing the header envelope logo
Serve an inline SVG icon at /favicon.svg and /favicon.ico and link it from the shared Layout and the standalone entry view, so the admin UI, status page, and entry pages stop emitting /favicon.ico 404s. Doubles as the fallback for the upcoming per-feed favicon feature. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ Single Cloudflare Worker built with Hono. Routes:
|
||||
| `GET /files/:attachmentId/:filename` | R2 attachment serving |
|
||||
| `GET /admin` | Password-protected admin UI |
|
||||
| `/hub` | WebSub hub (subscribe/publish) |
|
||||
| `GET /favicon.svg`, `/favicon.ico` | Project favicon (envelope logo); fallback for per-feed favicons |
|
||||
| `GET /health` | Health check |
|
||||
| `email` | Cloudflare Email routing handler (alternative to ForwardEmail webhook) |
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Feature gaps identified by comparing with [kill-the-newsletter](https://github.c
|
||||
|
||||
- [x] **JSON API for feed creation** — accept `Content-Type: application/json` on `POST /admin/feeds` and return `{ feedId, email, feedUrl }`. Useful for automation (e.g. Tofu/OpenTofu provisioning).
|
||||
|
||||
- [ ] **Project favicon** — serve a single bundled icon at `/favicon.ico` and add a `<link rel="icon">` in the shared `Layout` so the admin UI, status page, and entry views stop 404-ing. Doubles as the default/fallback icon for the per-feed favicon feature below.
|
||||
- [x] **Project favicon** — serve a single bundled icon at `/favicon.ico` and add a `<link rel="icon">` in the shared `Layout` so the admin UI, status page, and entry views stop 404-ing. Doubles as the default/fallback icon for the per-feed favicon feature below.
|
||||
|
||||
## Medium effort
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { handle as handleEntry } from "./routes/entries";
|
||||
import { handle as handleFiles } from "./routes/files";
|
||||
import { handle as handleStats } from "./routes/stats";
|
||||
import { handle as handleHome } from "./routes/home";
|
||||
import { handle as handleFavicon } from "./routes/favicon";
|
||||
import { hubRouter } from "./routes/hub";
|
||||
import { handleCloudflareEmail } from "./lib/cloudflare-email";
|
||||
import { Env } from "./types";
|
||||
@@ -168,6 +169,10 @@ app.route("/files", files);
|
||||
app.route("/admin", admin);
|
||||
app.route("/hub", hubRouter);
|
||||
|
||||
// Project favicon (also the fallback for the future per-feed favicon)
|
||||
app.get("/favicon.svg", handleFavicon);
|
||||
app.get("/favicon.ico", handleFavicon); // readers/browsers that hardcode .ico
|
||||
|
||||
// Health check endpoint for monitoring
|
||||
app.get("/health", (c) => c.json({ status: "ok", timestamp: Date.now() }));
|
||||
|
||||
|
||||
+29
-6
@@ -3,8 +3,14 @@ import layoutCss from "../../styles/layout.css";
|
||||
import componentsCss from "../../styles/components.css";
|
||||
import utilitiesCss from "../../styles/utilities.css";
|
||||
import { interactiveScripts } from "../../scripts/index";
|
||||
import { FAVICON_PATH } from "../favicon";
|
||||
|
||||
const designSystem = [variablesCss, layoutCss, componentsCss, utilitiesCss].join("\n");
|
||||
const designSystem = [
|
||||
variablesCss,
|
||||
layoutCss,
|
||||
componentsCss,
|
||||
utilitiesCss,
|
||||
].join("\n");
|
||||
|
||||
type LayoutProps = {
|
||||
title: string;
|
||||
@@ -17,6 +23,7 @@ export const Layout = ({ title, label = "admin", children }: LayoutProps) => {
|
||||
<html>
|
||||
<head>
|
||||
<title>{title} — kill-the-news</title>
|
||||
<link rel="icon" type="image/svg+xml" href={FAVICON_PATH} />
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
@@ -32,20 +39,36 @@ export const Layout = ({ title, label = "admin", children }: LayoutProps) => {
|
||||
/>
|
||||
{/* designSystem and interactiveScripts are static trusted strings, not user input */}
|
||||
<style dangerouslySetInnerHTML={{ __html: designSystem }} />
|
||||
<script dangerouslySetInnerHTML={{ __html: interactiveScripts + ";" }} />
|
||||
<script
|
||||
dangerouslySetInnerHTML={{ __html: interactiveScripts + ";" }}
|
||||
/>
|
||||
</head>
|
||||
<body class="page">
|
||||
<header class="site-header">
|
||||
<a href="https://kill-the.news/" class="site-header-logo" target="_blank" rel="noopener">
|
||||
<a
|
||||
href="https://kill-the.news/"
|
||||
class="site-header-logo"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
kill-the-news
|
||||
</a>
|
||||
<span class="site-header-label">{label}</span>
|
||||
</header>
|
||||
{children}
|
||||
<footer class="site-footer">
|
||||
<a href="https://kill-the.news/" target="_blank" rel="noopener">kill-the.news</a>
|
||||
<span class="site-footer-sep" aria-hidden="true">·</span>
|
||||
<a href="https://github.com/sponsors/juherr" target="_blank" rel="noopener" class="site-footer-sponsor">
|
||||
<a href="https://kill-the.news/" target="_blank" rel="noopener">
|
||||
kill-the.news
|
||||
</a>
|
||||
<span class="site-footer-sep" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<a
|
||||
href="https://github.com/sponsors/juherr"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="site-footer-sponsor"
|
||||
>
|
||||
♥ Sponsor
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<title>${emailData.subject}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import worker from "../index";
|
||||
import { createMockEnv } from "../test/setup";
|
||||
import type { Env } from "../types";
|
||||
|
||||
function req(path: string): Request {
|
||||
return new Request(`https://test.getmynews.app${path}`);
|
||||
}
|
||||
|
||||
describe("project favicon", () => {
|
||||
it("serves an SVG favicon at /favicon.svg", async () => {
|
||||
const env = createMockEnv() as unknown as Env;
|
||||
const res = await worker.fetch(req("/favicon.svg"), env);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toMatch(/^image\/svg\+xml/);
|
||||
expect(res.headers.get("Cache-Control")).toContain("max-age");
|
||||
const body = await res.text();
|
||||
expect(body).toContain("<svg");
|
||||
});
|
||||
|
||||
it("serves the same icon at /favicon.ico", async () => {
|
||||
const env = createMockEnv() as unknown as Env;
|
||||
const res = await worker.fetch(req("/favicon.ico"), env);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toMatch(/^image\/svg\+xml/);
|
||||
const body = await res.text();
|
||||
expect(body).toContain("<svg");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Context } from "hono";
|
||||
import { Env } from "../types";
|
||||
|
||||
export const FAVICON_PATH = "/favicon.svg";
|
||||
|
||||
// Project favicon — reuses the header's envelope logo (brand orange #f6821f),
|
||||
// rendered as a white envelope on a rounded orange square for legibility at 16px.
|
||||
export const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<rect width="32" height="32" rx="7" fill="#f6821f"/>
|
||||
<g fill="none" stroke="#ffffff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M7 9h18c1.1 0 2 .9 2 2v10c0 1.1-.9 2-2 2H7c-1.1 0-2-.9-2-2V11c0-1.1.9-2 2-2z"/>
|
||||
<polyline points="27,11 16,18.5 5,11"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
|
||||
export function handle(_c: Context<{ Bindings: Env }>): Response {
|
||||
return new Response(FAVICON_SVG, {
|
||||
headers: {
|
||||
"Content-Type": "image/svg+xml; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user