summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-07 02:00:59 -0800
committerFuwn <[email protected]>2026-02-07 02:00:59 -0800
commitf93bad7da47093a12116ff0f390abb548289b600 (patch)
treee2a9debcca3473af8f293c3215704549e5bde17f /apps
parentstyle: format Go worker with iku (diff)
downloadasa.news-f93bad7da47093a12116ff0f390abb548289b600.tar.xz
asa.news-f93bad7da47093a12116ff0f390abb548289b600.zip
style: lowercase all user-facing strings and add custom eslint rule
Comprehensive sweep of all user-facing text to enforce lowercase convention, including acronyms (api, rest, http, opml, json, totp, mfa, qr, hmac). Added asa-lowercase/lowercase-strings eslint rule that reports uppercase in notify() calls, error messages, jsx text, and checked attributes (placeholder, alt, title).
Diffstat (limited to 'apps')
-rw-r--r--apps/web/app/(marketing)/_components/feature-grid.tsx2
-rw-r--r--apps/web/app/api/account/data/route.ts2
-rw-r--r--apps/web/app/api/account/route.ts4
-rw-r--r--apps/web/app/api/billing/create-checkout-session/route.ts14
-rw-r--r--apps/web/app/api/billing/create-portal-session/route.ts8
-rw-r--r--apps/web/app/api/billing/webhook/route.ts8
-rw-r--r--apps/web/app/api/export/route.ts2
-rw-r--r--apps/web/app/api/share/[token]/route.ts8
-rw-r--r--apps/web/app/api/share/route.ts8
-rw-r--r--apps/web/app/api/v1/entries/[entryIdentifier]/route.ts4
-rw-r--r--apps/web/app/api/v1/entries/route.ts2
-rw-r--r--apps/web/app/api/v1/feeds/route.ts2
-rw-r--r--apps/web/app/api/v1/folders/route.ts2
-rw-r--r--apps/web/app/api/v1/keys/[keyIdentifier]/route.ts4
-rw-r--r--apps/web/app/api/v1/keys/route.ts14
-rw-r--r--apps/web/app/api/v1/profile/route.ts2
-rw-r--r--apps/web/app/api/webhook-config/route.ts16
-rw-r--r--apps/web/app/api/webhook-config/test/route.ts10
-rw-r--r--apps/web/app/layout.tsx2
-rw-r--r--apps/web/app/manifest.ts2
-rw-r--r--apps/web/app/reader/_components/entry-detail-panel.tsx4
-rw-r--r--apps/web/app/reader/settings/_components/account-settings.tsx8
-rw-r--r--apps/web/app/reader/settings/_components/api-settings.tsx50
-rw-r--r--apps/web/app/reader/settings/_components/billing-settings.tsx8
-rw-r--r--apps/web/app/reader/settings/_components/danger-zone-settings.tsx5
-rw-r--r--apps/web/app/reader/settings/_components/import-export-settings.tsx18
-rw-r--r--apps/web/app/reader/settings/_components/security-settings.tsx14
-rw-r--r--apps/web/app/reader/settings/_components/settings-shell.tsx2
-rw-r--r--apps/web/app/reader/shares/_components/shares-content.tsx2
-rw-r--r--apps/web/eslint-rules/lowercase-strings.mjs262
-rw-r--r--apps/web/eslint.config.mjs11
-rw-r--r--apps/web/lib/api-auth.ts10
-rw-r--r--apps/web/lib/opml.ts4
-rw-r--r--apps/web/lib/queries/use-custom-feed-mutations.ts2
-rw-r--r--apps/web/lib/queries/use-custom-feeds.ts2
-rw-r--r--apps/web/lib/queries/use-entry-state-mutations.ts4
-rw-r--r--apps/web/lib/queries/use-folder-mutations.ts4
-rw-r--r--apps/web/lib/queries/use-highlight-mutations.ts2
-rw-r--r--apps/web/lib/queries/use-muted-keyword-mutations.ts2
-rw-r--r--apps/web/lib/queries/use-subscription-mutations.ts2
-rw-r--r--apps/web/lib/queries/use-user-profile.ts2
41 files changed, 402 insertions, 132 deletions
diff --git a/apps/web/app/(marketing)/_components/feature-grid.tsx b/apps/web/app/(marketing)/_components/feature-grid.tsx
index 64fd1a4..607e82f 100644
--- a/apps/web/app/(marketing)/_components/feature-grid.tsx
+++ b/apps/web/app/(marketing)/_components/feature-grid.tsx
@@ -22,7 +22,7 @@ const FEATURES = [
{
title: "import & export",
description:
- "import your feeds from any reader via OPML. pro users can export their full data.",
+ "import your feeds from any reader via opml. pro users can export their full data.",
},
{
title: "real-time updates",
diff --git a/apps/web/app/api/account/data/route.ts b/apps/web/app/api/account/data/route.ts
index dbee725..bec6ab9 100644
--- a/apps/web/app/api/account/data/route.ts
+++ b/apps/web/app/api/account/data/route.ts
@@ -8,7 +8,7 @@ export async function GET() {
} = await supabaseClient.auth.getUser()
if (!user) {
- return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
const [
diff --git a/apps/web/app/api/account/route.ts b/apps/web/app/api/account/route.ts
index 6b1bc2d..35408d7 100644
--- a/apps/web/app/api/account/route.ts
+++ b/apps/web/app/api/account/route.ts
@@ -9,7 +9,7 @@ export async function DELETE() {
} = await supabaseClient.auth.getUser()
if (!user) {
- return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
const adminClient = createSupabaseAdminClient()
@@ -18,7 +18,7 @@ export async function DELETE() {
if (error) {
return NextResponse.json(
- { error: "Failed to delete account" },
+ { error: "failed to delete account" },
{ status: 500 }
)
}
diff --git a/apps/web/app/api/billing/create-checkout-session/route.ts b/apps/web/app/api/billing/create-checkout-session/route.ts
index cfbb388..d165cbc 100644
--- a/apps/web/app/api/billing/create-checkout-session/route.ts
+++ b/apps/web/app/api/billing/create-checkout-session/route.ts
@@ -12,12 +12,12 @@ export async function POST(request: Request) {
} = await supabaseClient.auth.getUser()
if (!user) {
- return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
const rateLimitResult = rateLimit(`checkout:${user.id}`, 10, 60_000)
if (!rateLimitResult.success) {
- return NextResponse.json({ error: "Too many requests" }, { status: 429 })
+ return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
const body = await request.json().catch(() => ({}))
@@ -38,7 +38,7 @@ export async function POST(request: Request) {
if (!stripePriceIdentifier) {
return NextResponse.json(
- { error: "Invalid plan configuration" },
+ { error: "invalid plan configuration" },
{ status: 500 }
)
}
@@ -51,7 +51,7 @@ export async function POST(request: Request) {
if (profileError || !profile) {
return NextResponse.json(
- { error: "Failed to load profile" },
+ { error: "failed to load profile" },
{ status: 500 }
)
}
@@ -62,7 +62,7 @@ export async function POST(request: Request) {
if (currentRank >= targetRank) {
return NextResponse.json(
- { error: `Already on ${profile.tier} plan` },
+ { error: `already on ${profile.tier} plan` },
{ status: 400 }
)
}
@@ -76,7 +76,7 @@ export async function POST(request: Request) {
if (!existingItemIdentifier) {
return NextResponse.json(
- { error: "Could not find existing subscription item" },
+ { error: "could not find existing subscription item" },
{ status: 500 }
)
}
@@ -123,7 +123,7 @@ export async function POST(request: Request) {
if (updateError) {
console.error("Admin client update error:", updateError)
return NextResponse.json(
- { error: "Failed to save customer: " + updateError.message },
+ { error: "failed to save customer: " + updateError.message },
{ status: 500 }
)
}
diff --git a/apps/web/app/api/billing/create-portal-session/route.ts b/apps/web/app/api/billing/create-portal-session/route.ts
index 3832c0d..29698e2 100644
--- a/apps/web/app/api/billing/create-portal-session/route.ts
+++ b/apps/web/app/api/billing/create-portal-session/route.ts
@@ -11,12 +11,12 @@ export async function POST() {
} = await supabaseClient.auth.getUser()
if (!user) {
- return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
const rateLimitResult = rateLimit(`portal:${user.id}`, 10, 60_000)
if (!rateLimitResult.success) {
- return NextResponse.json({ error: "Too many requests" }, { status: 429 })
+ return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
const { data: profile, error: profileError } = await supabaseClient
@@ -27,14 +27,14 @@ export async function POST() {
if (profileError || !profile) {
return NextResponse.json(
- { error: "Failed to load profile" },
+ { error: "failed to load profile" },
{ status: 500 }
)
}
if (!profile.stripe_customer_identifier) {
return NextResponse.json(
- { error: "No billing account found" },
+ { error: "no billing account found" },
{ status: 400 }
)
}
diff --git a/apps/web/app/api/billing/webhook/route.ts b/apps/web/app/api/billing/webhook/route.ts
index 8aed7d0..297ef8e 100644
--- a/apps/web/app/api/billing/webhook/route.ts
+++ b/apps/web/app/api/billing/webhook/route.ts
@@ -44,7 +44,7 @@ async function updateBillingState(
.eq("stripe_customer_identifier", stripeCustomerIdentifier)
if (error) {
- console.error("Failed to update billing state:", error)
+ console.error("failed to update billing state:", error)
}
}
@@ -131,14 +131,14 @@ export async function POST(request: Request) {
const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"
const rateLimitResult = rateLimit(`webhook:${clientIp}`, 60, 60_000)
if (!rateLimitResult.success) {
- return NextResponse.json({ error: "Too many requests" }, { status: 429 })
+ return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
const body = await request.text()
const signature = request.headers.get("stripe-signature")
if (!signature) {
- return NextResponse.json({ error: "Missing signature" }, { status: 400 })
+ return NextResponse.json({ error: "missing signature" }, { status: 400 })
}
let event: Stripe.Event
@@ -150,7 +150,7 @@ export async function POST(request: Request) {
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch {
- return NextResponse.json({ error: "Invalid signature" }, { status: 400 })
+ return NextResponse.json({ error: "invalid signature" }, { status: 400 })
}
switch (event.type) {
diff --git a/apps/web/app/api/export/route.ts b/apps/web/app/api/export/route.ts
index 4842f83..4d15c5a 100644
--- a/apps/web/app/api/export/route.ts
+++ b/apps/web/app/api/export/route.ts
@@ -8,7 +8,7 @@ export async function GET() {
} = await supabaseClient.auth.getUser()
if (!user) {
- return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
const { data: profile } = await supabaseClient
diff --git a/apps/web/app/api/share/[token]/route.ts b/apps/web/app/api/share/[token]/route.ts
index 45224aa..d1d57b5 100644
--- a/apps/web/app/api/share/[token]/route.ts
+++ b/apps/web/app/api/share/[token]/route.ts
@@ -13,7 +13,7 @@ export async function DELETE(
} = await supabaseClient.auth.getUser()
if (!user) {
- return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
const { token } = await params
@@ -26,7 +26,7 @@ export async function DELETE(
if (error) {
return NextResponse.json(
- { error: "Failed to delete share" },
+ { error: "failed to delete share" },
{ status: 500 }
)
}
@@ -44,7 +44,7 @@ export async function PATCH(
} = await supabaseClient.auth.getUser()
if (!user) {
- return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
const { token } = await params
@@ -76,7 +76,7 @@ export async function PATCH(
if (error) {
return NextResponse.json(
- { error: "Failed to update share" },
+ { error: "failed to update share" },
{ status: 500 }
)
}
diff --git a/apps/web/app/api/share/route.ts b/apps/web/app/api/share/route.ts
index 2558560..f330bd0 100644
--- a/apps/web/app/api/share/route.ts
+++ b/apps/web/app/api/share/route.ts
@@ -22,7 +22,7 @@ export async function POST(request: Request) {
} = await supabaseClient.auth.getUser()
if (!user) {
- return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
const { data: userProfile } = await supabaseClient
@@ -73,7 +73,7 @@ export async function POST(request: Request) {
if (!entryAccess) {
return NextResponse.json(
- { error: "Entry not found or not accessible" },
+ { error: "entry not found or not accessible" },
{ status: 404 }
)
}
@@ -87,7 +87,7 @@ export async function POST(request: Request) {
if (!subscriptionAccess) {
return NextResponse.json(
- { error: "You do not have access to this entry" },
+ { error: "you do not have access to this entry" },
{ status: 403 }
)
}
@@ -121,7 +121,7 @@ export async function POST(request: Request) {
if (error) {
return NextResponse.json(
- { error: "Failed to create share" },
+ { error: "failed to create share" },
{ status: 500 }
)
}
diff --git a/apps/web/app/api/v1/entries/[entryIdentifier]/route.ts b/apps/web/app/api/v1/entries/[entryIdentifier]/route.ts
index 157366b..d420f92 100644
--- a/apps/web/app/api/v1/entries/[entryIdentifier]/route.ts
+++ b/apps/web/app/api/v1/entries/[entryIdentifier]/route.ts
@@ -28,7 +28,7 @@ export async function GET(
.single()
if (error || !entry) {
- return NextResponse.json({ error: "Entry not found" }, { status: 404 })
+ return NextResponse.json({ error: "entry not found" }, { status: 404 })
}
const { data: subscription } = await adminClient
@@ -39,7 +39,7 @@ export async function GET(
.single()
if (!subscription) {
- return NextResponse.json({ error: "Entry not found" }, { status: 404 })
+ return NextResponse.json({ error: "entry not found" }, { status: 404 })
}
const { data: stateRow } = await adminClient
diff --git a/apps/web/app/api/v1/entries/route.ts b/apps/web/app/api/v1/entries/route.ts
index 653c79b..e782e3b 100644
--- a/apps/web/app/api/v1/entries/route.ts
+++ b/apps/web/app/api/v1/entries/route.ts
@@ -57,7 +57,7 @@ export async function GET(request: Request) {
if (error) {
return NextResponse.json(
- { error: "Failed to load entries" },
+ { error: "failed to load entries" },
{ status: 500 }
)
}
diff --git a/apps/web/app/api/v1/feeds/route.ts b/apps/web/app/api/v1/feeds/route.ts
index adf5422..5b59856 100644
--- a/apps/web/app/api/v1/feeds/route.ts
+++ b/apps/web/app/api/v1/feeds/route.ts
@@ -22,7 +22,7 @@ export async function GET(request: Request) {
if (error) {
return NextResponse.json(
- { error: "Failed to load feeds" },
+ { error: "failed to load feeds" },
{ status: 500 }
)
}
diff --git a/apps/web/app/api/v1/folders/route.ts b/apps/web/app/api/v1/folders/route.ts
index 5fb006d..3b808a6 100644
--- a/apps/web/app/api/v1/folders/route.ts
+++ b/apps/web/app/api/v1/folders/route.ts
@@ -21,7 +21,7 @@ export async function GET(request: Request) {
if (error) {
return NextResponse.json(
- { error: "Failed to load folders" },
+ { error: "failed to load folders" },
{ status: 500 }
)
}
diff --git a/apps/web/app/api/v1/keys/[keyIdentifier]/route.ts b/apps/web/app/api/v1/keys/[keyIdentifier]/route.ts
index 8026f27..9835227 100644
--- a/apps/web/app/api/v1/keys/[keyIdentifier]/route.ts
+++ b/apps/web/app/api/v1/keys/[keyIdentifier]/route.ts
@@ -12,7 +12,7 @@ export async function DELETE(
} = await supabaseClient.auth.getUser()
if (!user) {
- return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
const { keyIdentifier } = await params
@@ -27,7 +27,7 @@ export async function DELETE(
if (error) {
return NextResponse.json(
- { error: "Failed to revoke API key" },
+ { error: "failed to revoke api key" },
{ status: 500 }
)
}
diff --git a/apps/web/app/api/v1/keys/route.ts b/apps/web/app/api/v1/keys/route.ts
index 7ac7144..1461532 100644
--- a/apps/web/app/api/v1/keys/route.ts
+++ b/apps/web/app/api/v1/keys/route.ts
@@ -14,7 +14,7 @@ export async function GET() {
} = await supabaseClient.auth.getUser()
if (!user) {
- return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
const adminClient = createSupabaseAdminClient()
@@ -26,7 +26,7 @@ export async function GET() {
if (error) {
return NextResponse.json(
- { error: "Failed to load API keys" },
+ { error: "failed to load api keys" },
{ status: 500 }
)
}
@@ -50,12 +50,12 @@ export async function POST(request: Request) {
} = await supabaseClient.auth.getUser()
if (!user) {
- return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
const rateLimitResult = rateLimit(`api-keys:${user.id}`, 10, 60_000)
if (!rateLimitResult.success) {
- return NextResponse.json({ error: "Too many requests" }, { status: 429 })
+ return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
const adminClient = createSupabaseAdminClient()
@@ -71,7 +71,7 @@ export async function POST(request: Request) {
!TIER_LIMITS[userProfile.tier as SubscriptionTier]?.allowsApiAccess
) {
return NextResponse.json(
- { error: "API access requires the developer plan" },
+ { error: "api access requires the developer plan" },
{ status: 403 }
)
}
@@ -84,7 +84,7 @@ export async function POST(request: Request) {
if ((activeKeyCount ?? 0) >= MAXIMUM_ACTIVE_KEYS) {
return NextResponse.json(
- { error: `Maximum of ${MAXIMUM_ACTIVE_KEYS} active keys allowed` },
+ { error: `maximum of ${MAXIMUM_ACTIVE_KEYS} active keys allowed` },
{ status: 400 }
)
}
@@ -103,7 +103,7 @@ export async function POST(request: Request) {
if (insertError) {
return NextResponse.json(
- { error: "Failed to create API key" },
+ { error: "failed to create api key" },
{ status: 500 }
)
}
diff --git a/apps/web/app/api/v1/profile/route.ts b/apps/web/app/api/v1/profile/route.ts
index f7ec308..a7773dd 100644
--- a/apps/web/app/api/v1/profile/route.ts
+++ b/apps/web/app/api/v1/profile/route.ts
@@ -24,7 +24,7 @@ export async function GET(request: Request) {
if (error || !profile) {
return NextResponse.json(
- { error: "Failed to load profile" },
+ { error: "failed to load profile" },
{ status: 500 }
)
}
diff --git a/apps/web/app/api/webhook-config/route.ts b/apps/web/app/api/webhook-config/route.ts
index 1ce9a30..049f4f3 100644
--- a/apps/web/app/api/webhook-config/route.ts
+++ b/apps/web/app/api/webhook-config/route.ts
@@ -11,7 +11,7 @@ export async function GET() {
} = await supabaseClient.auth.getUser()
if (!user) {
- return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
const adminClient = createSupabaseAdminClient()
@@ -25,7 +25,7 @@ export async function GET() {
if (error || !profile) {
return NextResponse.json(
- { error: "Failed to load webhook config" },
+ { error: "failed to load webhook config" },
{ status: 500 }
)
}
@@ -45,12 +45,12 @@ export async function PUT(request: Request) {
} = await supabaseClient.auth.getUser()
if (!user) {
- return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
const rateLimitResult = rateLimit(`webhook-config:${user.id}`, 10, 60_000)
if (!rateLimitResult.success) {
- return NextResponse.json({ error: "Too many requests" }, { status: 429 })
+ return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
const adminClient = createSupabaseAdminClient()
@@ -66,7 +66,7 @@ export async function PUT(request: Request) {
!TIER_LIMITS[profile.tier as SubscriptionTier]?.allowsWebhooks
) {
return NextResponse.json(
- { error: "Webhooks require the developer plan" },
+ { error: "webhooks require the developer plan" },
{ status: 403 }
)
}
@@ -79,7 +79,7 @@ export async function PUT(request: Request) {
const trimmedUrl = body.webhookUrl.trim()
if (trimmedUrl && !trimmedUrl.startsWith("https://")) {
return NextResponse.json(
- { error: "Webhook URL must use HTTPS" },
+ { error: "webhook url must use https" },
{ status: 400 }
)
}
@@ -98,7 +98,7 @@ export async function PUT(request: Request) {
}
if (Object.keys(updates).length === 0) {
- return NextResponse.json({ error: "No updates provided" }, { status: 400 })
+ return NextResponse.json({ error: "no updates provided" }, { status: 400 })
}
const { error } = await adminClient
@@ -108,7 +108,7 @@ export async function PUT(request: Request) {
if (error) {
return NextResponse.json(
- { error: "Failed to update webhook config" },
+ { error: "failed to update webhook config" },
{ status: 500 }
)
}
diff --git a/apps/web/app/api/webhook-config/test/route.ts b/apps/web/app/api/webhook-config/test/route.ts
index 684ec0c..6171da4 100644
--- a/apps/web/app/api/webhook-config/test/route.ts
+++ b/apps/web/app/api/webhook-config/test/route.ts
@@ -12,12 +12,12 @@ export async function POST() {
} = await supabaseClient.auth.getUser()
if (!user) {
- return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
+ return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
const rateLimitResult = rateLimit(`webhook-test:${user.id}`, 5, 60_000)
if (!rateLimitResult.success) {
- return NextResponse.json({ error: "Too many requests" }, { status: 429 })
+ return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
const adminClient = createSupabaseAdminClient()
@@ -34,14 +34,14 @@ export async function POST() {
!TIER_LIMITS[profile.tier as SubscriptionTier]?.allowsWebhooks
) {
return NextResponse.json(
- { error: "Webhooks require the developer plan" },
+ { error: "webhooks require the developer plan" },
{ status: 403 }
)
}
if (!profile.webhook_url) {
return NextResponse.json(
- { error: "No webhook URL configured" },
+ { error: "no webhook url configured" },
{ status: 400 }
)
}
@@ -53,7 +53,7 @@ export async function POST() {
{
entryIdentifier: "test-entry-000",
feedIdentifier: "test-feed-000",
- title: "Test webhook delivery",
+ title: "test webhook delivery",
url: "https://asa.news",
author: "asa.news",
summary: "This is a test webhook payload to verify your endpoint.",
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index a3e3b8b..27c95bc 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -14,7 +14,7 @@ const jetBrainsMono = localFont({
export const metadata: Metadata = {
title: "asa.news",
- description: "A fast, minimal RSS reader for staying informed",
+ description: "a fast, minimal rss reader for staying informed",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
diff --git a/apps/web/app/manifest.ts b/apps/web/app/manifest.ts
index 0bef8b1..cb940d5 100644
--- a/apps/web/app/manifest.ts
+++ b/apps/web/app/manifest.ts
@@ -4,7 +4,7 @@ export default function manifest(): MetadataRoute.Manifest {
return {
name: "asa.news",
short_name: "asa.news",
- description: "A dense, keyboard-first RSS reader",
+ description: "a dense, keyboard-first rss reader",
start_url: "/reader",
display: "standalone",
background_color: "#0a0a0a",
diff --git a/apps/web/app/reader/_components/entry-detail-panel.tsx b/apps/web/app/reader/_components/entry-detail-panel.tsx
index 2e8e19c..b823fe7 100644
--- a/apps/web/app/reader/_components/entry-detail-panel.tsx
+++ b/apps/web/app/reader/_components/entry-detail-panel.tsx
@@ -112,7 +112,7 @@ export function EntryDetailPanel({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entryIdentifier, note: note ?? null }),
})
- if (!response.ok) throw new Error("Failed to create share")
+ if (!response.ok) throw new Error("failed to create share")
return response.json() as Promise<{
shareToken: string
shareUrl: string
@@ -133,7 +133,7 @@ export function EntryDetailPanel({
const response = await fetch(`/api/share/${shareToken}`, {
method: "DELETE",
})
- if (!response.ok) throw new Error("Failed to delete share")
+ if (!response.ok) throw new Error("failed to delete share")
},
onSuccess: () => {
notify("share link removed")
diff --git a/apps/web/app/reader/settings/_components/account-settings.tsx b/apps/web/app/reader/settings/_components/account-settings.tsx
index b9ed8c3..ccb09dd 100644
--- a/apps/web/app/reader/settings/_components/account-settings.tsx
+++ b/apps/web/app/reader/settings/_components/account-settings.tsx
@@ -29,7 +29,7 @@ export function AccountSettings() {
data: { user },
} = await supabaseClient.auth.getUser()
- if (!user) throw new Error("Not authenticated")
+ if (!user) throw new Error("not authenticated")
const { error } = await supabaseClient
.from("user_profiles")
@@ -59,7 +59,7 @@ export function AccountSettings() {
data: { user },
} = await supabaseClient.auth.getUser()
- if (!user?.email) throw new Error("Not authenticated")
+ if (!user?.email) throw new Error("not authenticated")
const { error: signInError } = await supabaseClient.auth.signInWithPassword({
email: user.email,
@@ -96,7 +96,7 @@ export function AccountSettings() {
data: { user },
} = await supabaseClient.auth.getUser()
- if (!user?.email) throw new Error("Not authenticated")
+ if (!user?.email) throw new Error("not authenticated")
const { error: signInError } = await supabaseClient.auth.signInWithPassword({
email: user.email,
@@ -139,7 +139,7 @@ export function AccountSettings() {
setIsRequestingData(true)
try {
const response = await fetch("/api/account/data")
- if (!response.ok) throw new Error("Export failed")
+ if (!response.ok) throw new Error("export failed")
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const anchor = document.createElement("a")
diff --git a/apps/web/app/reader/settings/_components/api-settings.tsx b/apps/web/app/reader/settings/_components/api-settings.tsx
index cb69958..0ae6a8d 100644
--- a/apps/web/app/reader/settings/_components/api-settings.tsx
+++ b/apps/web/app/reader/settings/_components/api-settings.tsx
@@ -26,7 +26,7 @@ function useApiKeys() {
queryKey: ["apiKeys"],
queryFn: async () => {
const response = await fetch("/api/v1/keys")
- if (!response.ok) throw new Error("Failed to load API keys")
+ if (!response.ok) throw new Error("failed to load api keys")
const data = await response.json()
return data.keys as ApiKey[]
},
@@ -44,7 +44,7 @@ function useCreateApiKey() {
})
if (!response.ok) {
const data = await response.json()
- throw new Error(data.error || "Failed to create API key")
+ throw new Error(data.error || "failed to create api key")
}
return response.json() as Promise<{
fullKey: string
@@ -67,7 +67,7 @@ function useRevokeApiKey() {
})
if (!response.ok) {
const data = await response.json()
- throw new Error(data.error || "Failed to revoke API key")
+ throw new Error(data.error || "failed to revoke api key")
}
},
onSuccess: () => {
@@ -81,7 +81,7 @@ function useWebhookConfig() {
queryKey: ["webhookConfig"],
queryFn: async () => {
const response = await fetch("/api/webhook-config")
- if (!response.ok) throw new Error("Failed to load webhook config")
+ if (!response.ok) throw new Error("failed to load webhook config")
return response.json() as Promise<WebhookConfiguration>
},
})
@@ -104,7 +104,7 @@ function useUpdateWebhookConfig() {
})
if (!response.ok) {
const data = await response.json()
- throw new Error(data.error || "Failed to update webhook config")
+ throw new Error(data.error || "failed to update webhook config")
}
},
onSuccess: () => {
@@ -121,7 +121,7 @@ function useTestWebhook() {
})
if (!response.ok) {
const data = await response.json()
- throw new Error(data.error || "Failed to send test webhook")
+ throw new Error(data.error || "failed to send test webhook")
}
return response.json() as Promise<{
delivered: boolean
@@ -147,7 +147,7 @@ function ApiKeysSection() {
onSuccess: (data) => {
setRevealedKey(data.fullKey)
setNewKeyLabel("")
- notify("API key created")
+ notify("api key created")
},
onError: (error: Error) => {
notify(error.message)
@@ -158,14 +158,14 @@ function ApiKeysSection() {
function handleCopyKey() {
if (revealedKey) {
navigator.clipboard.writeText(revealedKey)
- notify("API key copied to clipboard")
+ notify("api key copied to clipboard")
}
}
function handleRevokeKey(keyIdentifier: string) {
revokeApiKey.mutate(keyIdentifier, {
onSuccess: () => {
- notify("API key revoked")
+ notify("api key revoked")
setConfirmRevokeIdentifier(null)
},
onError: (error: Error) => {
@@ -176,9 +176,9 @@ function ApiKeysSection() {
return (
<div className="mb-6">
- <h3 className="mb-2 text-text-primary">API keys</h3>
+ <h3 className="mb-2 text-text-primary">api keys</h3>
<p className="mb-3 text-text-dim">
- use API keys to authenticate requests to the REST API
+ use api keys to authenticate requests to the rest api
</p>
{revealedKey && (
@@ -379,12 +379,12 @@ function WebhookSection() {
<div className="mb-6">
<h3 className="mb-2 text-text-primary">webhooks</h3>
<p className="mb-3 text-text-dim">
- receive HTTP POST notifications when new entries arrive in your
+ receive http post notifications when new entries arrive in your
subscribed feeds
</p>
<div className="mb-4">
- <label className="mb-1 block text-text-secondary">webhook URL</label>
+ <label className="mb-1 block text-text-secondary">webhook url</label>
<input
type="url"
value={webhookUrl}
@@ -409,7 +409,7 @@ function WebhookSection() {
setWebhookSecret(event.target.value)
setHasUnsavedChanges(true)
}}
- placeholder="optional HMAC-SHA256 signing secret"
+ placeholder="optional hmac-sha256 signing secret"
className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim"
/>
<button
@@ -479,21 +479,21 @@ export function ApiSettings() {
const { data: userProfile, isLoading } = useUserProfile()
if (isLoading) {
- return <p className="px-4 py-6 text-text-dim">loading API settings ...</p>
+ return <p className="px-4 py-6 text-text-dim">loading api settings ...</p>
}
if (!userProfile) {
return (
- <p className="px-4 py-6 text-text-dim">failed to load API settings</p>
+ <p className="px-4 py-6 text-text-dim">failed to load api settings</p>
)
}
if (userProfile.tier !== "developer") {
return (
<div className="px-4 py-3">
- <h3 className="mb-2 text-text-primary">developer API</h3>
+ <h3 className="mb-2 text-text-primary">developer api</h3>
<p className="mb-3 text-text-dim">
- the developer plan includes a read-only REST API and webhook push
+ the developer plan includes a read-only rest api and webhook push
notifications. upgrade to developer to access these features.
</p>
</div>
@@ -506,22 +506,22 @@ export function ApiSettings() {
<WebhookSection />
<div>
- <h3 className="mb-2 text-text-primary">API documentation</h3>
+ <h3 className="mb-2 text-text-primary">api documentation</h3>
<p className="mb-3 text-text-dim">
- authenticate requests with an API key in the Authorization header:
+ authenticate requests with an api key in the authorization header:
</p>
<code className="block bg-background-tertiary px-3 py-2 text-text-secondary">
Authorization: Bearer asn_your_key_here
</code>
<div className="mt-3 space-y-1 text-text-dim">
- <p>GET /api/v1/profile — your account info and limits</p>
- <p>GET /api/v1/feeds — your subscribed feeds</p>
- <p>GET /api/v1/folders — your folders</p>
+ <p>get /api/v1/profile — your account info and limits</p>
+ <p>get /api/v1/feeds — your subscribed feeds</p>
+ <p>get /api/v1/folders — your folders</p>
<p>
- GET /api/v1/entries — entries with ?cursor, ?limit, ?feedIdentifier,
+ get /api/v1/entries — entries with ?cursor, ?limit, ?feedIdentifier,
?readStatus, ?savedStatus filters
</p>
- <p>GET /api/v1/entries/:id — single entry with full content</p>
+ <p>get /api/v1/entries/:id — single entry with full content</p>
</div>
</div>
</div>
diff --git a/apps/web/app/reader/settings/_components/billing-settings.tsx b/apps/web/app/reader/settings/_components/billing-settings.tsx
index e49720a..4c62f16 100644
--- a/apps/web/app/reader/settings/_components/billing-settings.tsx
+++ b/apps/web/app/reader/settings/_components/billing-settings.tsx
@@ -26,7 +26,7 @@ function useCreateCheckoutSession() {
if (!response.ok) {
const data = await response.json()
- throw new Error(data.error || "Failed to create checkout session")
+ throw new Error(data.error || "failed to create checkout session")
}
const data = await response.json()
@@ -44,7 +44,7 @@ function useCreatePortalSession() {
if (!response.ok) {
const data = await response.json()
- throw new Error(data.error || "Failed to create portal session")
+ throw new Error(data.error || "failed to create portal session")
}
const data = await response.json()
@@ -58,14 +58,14 @@ const PRO_FEATURES = [
`${Number.isFinite(TIER_LIMITS.pro.historyRetentionDays) ? TIER_LIMITS.pro.historyRetentionDays.toLocaleString() + " days" : "unlimited"} history retention`,
`${TIER_LIMITS.pro.refreshIntervalSeconds / 60}-minute refresh interval`,
"authenticated feeds",
- "OPML export",
+ "opml export",
"manual feed refresh",
]
const DEVELOPER_FEATURES = [
`${TIER_LIMITS.developer.maximumFeeds} feeds`,
"everything in pro",
- "read-only REST API",
+ "read-only rest api",
"webhook push notifications",
]
diff --git a/apps/web/app/reader/settings/_components/danger-zone-settings.tsx b/apps/web/app/reader/settings/_components/danger-zone-settings.tsx
index 76c48d4..3525426 100644
--- a/apps/web/app/reader/settings/_components/danger-zone-settings.tsx
+++ b/apps/web/app/reader/settings/_components/danger-zone-settings.tsx
@@ -19,7 +19,7 @@ export function DangerZoneSettings() {
const deleteAccount = useMutation({
mutationFn: async () => {
const response = await fetch("/api/account", { method: "DELETE" })
- if (!response.ok) throw new Error("Failed to delete account")
+ if (!response.ok) throw new Error("failed to delete account")
},
onSuccess: () => {
router.push("/sign-in")
@@ -112,6 +112,7 @@ export function DangerZoneSettings() {
</p>
{showDeleteAccountConfirm ? (
<div>
+ {/* eslint-disable-next-line asa-lowercase/lowercase-strings */}
<p className="mb-2 text-status-error">
type DELETE to confirm account deletion.
</p>
@@ -120,7 +121,7 @@ export function DangerZoneSettings() {
type="text"
value={deleteConfirmText}
onChange={(event) => setDeleteConfirmText(event.target.value)}
- placeholder="type DELETE"
+ placeholder="type DELETE" // eslint-disable-line asa-lowercase/lowercase-strings
className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-status-error"
autoFocus
/>
diff --git a/apps/web/app/reader/settings/_components/import-export-settings.tsx b/apps/web/app/reader/settings/_components/import-export-settings.tsx
index efb3f09..e84b56f 100644
--- a/apps/web/app/reader/settings/_components/import-export-settings.tsx
+++ b/apps/web/app/reader/settings/_components/import-export-settings.tsx
@@ -41,7 +41,7 @@ export function ImportExportSettings() {
const groups = parseOpml(xmlString)
setParsedGroups(groups)
} catch {
- notify("failed to parse OPML file")
+ notify("failed to parse opml file")
}
}
reader.readAsText(file)
@@ -99,7 +99,7 @@ export function ImportExportSettings() {
setIsExportingData(true)
try {
const response = await fetch("/api/export")
- if (!response.ok) throw new Error("Export failed")
+ if (!response.ok) throw new Error("export failed")
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const anchor = document.createElement("a")
@@ -121,24 +121,24 @@ export function ImportExportSettings() {
return (
<div className="px-4 py-3">
<div className="mb-6">
- <h3 className="mb-2 text-text-primary">export OPML</h3>
+ <h3 className="mb-2 text-text-primary">export opml</h3>
<p className="mb-3 text-text-dim">
- download your subscriptions as an OPML file
+ download your subscriptions as an opml file
</p>
<button
onClick={handleExport}
disabled={!subscriptionsData}
className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50"
>
- export OPML
+ export opml
</button>
</div>
<div className="mb-6">
<h3 className="mb-2 text-text-primary">export data</h3>
<p className="mb-3 text-text-dim">
{tier === "pro" || tier === "developer"
- ? "download all your data as JSON (subscriptions, folders, saved entries)"
- : "download your saved entries as JSON (upgrade to pro for full export)"}
+ ? "download all your data as json (subscriptions, folders, saved entries)"
+ : "download your saved entries as json (upgrade to pro for full export)"}
</p>
<button
onClick={handleDataExport}
@@ -151,7 +151,7 @@ export function ImportExportSettings() {
<div>
<h3 className="mb-2 text-text-primary">import</h3>
<p className="mb-3 text-text-dim">
- import subscriptions from an OPML file
+ import subscriptions from an opml file
</p>
{parsedGroups === null ? (
<div>
@@ -166,7 +166,7 @@ export function ImportExportSettings() {
onClick={() => fileInputReference.current?.click()}
className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border"
>
- select OPML file
+ select opml file
</button>
</div>
) : (
diff --git a/apps/web/app/reader/settings/_components/security-settings.tsx b/apps/web/app/reader/settings/_components/security-settings.tsx
index 4a00241..32c84c4 100644
--- a/apps/web/app/reader/settings/_components/security-settings.tsx
+++ b/apps/web/app/reader/settings/_components/security-settings.tsx
@@ -24,7 +24,7 @@ export function SecuritySettings() {
const { data, error } = await supabaseClient.auth.mfa.listFactors()
if (error) {
- notify("failed to load MFA factors")
+ notify("failed to load mfa factors")
setIsLoading(false)
return
}
@@ -54,7 +54,7 @@ export function SecuritySettings() {
setIsProcessing(false)
if (error) {
- notify("failed to start MFA enrolment: " + error.message)
+ notify("failed to start mfa enrolment: " + error.message)
return
}
@@ -80,7 +80,7 @@ export function SecuritySettings() {
if (challengeError) {
setIsProcessing(false)
- notify("failed to create MFA challenge: " + challengeError.message)
+ notify("failed to create mfa challenge: " + challengeError.message)
return
}
@@ -147,7 +147,7 @@ export function SecuritySettings() {
<div className="mb-6">
<h3 className="mb-2 text-text-primary">two-factor authentication</h3>
<p className="mb-4 text-text-dim">
- add an extra layer of security to your account with a time-based one-time password (TOTP) authenticator app
+ add an extra layer of security to your account with a time-based one-time password (totp) authenticator app
</p>
{enrollmentState.step === "idle" && enrolledFactors.length === 0 && (
@@ -172,12 +172,12 @@ export function SecuritySettings() {
{enrollmentState.step === "enrolling" && (
<div className="space-y-4">
<p className="text-text-secondary">
- scan this QR code with your authenticator app, then enter the 6-digit code below
+ scan this qr code with your authenticator app, then enter the 6-digit code below
</p>
<div className="inline-block bg-white p-4">
<img
src={enrollmentState.qrCodeSvg}
- alt="TOTP QR code"
+ alt="totp qr code"
className="h-48 w-48"
/>
</div>
@@ -234,7 +234,7 @@ export function SecuritySettings() {
>
<div>
<span className="text-text-primary">
- {factor.friendly_name || "TOTP authenticator"}
+ {factor.friendly_name || "totp authenticator"}
</span>
<span className="ml-2 text-text-dim">
added{" "}
diff --git a/apps/web/app/reader/settings/_components/settings-shell.tsx b/apps/web/app/reader/settings/_components/settings-shell.tsx
index ae432f3..3c25281 100644
--- a/apps/web/app/reader/settings/_components/settings-shell.tsx
+++ b/apps/web/app/reader/settings/_components/settings-shell.tsx
@@ -23,7 +23,7 @@ const TABS = [
{ key: "account", label: "account" },
{ key: "security", label: "security" },
{ key: "billing", label: "billing" },
- { key: "api", label: "API" },
+ { key: "api", label: "api" },
{ key: "danger", label: "danger zone" },
] as const
diff --git a/apps/web/app/reader/shares/_components/shares-content.tsx b/apps/web/app/reader/shares/_components/shares-content.tsx
index e9ce7a4..b05d562 100644
--- a/apps/web/app/reader/shares/_components/shares-content.tsx
+++ b/apps/web/app/reader/shares/_components/shares-content.tsx
@@ -91,7 +91,7 @@ function useUpdateShareNote() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note }),
})
- if (!response.ok) throw new Error("Failed to update note")
+ if (!response.ok) throw new Error("failed to update note")
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["shared-entries"] })
diff --git a/apps/web/eslint-rules/lowercase-strings.mjs b/apps/web/eslint-rules/lowercase-strings.mjs
new file mode 100644
index 0000000..096d236
--- /dev/null
+++ b/apps/web/eslint-rules/lowercase-strings.mjs
@@ -0,0 +1,262 @@
+const UPPERCASE_PATTERN = /\b[A-Z]/
+
+const IGNORED_JSX_PARENTS = new Set(["code", "Code"])
+
+const CHECKED_JSX_ATTRIBUTES = new Set(["placeholder", "alt", "title"])
+
+const NOTIFY_NAMES = new Set(["notify"])
+
+function isInsideCodeElement(node) {
+ let current = node.parent
+ while (current) {
+ if (
+ current.type === "JSXElement" &&
+ current.openingElement?.name?.name &&
+ IGNORED_JSX_PARENTS.has(current.openingElement.name.name)
+ ) {
+ return true
+ }
+ current = current.parent
+ }
+ return false
+}
+
+function isClassName(node) {
+ return (
+ node.parent?.type === "JSXAttribute" &&
+ node.parent.name?.name === "className"
+ )
+}
+
+function isHrefOrSrc(node) {
+ return (
+ node.parent?.type === "JSXAttribute" &&
+ (node.parent.name?.name === "href" || node.parent.name?.name === "src")
+ )
+}
+
+function isFetchMethod(node) {
+ if (node.parent?.type !== "Property") return false
+ const key = node.parent.key
+ return key?.type === "Identifier" && key.name === "method"
+}
+
+function isObjectKey(node) {
+ return (
+ node.parent?.type === "Property" &&
+ node.parent.key === node
+ )
+}
+
+function isImportSource(node) {
+ return (
+ node.parent?.type === "ImportDeclaration" &&
+ node.parent.source === node
+ )
+}
+
+function isTypeContext(node) {
+ return (
+ node.parent?.type === "TSTypeReference" ||
+ node.parent?.type === "TSLiteralType" ||
+ node.parent?.type === "TSPropertySignature"
+ )
+}
+
+function isQueryKey(node) {
+ return (
+ node.parent?.type === "ArrayExpression" &&
+ node.parent.parent?.type === "Property" &&
+ node.parent.parent.key?.name === "queryKey"
+ )
+}
+
+function isContentTypeHeader(node) {
+ if (node.parent?.type !== "Property") return false
+ const key = node.parent.key
+ return key?.type === "Literal" && key.value === "Content-Type"
+}
+
+function isHeaderValue(node) {
+ if (node.parent?.type !== "Property") return false
+ const key = node.parent.key
+ if (key?.type === "Literal") {
+ const keyValue = String(key.value).toLowerCase()
+ return keyValue === "content-type" || keyValue === "authorization"
+ }
+ return false
+}
+
+function isComparisonTarget(node) {
+ return (
+ node.parent?.type === "BinaryExpression" &&
+ (node.parent.operator === "===" || node.parent.operator === "!==") &&
+ node.parent.right === node
+ )
+}
+
+function looksLikeCode(value) {
+ if (value.startsWith("Bearer ")) return true
+ if (value.startsWith("Authorization:")) return true
+ if (/^https?:\/\//.test(value)) return true
+ if (value.includes("::")) return true
+ return false
+}
+
+const rule = {
+ meta: {
+ type: "suggestion",
+ docs: {
+ description: "enforce lowercase user-facing strings",
+ },
+ messages: {
+ uppercaseString:
+ "user-facing string contains uppercase: \"{{value}}\". all user-facing text must be lowercase.",
+ uppercaseJsxText:
+ "jsx text contains uppercase: \"{{value}}\". all user-facing text must be lowercase.",
+ },
+ schema: [],
+ },
+ create(context) {
+ function checkStringLiteral(node, value, messageId) {
+ if (!UPPERCASE_PATTERN.test(value)) return
+ if (looksLikeCode(value)) return
+
+ context.report({
+ node,
+ messageId,
+ data: {
+ value: value.length > 60 ? value.slice(0, 57) + "..." : value,
+ },
+ })
+ }
+
+ return {
+ JSXText(node) {
+ const trimmed = node.value.trim()
+ if (!trimmed) return
+ if (!UPPERCASE_PATTERN.test(trimmed)) return
+ if (isInsideCodeElement(node)) return
+
+ context.report({
+ node,
+ messageId: "uppercaseJsxText",
+ data: {
+ value:
+ trimmed.length > 60 ? trimmed.slice(0, 57) + "..." : trimmed,
+ },
+ })
+ },
+
+ Literal(node) {
+ if (typeof node.value !== "string") return
+ if (!UPPERCASE_PATTERN.test(node.value)) return
+
+ if (isInsideCodeElement(node)) return
+ if (isClassName(node)) return
+ if (isHrefOrSrc(node)) return
+ if (isFetchMethod(node)) return
+ if (isObjectKey(node)) return
+ if (isImportSource(node)) return
+ if (isTypeContext(node)) return
+ if (isQueryKey(node)) return
+ if (isContentTypeHeader(node)) return
+ if (isHeaderValue(node)) return
+ if (isComparisonTarget(node)) return
+
+ if (
+ node.parent?.type === "CallExpression" &&
+ node.parent.callee?.type === "Identifier" &&
+ NOTIFY_NAMES.has(node.parent.callee.name)
+ ) {
+ checkStringLiteral(node, node.value, "uppercaseString")
+ return
+ }
+
+ if (
+ node.parent?.type === "NewExpression" &&
+ node.parent.callee?.type === "Identifier" &&
+ node.parent.callee.name === "Error"
+ ) {
+ checkStringLiteral(node, node.value, "uppercaseString")
+ return
+ }
+
+ if (
+ node.parent?.type === "JSXAttribute" &&
+ CHECKED_JSX_ATTRIBUTES.has(node.parent.name?.name)
+ ) {
+ checkStringLiteral(node, node.value, "uppercaseString")
+ return
+ }
+
+ if (node.parent?.type === "JSXExpressionContainer") {
+ if (isInsideCodeElement(node.parent)) return
+ checkStringLiteral(node, node.value, "uppercaseString")
+ return
+ }
+
+ if (
+ node.parent?.type === "Property" &&
+ node.parent.value === node &&
+ node.parent.key?.type === "Identifier"
+ ) {
+ const keyName = node.parent.key.name
+ if (
+ keyName === "label" ||
+ keyName === "description" ||
+ keyName === "title" ||
+ keyName === "error"
+ ) {
+ checkStringLiteral(node, node.value, "uppercaseString")
+ return
+ }
+ }
+ },
+
+ TemplateLiteral(node) {
+ const isNotifyArg =
+ node.parent?.type === "CallExpression" &&
+ node.parent.callee?.type === "Identifier" &&
+ NOTIFY_NAMES.has(node.parent.callee.name)
+
+ const isErrorArg =
+ node.parent?.type === "NewExpression" &&
+ node.parent.callee?.type === "Identifier" &&
+ node.parent.callee.name === "Error"
+
+ const isJsxExpression =
+ node.parent?.type === "JSXExpressionContainer"
+
+ if (!isNotifyArg && !isErrorArg && !isJsxExpression) return
+ if (isInsideCodeElement(node)) return
+
+ for (const quasi of node.quasis) {
+ const raw = quasi.value.raw
+ if (UPPERCASE_PATTERN.test(raw) && !looksLikeCode(raw)) {
+ context.report({
+ node: quasi,
+ messageId: "uppercaseString",
+ data: {
+ value:
+ raw.length > 60 ? raw.slice(0, 57) + "..." : raw,
+ },
+ })
+ }
+ }
+ },
+ }
+ },
+}
+
+const plugin = {
+ meta: {
+ name: "asa-lowercase",
+ version: "1.0.0",
+ },
+ rules: {
+ "lowercase-strings": rule,
+ },
+}
+
+export default plugin
diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs
index 05e726d..0ca21d3 100644
--- a/apps/web/eslint.config.mjs
+++ b/apps/web/eslint.config.mjs
@@ -1,18 +1,25 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
+import asaLowercase from "./eslint-rules/lowercase-strings.mjs";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
- // Override default ignores of eslint-config-next.
globalIgnores([
- // Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
+ {
+ plugins: {
+ "asa-lowercase": asaLowercase,
+ },
+ rules: {
+ "asa-lowercase/lowercase-strings": "warn",
+ },
+ },
]);
export default eslintConfig;
diff --git a/apps/web/lib/api-auth.ts b/apps/web/lib/api-auth.ts
index 309fbe9..3f819f2 100644
--- a/apps/web/lib/api-auth.ts
+++ b/apps/web/lib/api-auth.ts
@@ -19,14 +19,14 @@ export async function authenticateApiRequest(
return {
authenticated: false,
status: 401,
- error: "Missing or invalid Authorization header",
+ error: "missing or invalid authorization header",
}
}
const apiKey = authorizationHeader.slice(7)
if (!apiKey.startsWith("asn_")) {
- return { authenticated: false, status: 401, error: "Invalid API key format" }
+ return { authenticated: false, status: 401, error: "invalid api key format" }
}
const keyHash = hashApiKey(apiKey)
@@ -40,7 +40,7 @@ export async function authenticateApiRequest(
.single()
if (!keyRow) {
- return { authenticated: false, status: 401, error: "Invalid or revoked API key" }
+ return { authenticated: false, status: 401, error: "invalid or revoked api key" }
}
const { data: userProfile } = await adminClient
@@ -53,7 +53,7 @@ export async function authenticateApiRequest(
return {
authenticated: false,
status: 403,
- error: "API access requires the developer plan",
+ error: "api access requires the developer plan",
}
}
@@ -63,7 +63,7 @@ export async function authenticateApiRequest(
return {
authenticated: false,
status: 429,
- error: `Rate limit exceeded. ${rateLimitResult.remaining} requests remaining.`,
+ error: `rate limit exceeded. ${rateLimitResult.remaining} requests remaining.`,
}
}
diff --git a/apps/web/lib/opml.ts b/apps/web/lib/opml.ts
index bd0c3a7..5c0e3e2 100644
--- a/apps/web/lib/opml.ts
+++ b/apps/web/lib/opml.ts
@@ -87,13 +87,13 @@ export function parseOpml(xmlString: string): ParsedOpmlGroup[] {
const parseError = document.querySelector("parsererror")
if (parseError) {
- throw new Error("Invalid OPML file")
+ throw new Error("invalid opml file")
}
const body = document.querySelector("body")
if (!body) {
- throw new Error("Invalid OPML: no body element")
+ throw new Error("invalid opml: no body element")
}
const groups: ParsedOpmlGroup[] = []
diff --git a/apps/web/lib/queries/use-custom-feed-mutations.ts b/apps/web/lib/queries/use-custom-feed-mutations.ts
index f0751db..ad6b328 100644
--- a/apps/web/lib/queries/use-custom-feed-mutations.ts
+++ b/apps/web/lib/queries/use-custom-feed-mutations.ts
@@ -25,7 +25,7 @@ export function useCreateCustomFeed() {
data: { user },
} = await supabaseClient.auth.getUser()
- if (!user) throw new Error("Not authenticated")
+ if (!user) throw new Error("not authenticated")
const { error } = await supabaseClient.from("custom_feeds").insert({
user_id: user.id,
diff --git a/apps/web/lib/queries/use-custom-feeds.ts b/apps/web/lib/queries/use-custom-feeds.ts
index 5c11721..a93e431 100644
--- a/apps/web/lib/queries/use-custom-feeds.ts
+++ b/apps/web/lib/queries/use-custom-feeds.ts
@@ -24,7 +24,7 @@ export function useCustomFeeds() {
data: { user },
} = await supabaseClient.auth.getUser()
- if (!user) throw new Error("Not authenticated")
+ if (!user) throw new Error("not authenticated")
const { data, error } = await supabaseClient
.from("custom_feeds")
diff --git a/apps/web/lib/queries/use-entry-state-mutations.ts b/apps/web/lib/queries/use-entry-state-mutations.ts
index 5f79fc0..a8c72d0 100644
--- a/apps/web/lib/queries/use-entry-state-mutations.ts
+++ b/apps/web/lib/queries/use-entry-state-mutations.ts
@@ -22,7 +22,7 @@ export function useToggleEntryReadState() {
data: { user },
} = await supabaseClient.auth.getUser()
- if (!user) throw new Error("Not authenticated")
+ if (!user) throw new Error("not authenticated")
const { error } = await supabaseClient
.from("user_entry_states")
@@ -88,7 +88,7 @@ export function useToggleEntrySavedState() {
data: { user },
} = await supabaseClient.auth.getUser()
- if (!user) throw new Error("Not authenticated")
+ if (!user) throw new Error("not authenticated")
const { error } = await supabaseClient
.from("user_entry_states")
diff --git a/apps/web/lib/queries/use-folder-mutations.ts b/apps/web/lib/queries/use-folder-mutations.ts
index 8595a60..642bd96 100644
--- a/apps/web/lib/queries/use-folder-mutations.ts
+++ b/apps/web/lib/queries/use-folder-mutations.ts
@@ -15,7 +15,7 @@ export function useCreateFolder() {
data: { user },
} = await supabaseClient.auth.getUser()
- if (!user) throw new Error("Not authenticated")
+ if (!user) throw new Error("not authenticated")
const { error } = await supabaseClient.from("folders").insert({
user_id: user.id,
@@ -48,7 +48,7 @@ export function useDeleteAllFolders() {
data: { user },
} = await supabaseClient.auth.getUser()
- if (!user) throw new Error("Not authenticated")
+ if (!user) throw new Error("not authenticated")
await supabaseClient
.from("subscriptions")
diff --git a/apps/web/lib/queries/use-highlight-mutations.ts b/apps/web/lib/queries/use-highlight-mutations.ts
index 0e228c8..6c9dbb7 100644
--- a/apps/web/lib/queries/use-highlight-mutations.ts
+++ b/apps/web/lib/queries/use-highlight-mutations.ts
@@ -26,7 +26,7 @@ export function useCreateHighlight() {
data: { user },
} = await supabaseClient.auth.getUser()
- if (!user) throw new Error("Not authenticated")
+ if (!user) throw new Error("not authenticated")
const { data, error } = await supabaseClient
.from("user_highlights")
diff --git a/apps/web/lib/queries/use-muted-keyword-mutations.ts b/apps/web/lib/queries/use-muted-keyword-mutations.ts
index 67bcf33..de4e03f 100644
--- a/apps/web/lib/queries/use-muted-keyword-mutations.ts
+++ b/apps/web/lib/queries/use-muted-keyword-mutations.ts
@@ -15,7 +15,7 @@ export function useAddMutedKeyword() {
data: { user },
} = await supabaseClient.auth.getUser()
- if (!user) throw new Error("Not authenticated")
+ if (!user) throw new Error("not authenticated")
const { error } = await supabaseClient.from("muted_keywords").insert({
user_id: user.id,
diff --git a/apps/web/lib/queries/use-subscription-mutations.ts b/apps/web/lib/queries/use-subscription-mutations.ts
index 3b4b3ba..3162d96 100644
--- a/apps/web/lib/queries/use-subscription-mutations.ts
+++ b/apps/web/lib/queries/use-subscription-mutations.ts
@@ -109,7 +109,7 @@ export function useUnsubscribeAll() {
data: { user },
} = await supabaseClient.auth.getUser()
- if (!user) throw new Error("Not authenticated")
+ if (!user) throw new Error("not authenticated")
const { error } = await supabaseClient
.from("subscriptions")
diff --git a/apps/web/lib/queries/use-user-profile.ts b/apps/web/lib/queries/use-user-profile.ts
index 760f970..49c1c4e 100644
--- a/apps/web/lib/queries/use-user-profile.ts
+++ b/apps/web/lib/queries/use-user-profile.ts
@@ -15,7 +15,7 @@ export function useUserProfile() {
data: { user },
} = await supabaseClient.auth.getUser()
- if (!user) throw new Error("Not authenticated")
+ if (!user) throw new Error("not authenticated")
const { data, error } = await supabaseClient
.from("user_profiles")