Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/_reusable-lighthouse.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ on:
project_path:
required: true
type: string
description: 'Path to the Next.js project (relative to repo root)'
description: 'Path to the Astro project (relative to repo root)'

jobs:
lighthouse:
Expand All @@ -36,10 +36,10 @@ jobs:
run: npm install -g @lhci/cli@0.13.x
- name: Run Lighthouse CI
working-directory: ${{ inputs.project_path }}
# Non-blocking: chatislam/web is an SSR app (output: 'server', Vercel
# adapter) with no static dist for LHCI's staticDistDir to serve.
# Tracked as follow-up to rewire LHCI to build + serve the SSR app.
continue-on-error: true
# Blocking gate. The app is SSR (output: 'server', Vercel adapter); the
# landing pages audited by lighthouserc.cjs are `export const prerender =
# true`, so `pnpm build` emits their static HTML into
# .vercel/output/static, which LHCI serves via staticDistDir.
run: lhci autorun
- name: Upload Lighthouse report
if: always()
Expand Down
15 changes: 9 additions & 6 deletions .github/workflows/a11y-axe.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,15 @@ jobs:
NEXT_PUBLIC_AUTH_URL: 'https://auth.ummat.dev'
ANTHROPIC_API_KEY: 'dummy-for-build'

- name: Start production server
# `pnpm start` (astro preview) is unsupported by @astrojs/vercel/serverless,
# so it never binds and wait-on times out. Use the dev server, which serves
# SSR routes with any adapter (same approach as playwright.config.ts).
- name: Start dev server
working-directory: web
run: pnpm start --port 3042 &
run: pnpm dev &

- name: Wait for server
run: npx wait-on http://localhost:3042 --timeout 30000
run: npx wait-on http://localhost:3042 --timeout 120000

- name: Install axe-cli
run: npm install -g @axe-core/cli
Expand All @@ -68,13 +71,13 @@ jobs:
# Tags: wcag2a, wcag2aa, wcag21a, wcag21aa, wcag22aa
# --exit: exit non-zero on violation
# --tags: enforce up to WCAG 2.2 AA
# @axe-core/cli prints results to stdout by default; there is no
# --reporter flag (it errors "unknown option '--reporter'").
run: |
axe http://localhost:3042 \
--tags wcag2a,wcag2aa,wcag21a,wcag21aa,wcag22aa \
--exit \
--reporter cli \
--include "main, nav, [role='main']"
axe http://localhost:3042/chat \
--tags wcag2a,wcag2aa,wcag21a,wcag21aa,wcag22aa \
--exit \
--reporter cli
--exit
5 changes: 0 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,6 @@ jobs:
name: E2E Tests
runs-on: ubuntu-latest
needs: build
# Non-blocking: the Astro migration left pre-existing e2e failures (RTL
# dir/lang not applied in ar/ur locales, empty-state assertion, missing
# platform snapshot baselines). These are tracked as follow-up app/test
# work; the blocking gates are lint, type-check, unit tests, and build.
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
Expand Down
1 change: 1 addition & 0 deletions web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ coverage/
playwright-report/
test-results/
__tests__/e2e/**/*-snapshots/
.lighthouseci/

# Astro
.astro/
Expand Down
50 changes: 41 additions & 9 deletions web/__tests__/e2e/rtl.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,31 @@ test.describe('RTL layout — Arabic locale', () => {
expect(hasOverflow).toBe(false)
})

test('RTL baseline screenshot — homepage Arabic', async ({ page }) => {
// Visual PNG snapshots are platform-dependent — Arabic shaping/fonts render
// differently across OSes, so a baseline captured on one platform is not a
// reliable gate on CI (linux). Instead assert the RTL layout properties a
// baseline would protect: computed direction on the document + main region,
// translated (Arabic) content, and no horizontal overflow.
test('RTL layout renders correctly — homepage Arabic', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
// Baseline: run once with --update-snapshots to create; subsequent runs compare.
await expect(page).toHaveScreenshot('homepage-ar-rtl.png', {
fullPage: false,
animations: 'disabled',

const htmlDir = await page.evaluate(() => getComputedStyle(document.documentElement).direction)
expect(htmlDir).toBe('rtl')

const mainDir = await page.evaluate(() => {
const main = document.querySelector('#main-content')
return main ? getComputedStyle(main).direction : null
})
expect(mainDir).toBe('rtl')

const bodyText = await page.evaluate(() => document.body.innerText)
expect(bodyText).toMatch(/[؀-ۿ]/)

const hasOverflow = await page.evaluate(
() => document.documentElement.scrollWidth > window.innerWidth,
)
expect(hasOverflow).toBe(false)
})
})

Expand Down Expand Up @@ -110,13 +127,28 @@ test.describe('RTL layout — Urdu locale', () => {
expect(hasOverflow).toBe(false)
})

test('RTL baseline screenshot — homepage Urdu', async ({ page }) => {
// See the Arabic case above — assert RTL layout properties rather than commit
// a platform-dependent PNG baseline.
test('RTL layout renders correctly — homepage Urdu', async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
await expect(page).toHaveScreenshot('homepage-ur-rtl.png', {
fullPage: false,
animations: 'disabled',

const htmlDir = await page.evaluate(() => getComputedStyle(document.documentElement).direction)
expect(htmlDir).toBe('rtl')

const mainDir = await page.evaluate(() => {
const main = document.querySelector('#main-content')
return main ? getComputedStyle(main).direction : null
})
expect(mainDir).toBe('rtl')

const bodyText = await page.evaluate(() => document.body.innerText)
expect(bodyText).toMatch(/[؀-ۿ]/)

const hasOverflow = await page.evaluate(
() => document.documentElement.scrollWidth > window.innerWidth,
)
expect(hasOverflow).toBe(false)
})
})

Expand Down
4 changes: 4 additions & 0 deletions web/astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
site: 'https://chatislam.org',
output: 'server',
// Dev-only overlay injects unlabeled <button>/<input> controls that leak into
// a11y scans (settings-a11y e2e) and has no production analogue. Disable it so
// the dev server (used as the e2e webServer) matches production markup.
devToolbar: { enabled: false },
adapter: vercel({
webAnalytics: { enabled: false }, // Umami handles analytics (D-P3-21)
imageService: true,
Expand Down
8 changes: 8 additions & 0 deletions web/env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
/// <reference path=".astro/types.d.ts" />
/// <reference types="astro/client" />

// Astro per-request locals — populated by src/middleware.ts.
declare namespace App {
interface Locals {
/** Active UI locale resolved from NEXT_LOCALE cookie / Accept-Language. */
locale: string
}
}

// Window.umami — Umami analytics (D-P3-21). Optional because consent-gated.
// Mirrors root types/umami.d.ts so islands under src/ resolve the global.
interface Window {
Expand Down
22 changes: 14 additions & 8 deletions web/lighthouserc.cjs
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
/**
* lighthouserc.js — Lighthouse CI config for Astro 5
* lighthouserc.cjs — Lighthouse CI config for Astro 5 (SSR app)
* Target: all four categories >= 95 on static landing pages (QA-A gate).
* REF: P2-E3-W02-S02-T02
*
* The app is SSR (output: 'server', @astrojs/vercel adapter) so there is no
* ./dist. The four landing pages below are `export const prerender = true`,
* so `pnpm build` emits their static HTML into the Vercel static output dir.
* LHCI serves that dir directly — no running server needed, fully deterministic.
* REF: P2-E3-W02-S02-T02 · T-P7-Q-PERF-01
*/

/** @type {import('@lhci/cli').LhciConfig} */
module.exports = {
ci: {
collect: {
staticDistDir: './dist',
// Vercel adapter writes prerendered HTML + client assets here.
staticDistDir: './.vercel/output/static',
url: [
'http://localhost:4321/',
'http://localhost:4321/dawah',
'http://localhost:4321/donate',
'http://localhost:4321/legal/sharia-disclaimer',
'http://localhost/',
'http://localhost/dawah',
'http://localhost/donate',
'http://localhost/legal/sharia-disclaimer',
],
numberOfRuns: 1,
numberOfRuns: 3,
},
assert: {
assertions: {
Expand Down
9 changes: 9 additions & 0 deletions web/messages/ar.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
{
"home": {
"tagline": "أسئلة وأجوبة إسلامية بمساعدة الذكاء الاصطناعي — مستندة إلى القرآن والسنة الصحيحة.",
"cta": "ابدأ بالسؤال",
"featuresLabel": "المزايا",
"feature1": "أوضاع الجمهور: أسئلة وأجوبة عامة، الدعوة، التعلّم",
"feature2": "ضوابط عقيدة أهل السنة والجماعة — علم أصيل فقط",
"feature3": "دعم متعدد اللغات يشمل العربية والأردية والفارسية والفرنسية",
"feature4": "مجاني تمامًا — بدون حساب"
},
"nav": {
"home": "الرئيسية",
"newChat": "محادثة جديدة",
Expand Down
9 changes: 9 additions & 0 deletions web/messages/en.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
{
"home": {
"tagline": "AI-assisted Islamic Q&A — grounded in the Quran and authentic Sunnah.",
"cta": "Start Asking",
"featuresLabel": "Features",
"feature1": "Audience modes: General Q&A, Dawah (outreach), Learning (tutoring)",
"feature2": "Ahl us-Sunnah wal-Jamaa'ah theology guidelines — authentic scholarship only",
"feature3": "Multi-language support including Arabic (RTL), Urdu, Farsi, French",
"feature4": "Free to use — no account required"
},
"nav": {
"home": "Home",
"newChat": "New Chat",
Expand Down
9 changes: 9 additions & 0 deletions web/messages/fa.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
{
"home": {
"tagline": "پرسش و پاسخ اسلامی با کمک هوش مصنوعی — برگرفته از قرآن و سنت صحیح.",
"cta": "شروع به پرسیدن کنید",
"featuresLabel": "ویژگی‌ها",
"feature1": "حالت‌های مخاطب: پرسش و پاسخ عمومی، دعوت، آموزش",
"feature2": "اصول عقیده اهل سنت و جماعت — تنها دانش اصیل",
"feature3": "پشتیبانی چندزبانه شامل عربی، اردو، فارسی، فرانسوی",
"feature4": "استفاده رایگان — بدون نیاز به حساب"
},
"nav": {
"home": "خانه",
"newChat": "گفتگوی جدید",
Expand Down
9 changes: 9 additions & 0 deletions web/messages/ur.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
{
"home": {
"tagline": "مصنوعی ذہانت کی مدد سے اسلامی سوال و جواب — قرآن اور صحیح سنت پر مبنی۔",
"cta": "پوچھنا شروع کریں",
"featuresLabel": "خصوصیات",
"feature1": "سامعین کے انداز: عمومی سوال و جواب، دعوت، سیکھنا",
"feature2": "اہل سنت والجماعت کے عقیدے کے اصول — صرف مستند علم",
"feature3": "کثیر لسانی معاونت بشمول عربی، اردو، فارسی، فرانسیسی",
"feature4": "استعمال مفت — کسی اکاؤنٹ کی ضرورت نہیں"
},
"nav": {
"home": "ہوم",
"newChat": "نئی گفتگو",
Expand Down
7 changes: 5 additions & 2 deletions web/src/components/DisclaimerBanner.astro
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@
"
>
<span aria-hidden="true" style="font-size: 0.75rem; flex-shrink: 0; margin-top: 2px;">⚠️</span>
<p style="margin: 0; font-size: 0.72rem; color: var(--brand-green-light); line-height: 1.45;">
<p style="margin: 0; font-size: 0.75rem; color: var(--brand-green-light); line-height: 1.45;">
<strong>AI-generated.</strong> Responses are for informational purposes only and are not fatwas
or authoritative religious rulings. Always consult a qualified Islamic scholar.
<a href="/legal/sharia-disclaimer" style="color: var(--brand-green-mid);">Learn more</a>
<a
href="/legal/sharia-disclaimer"
style="color: var(--brand-green-mid);"
>Read our Sharia content guidelines</a>
</p>
</div>
2 changes: 2 additions & 0 deletions web/src/islands/ChatIsland.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ function EmptyState() {
<div
role="status"
aria-label="Chat is ready"
data-testid="empty-state"
data-state="empty"
style={{ textAlign: 'center', padding: '2rem', color: 'var(--brand-green-mid)' }}
>
<p style={{ fontSize: '1rem' }}>
Expand Down
32 changes: 29 additions & 3 deletions web/src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import '../styles/global.css'
import { isRtlLocale } from '../lib/i18n'

export interface Props {
title: string
Expand All @@ -29,13 +30,15 @@ export interface Props {
const {
title,
description = 'AI-powered Islamic guidance — accurate, sourced, always available.',
locale = 'en',
locale: localeProp,
canonical,
ogImage,
} = Astro.props

const RTL_LOCALES = new Set(['ar', 'ur', 'fa'])
const isRtl = RTL_LOCALES.has(locale.split('-')[0] ?? 'en')
// Locale precedence: explicit prop → middleware-resolved (NEXT_LOCALE cookie /
// Accept-Language) → default. The cookie drives RTL locale switching in tests + UI.
const locale = localeProp ?? Astro.locals.locale ?? 'en'
const isRtl = isRtlLocale(locale)

const siteUrl = import.meta.env.PUBLIC_BASE_URL ?? 'https://chatislam.org'
const canonicalUrl = canonical ?? new URL(Astro.url.pathname, siteUrl).href
Expand All @@ -51,6 +54,29 @@ const umamiId = import.meta.env.PUBLIC_UMAMI_WEBSITE_ID ?? ''
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#1E5E2F" />

{/*
Locale bootstrap (before paint). Prerendered pages (/, dawah, donate, legal)
are built as static English HTML and cannot read the per-request NEXT_LOCALE
cookie on the server, so apply the user's locale to <html lang/dir> here.
Cookie-only: when no cookie is set we leave the server-rendered value intact
so SSR pages keep their Accept-Language-resolved locale.
*/}
<script is:inline>
(function () {
try {
var m = document.cookie.match(/(?:^|;\s*)NEXT_LOCALE=([^;]+)/)
if (!m) return
var loc = decodeURIComponent(m[1]).split('-')[0].toLowerCase()
if (['en', 'ar', 'ur', 'fa', 'fr', 'id', 'ms', 'tr', 'bn'].indexOf(loc) === -1) return
document.documentElement.lang = loc
document.documentElement.setAttribute(
'dir',
['ar', 'ur', 'fa', 'he', 'ckb'].indexOf(loc) !== -1 ? 'rtl' : 'ltr',
)
} catch (e) { /* no-op */ }
})()
</script>

<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalUrl} />
Expand Down
Loading
Loading