mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat: add ESLint, lint-staged, and update pre-commit hook + CI
- Add ESLint 9 flat config (eslint.config.mjs) with typescript-eslint recommended rules and eslint-config-prettier - Add lint-staged to run eslint+prettier only on staged files - Update pre-commit hook to use lint-staged instead of full prettier check - Add `lint` and `format:check` scripts to package.json - Add Lint step to CI workflow - Fix resulting lint errors: unused vars (_ctx, _options, catch binding), any→unknown in type declarations, stale eslint-disable comments Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,10 @@ jobs:
|
|||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|
||||||
- name: Format check
|
- name: Format check
|
||||||
run: npx prettier --check '**/*.{js,ts,css,json,md}'
|
run: npm run format:check
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged && npm run typecheck && npm test
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import prettier from "eslint-config-prettier";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist/", "coverage/"] },
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["src/**/*.test.ts", "src/test/**/*.ts"],
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
Generated
+1708
-6
File diff suppressed because it is too large
Load Diff
+17
-1
@@ -6,12 +6,24 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "wrangler deploy --dry-run --outdir=dist",
|
"build": "wrangler deploy --dry-run --outdir=dist",
|
||||||
"format": "prettier --write '**/*.{js,ts,css,json,md}'",
|
"format": "prettier --write '**/*.{js,ts,css,json,md}'",
|
||||||
|
"format:check": "prettier --check '**/*.{js,ts,css,json,md}'",
|
||||||
|
"lint": "eslint src",
|
||||||
"dev": "wrangler dev",
|
"dev": "wrangler dev",
|
||||||
"deploy": "wrangler deploy --env production",
|
"deploy": "wrangler deploy --env production",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"prepare": "husky"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{ts,js}": [
|
||||||
|
"eslint --fix",
|
||||||
|
"prettier --write"
|
||||||
|
],
|
||||||
|
"*.{json,md,css}": [
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -20,10 +32,14 @@
|
|||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/rss": "^0.0.32",
|
"@types/rss": "^0.0.32",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"happy-dom": "^20.5.0",
|
"happy-dom": "^20.5.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^17.0.5",
|
||||||
"msw": "^2.12.8",
|
"msw": "^2.12.8",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.59.4",
|
||||||
"vitest": "^4.0.18",
|
"vitest": "^4.0.18",
|
||||||
"wrangler": "^4.63.0"
|
"wrangler": "^4.63.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { processEmail, RawAttachment } from "./email-processor";
|
|||||||
export async function handleCloudflareEmail(
|
export async function handleCloudflareEmail(
|
||||||
message: ForwardableEmailMessage,
|
message: ForwardableEmailMessage,
|
||||||
env: Env,
|
env: Env,
|
||||||
ctx: ExecutionContext,
|
_ctx: ExecutionContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const email = await PostalMime.parse(message.raw);
|
const email = await PostalMime.parse(message.raw);
|
||||||
|
|||||||
+26
-8
@@ -71,9 +71,11 @@ function timingSafeEqual(a: string, b: string): boolean {
|
|||||||
const aBytes = enc.encode(a);
|
const aBytes = enc.encode(a);
|
||||||
const bBytes = enc.encode(b);
|
const bBytes = enc.encode(b);
|
||||||
// Try native timing-safe implementation first (Cloudflare Workers runtime)
|
// Try native timing-safe implementation first (Cloudflare Workers runtime)
|
||||||
if (typeof (crypto.subtle as any).timingSafeEqual === "function") {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const subtle = crypto.subtle as any;
|
||||||
|
if (typeof subtle.timingSafeEqual === "function") {
|
||||||
if (aBytes.length !== bBytes.length) return false;
|
if (aBytes.length !== bBytes.length) return false;
|
||||||
return (crypto.subtle as any).timingSafeEqual(aBytes, bBytes);
|
return subtle.timingSafeEqual(aBytes, bBytes);
|
||||||
}
|
}
|
||||||
// Constant-time fallback for Node (test environment): encode length
|
// Constant-time fallback for Node (test environment): encode length
|
||||||
// mismatch into `diff` so the loop always runs over the full length.
|
// mismatch into `diff` so the loop always runs over the full length.
|
||||||
@@ -97,7 +99,9 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
|
|||||||
|
|
||||||
// Proxy auth: only active when both env vars are present
|
// Proxy auth: only active when both env vars are present
|
||||||
if (env.PROXY_AUTH_SECRET && env.PROXY_TRUSTED_IPS) {
|
if (env.PROXY_AUTH_SECRET && env.PROXY_TRUSTED_IPS) {
|
||||||
const trustedIps = env.PROXY_TRUSTED_IPS.split(",").map((s) => s.trim()).filter(Boolean);
|
const trustedIps = env.PROXY_TRUSTED_IPS.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
const clientIp = c.req.header("CF-Connecting-IP") ?? "";
|
const clientIp = c.req.header("CF-Connecting-IP") ?? "";
|
||||||
const providedSecret = c.req.header("X-Auth-Proxy-Secret") ?? "";
|
const providedSecret = c.req.header("X-Auth-Proxy-Secret") ?? "";
|
||||||
const remoteUser =
|
const remoteUser =
|
||||||
@@ -113,7 +117,11 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: signed cookie
|
// Fallback: signed cookie
|
||||||
const authCookie = await getSignedCookie(c, env.ADMIN_PASSWORD, ADMIN_COOKIE_NAME);
|
const authCookie = await getSignedCookie(
|
||||||
|
c,
|
||||||
|
env.ADMIN_PASSWORD,
|
||||||
|
ADMIN_COOKIE_NAME,
|
||||||
|
);
|
||||||
if (authCookie !== "1") {
|
if (authCookie !== "1") {
|
||||||
return c.redirect("/admin/login");
|
return c.redirect("/admin/login");
|
||||||
}
|
}
|
||||||
@@ -146,6 +154,7 @@ const authSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Base HTML layout with design system
|
// Base HTML layout with design system
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const layout = (title: string, content: any) => {
|
const layout = (title: string, content: any) => {
|
||||||
return html`<!DOCTYPE html>
|
return html`<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -1610,11 +1619,14 @@ app.post("/feeds/create", async (c) => {
|
|||||||
if (isJson) {
|
if (isJson) {
|
||||||
const body = await c.req.json<Record<string, unknown>>();
|
const body = await c.req.json<Record<string, unknown>>();
|
||||||
title = String(body.title ?? "");
|
title = String(body.title ?? "");
|
||||||
description = body.description != null ? String(body.description) : undefined;
|
description =
|
||||||
|
body.description != null ? String(body.description) : undefined;
|
||||||
language = String(body.language ?? "en");
|
language = String(body.language ?? "en");
|
||||||
view = "list";
|
view = "list";
|
||||||
allowedSenders = Array.isArray(body.allowedSenders)
|
allowedSenders = Array.isArray(body.allowedSenders)
|
||||||
? normalizeAllowedSenders((body.allowedSenders as unknown[]).map(String))
|
? normalizeAllowedSenders(
|
||||||
|
(body.allowedSenders as unknown[]).map(String),
|
||||||
|
)
|
||||||
: [];
|
: [];
|
||||||
} else {
|
} else {
|
||||||
const formData = await c.req.formData();
|
const formData = await c.req.formData();
|
||||||
@@ -1930,7 +1942,8 @@ async function purgeFeedKeysStep(
|
|||||||
});
|
});
|
||||||
if (emailKeys.length > 0) {
|
if (emailKeys.length > 0) {
|
||||||
const emailDataResults = await Promise.allSettled(
|
const emailDataResults = await Promise.allSettled(
|
||||||
emailKeys.map((k) =>
|
emailKeys.map(
|
||||||
|
(k) =>
|
||||||
emailStorage.get(k, { type: "json" }) as Promise<EmailData | null>,
|
emailStorage.get(k, { type: "json" }) as Promise<EmailData | null>,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1976,7 +1989,12 @@ app.post("/feeds/:feedId/delete", async (c) => {
|
|||||||
|
|
||||||
// Best-effort cleanup in the background so the request stays fast.
|
// Best-effort cleanup in the background so the request stays fast.
|
||||||
// Use the UI purge endpoint for full, user-visible progress.
|
// Use the UI purge endpoint for full, user-visible progress.
|
||||||
waitUntilSafe(c, purgeFeedKeysStep(emailStorage, feedId, { bucket: env.ATTACHMENT_BUCKET }));
|
waitUntilSafe(
|
||||||
|
c,
|
||||||
|
purgeFeedKeysStep(emailStorage, feedId, {
|
||||||
|
bucket: env.ATTACHMENT_BUCKET,
|
||||||
|
}),
|
||||||
|
);
|
||||||
if (wantsJson) {
|
if (wantsJson) {
|
||||||
return c.json({ ok: true, feedId });
|
return c.json({ ok: true, feedId });
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-10
@@ -71,7 +71,7 @@ class MockCache implements Cache {
|
|||||||
|
|
||||||
async match(
|
async match(
|
||||||
request: RequestInfo,
|
request: RequestInfo,
|
||||||
options?: CacheQueryOptions,
|
_options?: CacheQueryOptions,
|
||||||
): Promise<Response | undefined> {
|
): Promise<Response | undefined> {
|
||||||
const key = request instanceof Request ? request.url : request;
|
const key = request instanceof Request ? request.url : request;
|
||||||
const response = this.store.get(key);
|
const response = this.store.get(key);
|
||||||
@@ -80,7 +80,7 @@ class MockCache implements Cache {
|
|||||||
|
|
||||||
async delete(
|
async delete(
|
||||||
request: RequestInfo,
|
request: RequestInfo,
|
||||||
options?: CacheQueryOptions,
|
_options?: CacheQueryOptions,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const key = request instanceof Request ? request.url : request;
|
const key = request instanceof Request ? request.url : request;
|
||||||
return this.store.delete(key);
|
return this.store.delete(key);
|
||||||
@@ -107,7 +107,6 @@ beforeAll(() => {
|
|||||||
server.listen({ onUnhandledRequest: "error" });
|
server.listen({ onUnhandledRequest: "error" });
|
||||||
|
|
||||||
// Type-safe access to Node's global object for setting Workers-like globals in tests
|
// Type-safe access to Node's global object for setting Workers-like globals in tests
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const g = globalThis as any;
|
const g = globalThis as any;
|
||||||
|
|
||||||
// Mock Cloudflare Workers runtime globals
|
// Mock Cloudflare Workers runtime globals
|
||||||
@@ -118,31 +117,26 @@ beforeAll(() => {
|
|||||||
|
|
||||||
// Mock crypto for generating random values
|
// Mock crypto for generating random values
|
||||||
if (!g.crypto) {
|
if (!g.crypto) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
g.crypto = require("crypto").webcrypto;
|
g.crypto = require("crypto").webcrypto;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure other required globals are available
|
// Ensure other required globals are available
|
||||||
if (!g.FormData) {
|
if (!g.FormData) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
const { FormData } = require("undici");
|
const { FormData } = require("undici");
|
||||||
g.FormData = FormData;
|
g.FormData = FormData;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!g.Headers) {
|
if (!g.Headers) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
const { Headers } = require("undici");
|
const { Headers } = require("undici");
|
||||||
g.Headers = Headers;
|
g.Headers = Headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!g.Request) {
|
if (!g.Request) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
const { Request } = require("undici");
|
const { Request } = require("undici");
|
||||||
g.Request = Request;
|
g.Request = Request;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!g.Response) {
|
if (!g.Response) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
const { Response } = require("undici");
|
const { Response } = require("undici");
|
||||||
g.Response = Response;
|
g.Response = Response;
|
||||||
}
|
}
|
||||||
@@ -173,7 +167,13 @@ export class MockR2 {
|
|||||||
|
|
||||||
async put(
|
async put(
|
||||||
key: string,
|
key: string,
|
||||||
value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob,
|
value:
|
||||||
|
| ReadableStream
|
||||||
|
| ArrayBuffer
|
||||||
|
| ArrayBufferView
|
||||||
|
| string
|
||||||
|
| null
|
||||||
|
| Blob,
|
||||||
options?: {
|
options?: {
|
||||||
httpMetadata?: { contentType?: string; contentDisposition?: string };
|
httpMetadata?: { contentType?: string; contentDisposition?: string };
|
||||||
},
|
},
|
||||||
@@ -216,7 +216,10 @@ export class MockR2 {
|
|||||||
headers.set("Content-Type", entry.httpMetadata.contentType);
|
headers.set("Content-Type", entry.httpMetadata.contentType);
|
||||||
}
|
}
|
||||||
if (entry.httpMetadata?.contentDisposition) {
|
if (entry.httpMetadata?.contentDisposition) {
|
||||||
headers.set("Content-Disposition", entry.httpMetadata.contentDisposition);
|
headers.set(
|
||||||
|
"Content-Disposition",
|
||||||
|
entry.httpMetadata.contentDisposition,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -71,7 +71,7 @@ declare global {
|
|||||||
// This is not an ideal solution but works for our example
|
// This is not an ideal solution but works for our example
|
||||||
interface KVNamespace {
|
interface KVNamespace {
|
||||||
get(key: string, options?: { type: "text" }): Promise<string | null>;
|
get(key: string, options?: { type: "text" }): Promise<string | null>;
|
||||||
get(key: string, options: { type: "json" }): Promise<any | null>;
|
get(key: string, options: { type: "json" }): Promise<unknown>;
|
||||||
get(
|
get(
|
||||||
key: string,
|
key: string,
|
||||||
options: { type: "arrayBuffer" },
|
options: { type: "arrayBuffer" },
|
||||||
|
|||||||
Vendored
+2
-2
@@ -15,7 +15,7 @@ declare module "rss" {
|
|||||||
generator?: string;
|
generator?: string;
|
||||||
categories?: string[];
|
categories?: string[];
|
||||||
custom_namespaces?: Record<string, string>;
|
custom_namespaces?: Record<string, string>;
|
||||||
custom_elements?: any[];
|
custom_elements?: unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RSSItemOptions {
|
interface RSSItemOptions {
|
||||||
@@ -34,7 +34,7 @@ declare module "rss" {
|
|||||||
size?: number;
|
size?: number;
|
||||||
type?: string;
|
type?: string;
|
||||||
};
|
};
|
||||||
custom_elements?: any[];
|
custom_elements?: unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RSSXMLOptions {
|
interface RSSXMLOptions {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export class EmailParser {
|
|||||||
return match ? match[1] : null;
|
return match ? match[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
static parseForwardEmailPayload(payload: any): EmailData {
|
static parseForwardEmailPayload(payload: any): EmailData {
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
throw new Error("Missing or invalid webhook payload");
|
throw new Error("Missing or invalid webhook payload");
|
||||||
@@ -30,6 +31,7 @@ export class EmailParser {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
private static extractHeaders(payload: any): Record<string, string> {
|
private static extractHeaders(payload: any): Record<string, string> {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ export class EmailParser {
|
|||||||
if (encoding.toUpperCase() === "B") {
|
if (encoding.toUpperCase() === "B") {
|
||||||
try {
|
try {
|
||||||
return atob(text);
|
return atob(text);
|
||||||
} catch (e) {
|
} catch {
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
} else if (encoding.toUpperCase() === "Q") {
|
} else if (encoding.toUpperCase() === "Q") {
|
||||||
|
|||||||
Reference in New Issue
Block a user