From 0714e5092540043955941873730dd828b7f09b35 Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Mon, 11 May 2026 18:54:40 -0500 Subject: [PATCH 01/22] First commit for the quran syntax change. --- .env.example | 9 +- .../StudyModeModal/StudyModeBody.tsx | 1 + .../StudyModeModal/StudyModeBodyTabs.tsx | 15 + .../StudyModeBottomActions/index.tsx | 1 + .../ReadingView/StudyModeModal/index.tsx | 1 + .../tabs/StudyModeSyntaxTab.tsx | 38 ++ .../QuranReader/SyntaxView/SyntaxBody.tsx | 193 ++++++++ .../SyntaxView/SyntaxChartTables.tsx | 392 ++++++++++++++++ .../SyntaxView/SyntaxSkeleton.module.scss | 11 + .../QuranReader/SyntaxView/SyntaxSkeleton.tsx | 21 + .../SyntaxView/SyntaxTabLayout.module.scss | 12 + .../SyntaxView/SyntaxTabLayout.tsx | 42 ++ .../SyntaxView/SyntaxView.module.scss | 291 ++++++++++++ .../useSyntaxChartArabicTypography.ts | 65 +++ src/components/Verse/VerseText.module.scss | 2 +- src/middleware.ts | 2 +- src/pages/api/syntax/analyze.ts | 426 ++++++++++++++++++ src/services/syntaxAnalysis.mock.ts | 260 +++++++++++ src/services/syntaxAnalysisService.ts | 64 +++ src/utils/url.ts | 11 + types/SyntaxAnalysis.ts | 100 ++++ 21 files changed, 1954 insertions(+), 3 deletions(-) create mode 100644 src/components/QuranReader/ReadingView/StudyModeModal/tabs/StudyModeSyntaxTab.tsx create mode 100644 src/components/QuranReader/SyntaxView/SyntaxBody.tsx create mode 100644 src/components/QuranReader/SyntaxView/SyntaxChartTables.tsx create mode 100644 src/components/QuranReader/SyntaxView/SyntaxSkeleton.module.scss create mode 100644 src/components/QuranReader/SyntaxView/SyntaxSkeleton.tsx create mode 100644 src/components/QuranReader/SyntaxView/SyntaxTabLayout.module.scss create mode 100644 src/components/QuranReader/SyntaxView/SyntaxTabLayout.tsx create mode 100644 src/components/QuranReader/SyntaxView/SyntaxView.module.scss create mode 100644 src/components/QuranReader/SyntaxView/useSyntaxChartArabicTypography.ts create mode 100644 src/pages/api/syntax/analyze.ts create mode 100644 src/services/syntaxAnalysis.mock.ts create mode 100644 src/services/syntaxAnalysisService.ts create mode 100644 types/SyntaxAnalysis.ts diff --git a/.env.example b/.env.example index ffee271e11..eeb92cb430 100644 --- a/.env.example +++ b/.env.example @@ -30,4 +30,11 @@ NEXT_PUBLIC_QURAN_REFLECT_URL=https://quranreflect.com NEXT_PUBLIC_SSO_ENABLED=false -NEXT_PUBLIC_EMBED_URL=https://quran.com/embed/v1 # Embed Ayah \ No newline at end of file +NEXT_PUBLIC_EMBED_URL=https://quran.com/embed/v1 # Embed Ayah + +# Optional: Syntax tab morphology analysis (OpenAI Chat Completions from /api/syntax/analyze) +# OPENAI_API_KEY= +# SYNTAX_ANALYSIS_MODEL=gpt-4o-mini +# +# Set to true to skip API/OpenAI — uses pasted JSON from src/services/syntaxAnalysis.mock.ts +# NEXT_PUBLIC_SYNTAX_ANALYSIS_MOCK=true \ No newline at end of file diff --git a/src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBody.tsx b/src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBody.tsx index f21a174757..6f364b740a 100644 --- a/src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBody.tsx +++ b/src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBody.tsx @@ -113,6 +113,7 @@ const StudyModeBody: React.FC = ({ (
@@ -36,6 +38,10 @@ export const StudyModeTafsirTab = dynamic(() => import('./tabs/StudyModeTafsirTa loading: Loading, }); +export const StudyModeSyntaxTab = dynamic(() => import('./tabs/StudyModeSyntaxTab'), { + loading: Loading, +}); + export const StudyModeReflectionsTab = dynamic(() => import('./tabs/StudyModeReflectionsTab'), { loading: Loading, }); @@ -68,6 +74,7 @@ export const StudyModeRelatedVersesTab = dynamic( interface TabProps { chapterId: string; verseNumber: string; + selectedWord?: Word; switchTab?: (tabId: StudyModeTabId | null) => void; questionId?: string; questionsInitialData?: AyahQuestionsResponse; @@ -79,6 +86,7 @@ interface TabProps { export const TAB_COMPONENTS: Partial>> = { [StudyModeTabId.TAFSIR]: StudyModeTafsirTab, + [StudyModeTabId.SYNTAX]: StudyModeSyntaxTab, [StudyModeTabId.LAYERS]: StudyModeLayersTab, [StudyModeTabId.REFLECTIONS]: StudyModeReflectionsTab, [StudyModeTabId.LESSONS]: StudyModeLessonsTab, @@ -156,6 +164,13 @@ export const useStudyModeTabs = ({ onClick: () => handleTabClick(StudyModeTabId.TAFSIR), condition: true, }, + { + id: StudyModeTabId.SYNTAX, + label: 'Syntax', + icon: , + onClick: () => handleTabClick(StudyModeTabId.SYNTAX), + condition: true, + }, { id: StudyModeTabId.LAYERS, label: t('quran-reader:layers.title'), diff --git a/src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBottomActions/index.tsx b/src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBottomActions/index.tsx index 9ed89447fb..e1950b8458 100644 --- a/src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBottomActions/index.tsx +++ b/src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBottomActions/index.tsx @@ -13,6 +13,7 @@ import { AudioPlayerMachineContext } from 'src/xstate/AudioPlayerMachineContext' export enum StudyModeTabId { TAFSIR = 'tafsir', + SYNTAX = 'syntax', LAYERS = 'layers', LESSONS = 'lessons', REFLECTIONS = 'reflections', diff --git a/src/components/QuranReader/ReadingView/StudyModeModal/index.tsx b/src/components/QuranReader/ReadingView/StudyModeModal/index.tsx index ad2875db61..cb8d0223b1 100644 --- a/src/components/QuranReader/ReadingView/StudyModeModal/index.tsx +++ b/src/components/QuranReader/ReadingView/StudyModeModal/index.tsx @@ -412,6 +412,7 @@ const StudyModeModal: React.FC = ({ activeContentTab && [ StudyModeTabId.TAFSIR, + StudyModeTabId.SYNTAX, StudyModeTabId.LAYERS, StudyModeTabId.REFLECTIONS, StudyModeTabId.LESSONS, diff --git a/src/components/QuranReader/ReadingView/StudyModeModal/tabs/StudyModeSyntaxTab.tsx b/src/components/QuranReader/ReadingView/StudyModeModal/tabs/StudyModeSyntaxTab.tsx new file mode 100644 index 0000000000..49816d4f62 --- /dev/null +++ b/src/components/QuranReader/ReadingView/StudyModeModal/tabs/StudyModeSyntaxTab.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import dynamic from 'next/dynamic'; + +import SyntaxSkeleton from '@/components/QuranReader/SyntaxView/SyntaxSkeleton'; +import { useSyntaxTabScroll, syntaxTabStyles as styles } from '@/components/QuranReader/SyntaxView/SyntaxTabLayout'; +import Word from '@/types/Word'; + +const SyntaxBody = dynamic(() => import('@/components/QuranReader/SyntaxView/SyntaxBody'), { + loading: SyntaxSkeleton, +}); + +interface StudyModeSyntaxTabProps { + chapterId: string; + verseNumber: string; + selectedWord?: Word; +} + +const StudyModeSyntaxTab: React.FC = ({ + chapterId, + verseNumber, + selectedWord, +}) => { + const { containerRef, scrollToTop } = useSyntaxTabScroll(); + + return ( +
+ +
+ ); +}; + +export default StudyModeSyntaxTab; diff --git a/src/components/QuranReader/SyntaxView/SyntaxBody.tsx b/src/components/QuranReader/SyntaxView/SyntaxBody.tsx new file mode 100644 index 0000000000..54358eec78 --- /dev/null +++ b/src/components/QuranReader/SyntaxView/SyntaxBody.tsx @@ -0,0 +1,193 @@ +/* eslint-disable max-lines */ +/* eslint-disable i18next/no-literal-string */ +/* eslint-disable jsdoc/require-returns */ +/* eslint-disable no-void */ +/* eslint-disable no-nested-ternary */ +/* eslint-disable react/no-array-index-key */ +import React from 'react'; + +import classNames from 'classnames'; +import { useSelector, shallowEqual } from 'react-redux'; +import useSWR from 'swr'; + +import { SyntaxAnalysisCharts } from './SyntaxChartTables'; +import SyntaxTabLayout from './SyntaxTabLayout'; +import styles from './SyntaxView.module.scss'; +import useSyntaxChartArabicTypography from './useSyntaxChartArabicTypography'; + +import { selectQuranReaderStyles } from '@/redux/slices/QuranReader/styles'; +import { fetchSyntaxAnalysis, getWordTextUthmaniForSyntax } from '@/services/syntaxAnalysisService'; +import Word from '@/types/Word'; + +interface SyntaxBodyProps { + chapterId: string; + verseNumber: string; + selectedWord?: Word; + scrollToTop: () => void; +} + +/** + * Syntax tab body — layout and font scaling match TafsirBody (tafsirFontScale + generate-font-scales). + * Morphology JSON comes from `/api/syntax/analyze` (OpenAI when configured). + */ +const SyntaxBody: React.FC = (props) => { + const { selectedWord, scrollToTop, chapterId, verseNumber } = props; + void scrollToTop; + void chapterId; + void verseNumber; + + const quranReaderStyles = useSelector(selectQuranReaderStyles, shallowEqual); + const { tafsirFontScale } = quranReaderStyles; + const { arabicTypographyStyle, verbTypographyClassName } = useSyntaxChartArabicTypography(); + + const wordText = selectedWord ? getWordTextUthmaniForSyntax(selectedWord) : ''; + const verseKey = selectedWord?.verseKey; + + const swrKey = + selectedWord && wordText + ? ['syntax-analysis', selectedWord.location ?? '', wordText, verseKey ?? ''] + : null; + + const { + data: analysis, + error, + isValidating, + } = useSWR( + swrKey, + () => + fetchSyntaxAnalysis({ + textUthmani: wordText, + verseKey, + }), + { revalidateOnFocus: false }, + ); + + const showAnalysisLoading = Boolean(swrKey && isValidating && !analysis && !error); + + let analysisErrorMessage: string | null = null; + if (error instanceof Error) { + analysisErrorMessage = error.message; + } else if (error) { + analysisErrorMessage = String(error); + } + + return ( + +
+

+ Grammatical Analysis of the word:{' '} + {wordText ? ( + + {wordText} + + ) : ( + No script text on this token — pick a word token. + )} +

+ +

+ {selectedWord?.translation?.text || 'None selected'} -{' '} + {selectedWord?.transliteration?.text || 'None selected'} +

+ +
+

Morphology

+ {!selectedWord && ( +

Select a word in the verse to analyze.

+ )} + {selectedWord && !wordText && ( +

This token has no Uthmani text field.

+ )} + {showAnalysisLoading &&

Loading analysis…

} + {analysisErrorMessage && ( +

+ {analysisErrorMessage} +

+ )} + {analysis && ( + <> +
+ Root letters: +

+ + {analysis.rootLetter.rootLetter} + +

+
+
+ Pattern + +

+

+ {analysis.pattern.wordPattern} + {' '} + — + + {analysis.pattern.patternType} + +

+ +

+ + + + +
+
+ Word breakdown +
    + {analysis.wordBreakDown.map((row, idx) => ( +
  • + + {row.part} + {' '} + — {row.meaning} +
  • + ))} +
+
+ +
{JSON.stringify(analysis, null, 2)}
+ + )} +
+ + {selectedWord && ( +
+ Raw word payload +
{JSON.stringify(selectedWord, null, 2)}
+
+ )} +
+
+ } + /> + ); +}; + +export default SyntaxBody; diff --git a/src/components/QuranReader/SyntaxView/SyntaxChartTables.tsx b/src/components/QuranReader/SyntaxView/SyntaxChartTables.tsx new file mode 100644 index 0000000000..f47609b646 --- /dev/null +++ b/src/components/QuranReader/SyntaxView/SyntaxChartTables.tsx @@ -0,0 +1,392 @@ +/* eslint-disable max-lines */ +/* eslint-disable react/no-multi-comp */ +/* eslint-disable i18next/no-literal-string */ +import React from 'react'; + +import classNames from 'classnames'; + +import styles from './SyntaxView.module.scss'; +import useSyntaxChartArabicTypography from './useSyntaxChartArabicTypography'; + +import type { + SyntaxAnalysisIsmChart, + SyntaxAnalysisSarfChart, + SyntaxAnalysisSarfColumnKey, + SyntaxAnalysisVerbChart, + SyntaxAnalysisVerbSlot, +} from 'types/SyntaxAnalysis'; + +const VERB_PERSON_LABELS: Record = { + '3rdPersonMasculine': 'Masculine 3rd person', + '3rdPersonFeminine': 'Feminine 3rd person', + '2ndPersonMasculine': 'Masculine 2nd person', + '2ndPersonFeminine': 'Feminine 2nd person', + '1stPerson': '1st Person', +}; + +const ISM_CASES = ['Rafa', 'Nasab', 'Jar'] as const; + +/** Sarf columns — with `dir="rtl"` on the table, first cell is Past (visual right). */ +const SYNTAX_SARF_COLUMN_ORDER: SyntaxAnalysisSarfColumnKey[] = [ + 'pastTense', + 'presentTense', + 'idea', + 'doer', +]; + +/** Stable row order for verb conjugation table */ +const VERB_CHART_ORDER: (keyof SyntaxAnalysisVerbChart)[] = [ + '3rdPersonMasculine', + '3rdPersonFeminine', + '2ndPersonMasculine', + '2ndPersonFeminine', + '1stPerson', +]; + +function VerbFormCell({ + slot, + colSpan, + verbTypographyClassName, + arabicTypographyStyle, +}: { + slot: SyntaxAnalysisVerbSlot; + colSpan?: number; + verbTypographyClassName: string; + arabicTypographyStyle: React.CSSProperties; +}) { + return ( + +
+ {slot.verb} +
+ + ); +} + +function MeaningCell({ + slot, + meaningTypographyClassName, + arabicTypographyStyle, +}: { + slot: SyntaxAnalysisVerbSlot; + meaningTypographyClassName: string; + arabicTypographyStyle: React.CSSProperties; +}) { + return ( + +
+ {slot.meaning} + + {slot.pronoun} + +
+ + ); +} + +type Props = { + verbChart?: SyntaxAnalysisVerbChart; + verbPresentTenseChart?: SyntaxAnalysisVerbChart; + verbPastTenseChart?: SyntaxAnalysisVerbChart; + ismChart?: SyntaxAnalysisIsmChart; + sarfChart?: SyntaxAnalysisSarfChart; +}; + +export const SyntaxVerbChartTable: React.FC<{ + chart: SyntaxAnalysisVerbChart; + title: string; +}> = ({ chart, title }) => { + const { arabicTypographyStyle, verbTypographyClassName, meaningTypographyClassName } = + useSyntaxChartArabicTypography(); + + const sections: React.ReactNode[] = []; + + VERB_CHART_ORDER.forEach((personKey) => { + if (!(personKey in chart)) return; + const block = chart[personKey]; + const personLabel = VERB_PERSON_LABELS[personKey]; + + if (personKey === '1stPerson') { + const sg = block.singular; + const pl = block.plural; + if (!sg || !pl) return; + + sections.push( + + + +
+ {pl.meaning} + + {pl.pronoun} + +
+ + + + {personLabel} + + + + + + +
, + ); + return; + } + + const pl = block.plural; + const du = block.dual; + const sg = block.singular; + if (!pl || !du || !sg) return; + + sections.push( + + + + + + + {personLabel} + + + + + + + + , + ); + }); + + if (sections.length === 0) return null; + + return ( +
+

{title}

+
+ + + + + + + + + {sections} +
PluralPairSingular +
+
+
+ ); +}; + +export const SyntaxIsmChartTable: React.FC<{ chart: SyntaxAnalysisIsmChart }> = ({ chart }) => { + const { arabicTypographyStyle, verbTypographyClassName } = useSyntaxChartArabicTypography(); + + return ( +
+

Ism chart (declension)

+
+ + + + + + + + + + + + + + + + + + {ISM_CASES.map((caseName) => ( + + + + + + + + + + ))} + +
+ Feminine + + Masculine + Case
PluralDualSingularPluralDualSingular +
+ + {chart.Feminine[caseName].plural} + + + + {chart.Feminine[caseName].dual} + + + + {chart.Feminine[caseName].singular} + + + + {chart.Masculine[caseName].plural} + + + + {chart.Masculine[caseName].dual} + + + + {chart.Masculine[caseName].singular} + + {caseName}
+
+
+ ); +}; + +export const SyntaxSarfChartTable: React.FC<{ chart: SyntaxAnalysisSarfChart }> = ({ chart }) => { + const { arabicTypographyStyle, verbTypographyClassName } = useSyntaxChartArabicTypography(); + + const headerCells = SYNTAX_SARF_COLUMN_ORDER.map((key) => ( + + {chart.columnHeaders[key]} + + )); + + const labelCells = (get: (k: SyntaxAnalysisSarfColumnKey) => string | undefined) => + SYNTAX_SARF_COLUMN_ORDER.map((key) => ( + + {get(key) ?? ''} + + )); + + const formCells = (get: (k: SyntaxAnalysisSarfColumnKey) => string | undefined) => + SYNTAX_SARF_COLUMN_ORDER.map((key) => { + const text = get(key)?.trim(); + if (!text) { + return ; + } + return ( + +
+ {text} +
+ + ); + }); + + return ( +
+

Sarf chart

+
+ + + {headerCells} + + + {formCells((k) => chart.activeVoice[k])} + {labelCells((k) => chart.passiveVoiceLabels[k])} + {formCells((k) => chart.passiveVoiceForms[k])} + {labelCells((k) => chart.commandingLabels[k])} + {formCells((k) => chart.commandingForms[k])} + +
+
+
+ ); +}; + +export const SyntaxAnalysisCharts: React.FC = ({ + verbChart, + verbPresentTenseChart, + verbPastTenseChart, + ismChart, + sarfChart, +}) => ( + <> + {sarfChart && } + {verbPresentTenseChart && ( + + )} + {verbPastTenseChart && ( + + )} + {verbChart && ( + + )} + {ismChart && } + +); diff --git a/src/components/QuranReader/SyntaxView/SyntaxSkeleton.module.scss b/src/components/QuranReader/SyntaxView/SyntaxSkeleton.module.scss new file mode 100644 index 0000000000..67e1688277 --- /dev/null +++ b/src/components/QuranReader/SyntaxView/SyntaxSkeleton.module.scss @@ -0,0 +1,11 @@ +.syntaxSkeletonItem { + width: 40%; + height: 1.25rem; + margin-bottom: 1rem; +} + +.syntaxSkeletonLine { + width: 100%; + height: 0.875rem; + margin-bottom: 0.75rem; +} diff --git a/src/components/QuranReader/SyntaxView/SyntaxSkeleton.tsx b/src/components/QuranReader/SyntaxView/SyntaxSkeleton.tsx new file mode 100644 index 0000000000..8810be24c6 --- /dev/null +++ b/src/components/QuranReader/SyntaxView/SyntaxSkeleton.tsx @@ -0,0 +1,21 @@ +import range from 'lodash/range'; + +import Skeleton from '@/dls/Skeleton/Skeleton'; + +import styles from './SyntaxSkeleton.module.scss'; + +/** + * Loading placeholder for Syntax view content (Study Mode dynamic import). + */ +const SyntaxSkeleton = () => { + return ( + <> + + {range(1, 12).map((i) => ( + + ))} + + ); +}; + +export default SyntaxSkeleton; diff --git a/src/components/QuranReader/SyntaxView/SyntaxTabLayout.module.scss b/src/components/QuranReader/SyntaxView/SyntaxTabLayout.module.scss new file mode 100644 index 0000000000..9901cd7b0b --- /dev/null +++ b/src/components/QuranReader/SyntaxView/SyntaxTabLayout.module.scss @@ -0,0 +1,12 @@ +.container { + overflow: hidden; +} + +.content { + display: flex; + flex-direction: column; + position: relative; +} + +.bodyContainer { +} diff --git a/src/components/QuranReader/SyntaxView/SyntaxTabLayout.tsx b/src/components/QuranReader/SyntaxView/SyntaxTabLayout.tsx new file mode 100644 index 0000000000..e4765632c0 --- /dev/null +++ b/src/components/QuranReader/SyntaxView/SyntaxTabLayout.tsx @@ -0,0 +1,42 @@ +import React, { useRef, useCallback, ReactNode } from 'react'; + +import { FontSizeType } from '@/components/QuranReader/ReadingView/StudyModeModal/FontSizeControl'; +import StudyModeControlsHeader from '@/components/QuranReader/ReadingView/StudyModeModal/StudyModeControlsHeader'; + +import styles from './SyntaxTabLayout.module.scss'; + +interface SyntaxTabLayoutProps { + selectionControl: ReactNode; + body: ReactNode; + fontType?: FontSizeType; +} + +/** + * Layout for Syntax (grammar analytics) content in Study Mode — mirrors StudyModeTabLayout pattern. + */ +const SyntaxTabLayout: React.FC = ({ + selectionControl, + body, + fontType = 'tafsir', +}) => { + return ( +
+ +
{body}
+
+ ); +}; + +export default SyntaxTabLayout; + +export const useSyntaxTabScroll = () => { + const containerRef = useRef(null); + + const scrollToTop = useCallback(() => { + containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); + + return { containerRef, scrollToTop }; +}; + +export { styles as syntaxTabStyles }; diff --git a/src/components/QuranReader/SyntaxView/SyntaxView.module.scss b/src/components/QuranReader/SyntaxView/SyntaxView.module.scss new file mode 100644 index 0000000000..3680ca6007 --- /dev/null +++ b/src/components/QuranReader/SyntaxView/SyntaxView.module.scss @@ -0,0 +1,291 @@ +@use 'src/styles/utility'; + +.syntaxPageContainer { + max-inline-size: 100%; + margin-block-start: 0; + margin-block-end: 0; + margin-inline-start: 0; + margin-inline-end: 0; +} + +.syntaxContainer { + @include utility.edgeHorizontalPadding; + padding-block-start: var(--spacing-medium); + padding-block-end: var(--spacing-large); + letter-spacing: 0; + line-height: normal; + p { + margin-block-end: var(--spacing-small); + opacity: var(--opacity-85); + line-height: normal; + } + br { + content: ''; + margin-block-start: var(--spacing-small); + margin-inline-end: var(--spacing-small); + margin-block-end: var(--spacing-small); + margin-inline-start: var(--spacing-small); + display: block; + } + h2 { + font-weight: var(--font-weight-semibold); + font-size: var(--font-size-xlarge); + margin-block-end: var(--spacing-small); + margin-block-start: calc(1.5 * var(--spacing-medium)); + &:first-child { + margin-block-start: 0; + } + } + * { + letter-spacing: 0; + line-height: normal; + } + *[class~='arabic'] { + direction: rtl; + /* stylelint-disable-next-line csstools/use-logical */ + text-align: right; + margin-block-end: var(--spacing-small); + } + *[class~='uthmani'] { + font-family: UthmanicHafs; + font-size: larger; + } +} + +.syntaxSectionTitle { + border-block-start: 1px solid var(--color-borders-hairline); + font-size: var(--font-size-xlarge); + font-weight: var(--font-weight-bold); + padding-block-start: var(--spacing-medium); + padding-block-end: var(--spacing-xxsmall); + padding-inline-start: 0; + padding-inline-end: 0; + margin-block-start: var(--spacing-xxsmall); + margin-inline-start: 0; + margin-inline-end: 0; +} + +.syntaxMetaRow { + margin-block-end: var(--spacing-small); +} + +.syntaxWordJson { + margin-block-start: var(--spacing-medium); + margin-block-end: 0; + padding: var(--spacing-small); + border: 1px solid var(--color-borders-hairline); + border-radius: var(--border-radius-default); + background-color: var(--color-background-default); + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.syntaxAnalysisSection { + margin-block-start: var(--spacing-medium); + padding-block-start: var(--spacing-small); + border-block-start: 1px solid var(--color-borders-hairline); +} + +.syntaxAnalysisHeading { + font-size: var(--font-size-large); + font-weight: var(--font-weight-semibold); + margin-block-end: var(--spacing-small); +} + +.syntaxAnalysisBlock { + margin-block-end: var(--spacing-medium); + + strong { + display: block; + margin-block-end: var(--spacing-xxsmall); + } +} + +.syntaxMorphSubheading { + margin-block-end: var(--spacing-xxsmall); + margin-block-start: var(--spacing-xsmall); + font-size: var(--font-size-xsmall); + font-weight: var(--font-weight-medium); + opacity: var(--opacity-75); + + &:first-of-type { + margin-block-start: 0; + } +} + +.syntaxPatternWordPattern { + margin-block-start: 0; + margin-block-end: 0; + line-height: 1.45; + opacity: var(--opacity-85); +} + +.syntaxBreakdownList { + margin: 0; + padding-inline-start: var(--spacing-medium); +} + +.syntaxMuted { + opacity: var(--opacity-75); + font-style: normal; +} + +.syntaxError { + color: var(--color-warning-medium); + margin-block-end: var(--spacing-small); +} + +.syntaxRawDetails { + margin-block-start: var(--spacing-large); + + summary { + cursor: pointer; + margin-block-end: var(--spacing-small); + } +} + +.syntaxTableSection { + margin-block-start: var(--spacing-large); + margin-block-end: var(--spacing-medium); +} + +.syntaxTableTitle { + font-size: var(--font-size-normal); + font-weight: var(--font-weight-semibold); + margin-block-end: var(--spacing-small); +} + +.syntaxTableScroll { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.syntaxTable { + inline-size: 100%; + min-inline-size: min(100%, 32rem); + border-collapse: collapse; + font-size: var(--font-size-small); + + th, + td { + border: 1px solid var(--color-borders-hairline); + padding: var(--spacing-xxsmall) var(--spacing-xsmall); + text-align: start; + vertical-align: top; + } + + thead th { + background-color: var(--color-background-alternative-faint); + font-weight: var(--font-weight-semibold); + } + + tbody th[scope='row'] { + font-weight: var(--font-weight-medium); + background-color: var(--color-background-alternative-faint); + white-space: nowrap; + } +} + +/* Verb conjugation grid (plural | dual | singular | person) */ +.syntaxVerbChartTable { + min-inline-size: min(100%, 36rem); + + th, + td { + border: 1px solid var(--color-text-default); + vertical-align: middle; + } + + thead th { + text-align: center; + background-color: var(--color-background-alternative-faded); + } +} + +.syntaxVerbChartPersonCell { + inline-size: 6.5rem; + text-align: center; + font-weight: var(--font-weight-semibold); + font-size: var(--font-size-xsmall); + line-height: var(--line-height-normal); + background-color: var(--color-background-alternative-faded); +} + +.syntaxVerbChartMeaningCell { + background-color: var(--color-background-alternative-faded); + padding: var(--spacing-xsmall); +} + +.syntaxVerbChartMeaningInner { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: var(--spacing-xsmall); +} + +.syntaxVerbChartMeaningEn { + text-align: start; + flex: 1; + min-inline-size: 0; +} + +.syntaxVerbChartMeaningPronoun { + flex-shrink: 0; +} + +/* Meaning-row Arabic: half of verb-row computed `--font-size` from chart scale classes */ +.syntaxVerbChartMeaningArabic { + font-size: calc(0.6 * var(--font-size)); +} + +.syntaxVerbChartVerbCell { + background-color: var(--color-background-default); + text-align: center; + padding-block: var(--spacing-small); + padding-inline: var(--spacing-xsmall); +} + +.syntaxVerbChartVerbInner { + line-height: var(--line-height, normal); +} + +/* Sarf chart (verb morphology grid) */ +.syntaxSarfChartTable { + min-inline-size: min(100%, 40rem); + + th, + td { + border: 1px solid var(--color-text-default); + vertical-align: middle; + text-align: center; + } +} + +.syntaxSarfChartHeaderCell { + background-color: var(--color-success-medium); + color: var(--color-text-inverse); + font-weight: var(--font-weight-semibold); + font-size: var(--font-size-small); + padding-block: var(--spacing-small); +} + +.syntaxSarfChartLabelCell { + background-color: var(--color-background-alternative-faded); + font-size: var(--font-size-xsmall); + line-height: var(--line-height-normal); + padding-block: var(--spacing-xsmall); +} + +.syntaxSarfChartDataCell { + background-color: var(--color-background-default); + padding-block: var(--spacing-medium); + padding-inline: var(--spacing-xsmall); +} + +.syntaxSarfChartArabic { + line-height: var(--line-height, normal); +} + +@include utility.generate-font-scales('tafsir'); diff --git a/src/components/QuranReader/SyntaxView/useSyntaxChartArabicTypography.ts b/src/components/QuranReader/SyntaxView/useSyntaxChartArabicTypography.ts new file mode 100644 index 0000000000..0a0e919cc7 --- /dev/null +++ b/src/components/QuranReader/SyntaxView/useSyntaxChartArabicTypography.ts @@ -0,0 +1,65 @@ +import { useMemo } from 'react'; + +import classNames from 'classnames'; +import { shallowEqual, useSelector } from 'react-redux'; + +import styles from './SyntaxView.module.scss'; + +import textWordStyles from '@/components/dls/QuranWord/TextWord.module.scss'; +import verseTextStyles from '@/components/Verse/VerseText.module.scss'; +import { selectQuranReaderStyles } from '@/redux/slices/QuranReader/styles'; +import { getFontClassName, getFontFaceNameForPage } from '@/utils/fontFaceHelper'; +import { QuranFont } from 'types/QuranReader'; + +/** Madani V1 / page 1 QCF face — matches `p1-v1` glyph fonts used in the reader. */ +const SYNTAX_CHART_ARABIC_FONT = QuranFont.MadaniV1; +const SYNTAX_CHART_ARABIC_PAGE = 1; + +/** + * Shared Arabic typography for Syntax charts and inline Arabic in SyntaxBody: + * same scale as Quran text (user setting) + `tafsirOrTranslationMode`, with QCF font `p1-v1`. + * + * @returns {{ + * arabicFontFamily: string; + * arabicTypographyStyle: import('react').CSSProperties; + * verbTypographyClassName: string; + * meaningTypographyClassName: string; + * }} Typography classes and inline `fontFamily` for Syntax Arabic (`p1-v1`). + */ +export default function useSyntaxChartArabicTypography() { + const { quranTextFontScale, mushafLines } = useSelector(selectQuranReaderStyles, shallowEqual); + + const arabicFontFamily = useMemo( + () => getFontFaceNameForPage(SYNTAX_CHART_ARABIC_FONT, SYNTAX_CHART_ARABIC_PAGE), + [], + ); + + const arabicTypographyStyle = useMemo( + () => ({ fontFamily: arabicFontFamily }), + [arabicFontFamily], + ); + + const verbTypographyClassName = useMemo( + () => + classNames( + textWordStyles.word, + verseTextStyles.tafsirOrTranslationMode, + verseTextStyles[ + getFontClassName(SYNTAX_CHART_ARABIC_FONT, quranTextFontScale, mushafLines) + ], + ), + [mushafLines, quranTextFontScale], + ); + + const meaningTypographyClassName = useMemo( + () => classNames(verbTypographyClassName, styles.syntaxVerbChartMeaningArabic), + [verbTypographyClassName], + ); + + return { + arabicFontFamily, + arabicTypographyStyle, + verbTypographyClassName, + meaningTypographyClassName, + }; +} diff --git a/src/components/Verse/VerseText.module.scss b/src/components/Verse/VerseText.module.scss index 028c17bbe8..4111a7cba2 100644 --- a/src/components/Verse/VerseText.module.scss +++ b/src/components/Verse/VerseText.module.scss @@ -89,5 +89,5 @@ // On mobile, make the QuranWord slightly bigger on translation or tafsir mode .tafsirOrTranslationMode { // --font-size is generated by `utility.genereate-font-sizes` - font-size: calc(1.2 * var(--font-size)); + font-size: calc(0.75 * var(--font-size)); } diff --git a/src/middleware.ts b/src/middleware.ts index f77dbc759a..cf5535c9f8 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; export default function middleware(req: NextRequest) { // If the request is for _next/data, return a 404 response // This forces a full page reload when a new deployment is made - if (req.url.includes('_next/data')) { + if (process.env.NODE_ENV === 'production' && req.url.includes('_next/data')) { return new NextResponse(null, { status: 404 }); } diff --git a/src/pages/api/syntax/analyze.ts b/src/pages/api/syntax/analyze.ts new file mode 100644 index 0000000000..c46ca9c038 --- /dev/null +++ b/src/pages/api/syntax/analyze.ts @@ -0,0 +1,426 @@ +/* eslint-disable max-lines */ +/* eslint-disable react-func/max-lines-per-function */ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { NextApiRequest, NextApiResponse } from 'next'; + +import type { + SyntaxAnalysisIsmChart, + SyntaxAnalysisResult, + SyntaxAnalysisSarfChart, + SyntaxAnalysisSarfColumnKey, + SyntaxAnalysisVerbChart, + SyntaxAnalysisVerbSlot, +} from 'types/SyntaxAnalysis'; + +type ErrorBody = { error: string }; + +const MAX_WORD_LENGTH = 200; + +function extractJsonFromContent(content: string): unknown { + const trimmed = content.trim(); + const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)```$/m); + const jsonStr = fenced ? fenced[1].trim() : trimmed; + return JSON.parse(jsonStr); +} + +const SYNTAX_SARF_KEYS: SyntaxAnalysisSarfColumnKey[] = [ + 'pastTense', + 'presentTense', + 'idea', + 'doer', +]; + +function isSarfFullColumnStrings(v: unknown): v is Record { + if (!v || typeof v !== 'object') return false; + const o = v as Record; + return SYNTAX_SARF_KEYS.every((k) => typeof o[k] === 'string'); +} + +function normalizeSarfChart(raw: unknown): SyntaxAnalysisSarfChart | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const s = raw as Record; + if (!isSarfFullColumnStrings(s.columnHeaders) || !isSarfFullColumnStrings(s.activeVoice)) { + return undefined; + } + const partialStrings = (key: string): Partial> => { + const v = s[key]; + if (!v || typeof v !== 'object') return {}; + const o = v as Record; + const entries = SYNTAX_SARF_KEYS.filter((k) => typeof o[k] === 'string').map((k) => [ + k, + o[k], + ]) as [SyntaxAnalysisSarfColumnKey, string][]; + return Object.fromEntries(entries) as Partial>; + }; + return { + columnHeaders: s.columnHeaders, + activeVoice: s.activeVoice, + passiveVoiceLabels: partialStrings('passiveVoiceLabels'), + passiveVoiceForms: partialStrings('passiveVoiceForms'), + commandingLabels: partialStrings('commandingLabels'), + commandingForms: partialStrings('commandingForms'), + }; +} + +const ISM_CASE_KEYS = ['Rafa', 'Nasab', 'Jar'] as const; +const ISM_NUMBER_KEYS = ['singular', 'dual', 'plural'] as const; + +function normalizeIsmChart(raw: unknown): SyntaxAnalysisIsmChart | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const chart = raw as Record; + const valid = (['Masculine', 'Feminine'] as const).every((gender) => { + const g = chart[gender]; + if (!g || typeof g !== 'object') return false; + const go = g as Record; + return ISM_CASE_KEYS.every((caseName) => { + const row = go[caseName]; + if (!row || typeof row !== 'object') return false; + const ro = row as Record; + return ISM_NUMBER_KEYS.every((num) => typeof ro[num] === 'string'); + }); + }); + return valid ? (raw as SyntaxAnalysisIsmChart) : undefined; +} + +const VERB_CHART_PERSON_KEYS = [ + '3rdPersonMasculine', + '3rdPersonFeminine', + '2ndPersonMasculine', + '2ndPersonFeminine', +] as const; + +/** Full iteration order including 1st person (not part of the four person-number rows). */ +const VERB_CHART_ALL_KEYS: readonly (keyof SyntaxAnalysisVerbChart)[] = [ + ...VERB_CHART_PERSON_KEYS, + '1stPerson', +]; + +function isVerbSlot(v: unknown): v is { pronoun: string; meaning: string; verb: string } { + if (!v || typeof v !== 'object') return false; + const o = v as Record; + return ( + typeof o.pronoun === 'string' && typeof o.meaning === 'string' && typeof o.verb === 'string' + ); +} + +/** + * Strict grid: every person row must be complete (otherwise the whole chart is dropped). + * @returns {SyntaxAnalysisVerbChart | undefined} Parsed verb chart, or undefined if invalid. + */ +function normalizeVerbChartStrict(raw: unknown): SyntaxAnalysisVerbChart | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const o = raw as Record; + const fourOk = VERB_CHART_PERSON_KEYS.every((key) => { + const block = o[key]; + if (!block || typeof block !== 'object') return false; + const b = block as Record; + return (['singular', 'dual', 'plural'] as const).every((num) => isVerbSlot(b[num])); + }); + if (!fourOk) return undefined; + const first = o['1stPerson']; + if (!first || typeof first !== 'object') return undefined; + const fb = first as Record; + if (!isVerbSlot(fb.singular) || !isVerbSlot(fb.plural)) return undefined; + return raw as SyntaxAnalysisVerbChart; +} + +/** + * Keep every person block that is individually valid so one bad row (e.g. incomplete 1st person) + * does not strip the entire present/past chart from the API response. + * @returns {SyntaxAnalysisVerbChart | undefined} Chart with only valid person blocks, or undefined. + */ +function normalizeVerbChartBestEffort(raw: unknown): SyntaxAnalysisVerbChart | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const o = raw as Record; + const out: Partial = {}; + + VERB_CHART_ALL_KEYS.forEach((personKey) => { + const block = o[personKey]; + if (!block || typeof block !== 'object') return; + const b = block as Record; + + if (personKey === '1stPerson') { + if (!isVerbSlot(b.singular) || !isVerbSlot(b.plural)) return; + out['1stPerson'] = { + singular: b.singular as SyntaxAnalysisVerbSlot, + plural: b.plural as SyntaxAnalysisVerbSlot, + }; + return; + } + + const sg = b.singular; + const du = b.dual; + const pl = b.plural; + if (!isVerbSlot(sg) || !isVerbSlot(du) || !isVerbSlot(pl)) return; + out[personKey] = { + singular: sg as SyntaxAnalysisVerbSlot, + dual: du as SyntaxAnalysisVerbSlot, + plural: pl as SyntaxAnalysisVerbSlot, + }; + }); + + if (Object.keys(out).length === 0) return undefined; + return out as SyntaxAnalysisVerbChart; +} + +function normalizeVerbChart(raw: unknown): SyntaxAnalysisVerbChart | undefined { + return normalizeVerbChartStrict(raw) ?? normalizeVerbChartBestEffort(raw); +} + +const VERB_PRESENT_CHART_JSON_KEYS = [ + 'verbPresentTenseChart', + 'verbPresentChart', + 'presentTenseVerbChart', +] as const; + +function pickFirstVerbChartRaw(r: Record, keys: readonly string[]): unknown { + const key = keys.find((k) => { + const v = r[k]; + return Boolean(v && typeof v === 'object'); + }); + return key ? r[key] : undefined; +} + +function verbTensePatternHints( + patternType: string, + wordPattern: string, +): { + looksPresent: boolean; + looksPast: boolean; +} { + const looksPresent = + /مضارع/.test(patternType) || /\b(imperfect|present\s+tense|\bpresent\b)/i.test(wordPattern); + const looksPast = + /ماض[يى]/.test(patternType) || /\b(perfect|past\s+tense|\bpast\b)/i.test(wordPattern); + return { looksPresent, looksPast }; +} + +function normalizeResult(raw: unknown): SyntaxAnalysisResult | null { + if (!raw || typeof raw !== 'object') return null; + const r = raw as Record; + const root = r.rootLetter as Record | undefined; + const pattern = r.pattern as Record | undefined; + const breakdown = r.wordBreakDown; + + if ( + !root || + typeof root.arabicName !== 'string' || + typeof root.rootLetter !== 'string' || + !pattern || + typeof pattern.wordPattern !== 'string' || + typeof pattern.patternType !== 'string' || + !Array.isArray(breakdown) + ) { + return null; + } + + const { patternType, wordPattern } = pattern; + + const parts: SyntaxAnalysisResult['wordBreakDown'] = breakdown.flatMap((item) => { + if (!item || typeof item !== 'object') return []; + const p = item as Record; + if (typeof p.part === 'string' && typeof p.meaning === 'string') { + return [{ part: p.part, meaning: p.meaning }]; + } + return []; + }); + + const base: SyntaxAnalysisResult = { + rootLetter: { + arabicName: root.arabicName, + rootLetter: root.rootLetter, + }, + wordBreakDown: parts, + pattern: { + wordPattern: pattern.wordPattern, + patternType: pattern.patternType, + }, + }; + + let result: SyntaxAnalysisResult = base; + const sarfChart = normalizeSarfChart(r.sarfChart); + if (sarfChart) result = { ...result, sarfChart }; + + const { looksPresent, looksPast } = verbTensePatternHints(patternType, wordPattern); + + const legacyVerbChart = normalizeVerbChart(r.verbChart); + let verbPresentTenseChart = normalizeVerbChart( + pickFirstVerbChartRaw(r, VERB_PRESENT_CHART_JSON_KEYS), + ); + let verbPastTenseChart = normalizeVerbChart(r.verbPastTenseChart); + + const ambiguousTense = looksPresent && looksPast; + if (!ambiguousTense) { + if (!verbPresentTenseChart && legacyVerbChart && looksPresent) { + verbPresentTenseChart = legacyVerbChart; + } + if (!verbPastTenseChart && legacyVerbChart && looksPast) { + verbPastTenseChart = legacyVerbChart; + } + } + + let verbChartOut: SyntaxAnalysisVerbChart | undefined = legacyVerbChart; + if ( + verbChartOut && + (verbPresentTenseChart === verbChartOut || verbPastTenseChart === verbChartOut) + ) { + verbChartOut = undefined; + } + + if (verbPresentTenseChart) result = { ...result, verbPresentTenseChart }; + if (verbPastTenseChart) result = { ...result, verbPastTenseChart }; + if (verbChartOut) result = { ...result, verbChart: verbChartOut }; + + const ismChart = normalizeIsmChart(r.ismChart); + if (ismChart) result = { ...result, ismChart }; + return result; +} + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const apiKey = process.env.OPENAI_API_KEY; + const model = + process.env.SYNTAX_ANALYSIS_MODEL || process.env.OPENAI_SYNTAX_MODEL || 'gpt-4o-mini'; + + const { textUthmani, verseKey } = req.body as { + textUthmani?: string; + verseKey?: string; + }; + + const text = typeof textUthmani === 'string' ? textUthmani.trim() : ''; + if (!text) { + return res.status(400).json({ error: 'Missing or empty textUthmani' }); + } + if (text.length > MAX_WORD_LENGTH) { + return res.status(400).json({ error: 'textUthmani too long' }); + } + + if (!apiKey) { + return res.status(503).json({ + error: 'Syntax analysis is not configured. Set OPENAI_API_KEY on the server.', + }); + } + + const systemPrompt = `You are an expert in Quranic Arabic morphology, صرف (Sarf), and نحو. +Respond with ONLY valid JSON (no markdown fences). Include the REQUIRED fields below. When the analyzed word supports them, also include the OPTIONAL chart objects using these exact key names and nesting. + +REQUIRED: +- rootLetter: { "arabicName": string, "rootLetter": string } + - arabicName: short Arabic gloss or label for the root (may repeat or describe the letters). + - rootLetter: the lexical root as Arabic consonants (usually three letters in Arabic script). +- wordBreakDown: [ { "part": string, "meaning": string }, ... ] +- pattern: { "wordPattern": string, "patternType": string } + - patternType: concise Arabic grammatical category for this surface form (e.g. فعل ماضي، فعل مضارع، اسم فاعل، مصدر). + - wordPattern: fuller morphological description of THIS token — typically English (person, gender, number, verb form/bāb, noun case, etc.), e.g. "3rd person masculine singular (form IV) imperfect verb". + +OPTIONAL — include when relevant (omit entirely if not applicable). Prefer this order in your JSON object when multiple charts apply: + +1) sarfChart — verb-derived morphology table (active / passive / commanding rows for the UI): +{ + "sarfChart": { + "columnHeaders": { + "pastTense": string, + "presentTense": string, + "idea": string, + "doer": string + }, + "activeVoice": { "pastTense": string, "presentTense": string, "idea": string, "doer": string }, + "passiveVoiceLabels": { same four keys, strings (row labels e.g. Passive + Arabic grammar terms) }, + "passiveVoiceForms": { same four keys, Arabic strings }, + "commandingLabels": { optional keys among the four; strings for أمر / نهى / ظرف }, + "commandingForms": { same optional keys; Arabic; multiple ظرف variants may use " | " }, + For commanding rows omit "doer" or leave unused cells absent if there is no أمر/نهى/ظرف counterpart under doer. + } +} +- columnHeaders: human-readable titles per column, e.g. "PastTense - فعل ماضى", "PresentTense - فعل مضارع", "Idea - مصدر", "Doer - اسم فاعل". +- Align passiveVoiceLabels with passiveVoiceForms; commandingLabels with commandingForms. + +2) verbPresentTenseChart & verbPastTenseChart — same structure for مضارع and ماضي conjugations (full grid): +Each chart object has keys exactly: +"3rdPersonMasculine" | "3rdPersonFeminine" | "2ndPersonMasculine" | "2ndPersonFeminine" | "1stPerson" +- For 3rd/2nd persons each value is: { "singular": verbSlot, "dual": verbSlot, "plural": verbSlot } +- For "1stPerson": { "singular": verbSlot, "plural": verbSlot } only (no dual). +- verbSlot = { "pronoun": string (Arabic), "meaning": string (short English), "verb": string (Arabic conjugated form) } + +3) verbChart — legacy optional key; same object shape as verbPastTenseChart (full conjugation grid). Include when returned separately from verbPastTenseChart if needed. + +4) ismChart — اسم declension grid for a singular noun/adjective template: +{ + "ismChart": { + "Masculine": { + "Rafa": { "singular": string, "dual": string, "plural": string }, + "Nasab": { "singular": string, "dual": string, "plural": string }, + "Jar": { "singular": string, "dual": string, "plural": string } + }, + "Feminine": { + "Rafa": { "singular": string, "dual": string, "plural": string }, + "Nasab": { "singular": string, "dual": string, "plural": string }, + "Jar": { "singular": string, "dual": string, "plural": string } + } + } +} + +Use Arabic script for Arabic forms and pronouns; keep English glosses concise.`; + + const userPrompt = `Verse context: ${verseKey || 'unknown'} +Arabic word (Uthmani): ${text} + +Analyze this single word and fill the JSON.`; + + try { + const openaiRes = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + temperature: 0.2, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + }), + }); + + if (!openaiRes.ok) { + const errText = await openaiRes.text(); + return res.status(502).json({ + error: `OpenAI error (${openaiRes.status}): ${errText.slice(0, 200)}`, + }); + } + + const completion = (await openaiRes.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const content = completion.choices?.[0]?.message?.content; + if (!content) { + return res.status(502).json({ error: 'Empty model response' }); + } + + let parsed: unknown; + try { + parsed = extractJsonFromContent(content); + } catch { + return res.status(502).json({ error: 'Model returned invalid JSON' }); + } + + const normalized = normalizeResult(parsed); + if (!normalized) { + return res.status(502).json({ error: 'Could not normalize model output' }); + } + + return res.status(200).json(normalized); + } catch (e) { + const message = e instanceof Error ? e.message : 'Syntax analysis failed'; + return res.status(500).json({ error: message }); + } +} diff --git a/src/services/syntaxAnalysis.mock.ts b/src/services/syntaxAnalysis.mock.ts new file mode 100644 index 0000000000..b23fb23087 --- /dev/null +++ b/src/services/syntaxAnalysis.mock.ts @@ -0,0 +1,260 @@ +/* eslint-disable import/prefer-default-export */ +/* eslint-disable max-lines */ +import type { SyntaxAnalysisResult } from 'types/SyntaxAnalysis'; + +/** + * Paste a full `SyntaxAnalysisResult` JSON object here while + * `NEXT_PUBLIC_SYNTAX_ANALYSIS_MOCK=true` is set. No OpenAI/API call is made. + * + * Tip: paste from an API/tools response, keeping valid TypeScript/JSON shapes. + */ +export const SYNTAX_ANALYSIS_MOCK_RESPONSE: SyntaxAnalysisResult = { + rootLetter: { + arabicName: 'جذر تجريبي', + rootLetter: 'كتب', + }, + wordBreakDown: [ + { part: 'يَكتُبُ', meaning: '(example) writes' }, + { part: 'كتب', meaning: '(example) root k-t-b' }, + ], + pattern: { + wordPattern: '(example) 3rd person masculine singular imperfect verb', + patternType: 'فعل مضارع', + }, + ismChart: { + Masculine: { + Rafa: { + singular: 'مُسْلِمٌ', + dual: 'مُسْلِمَانِ', + plural: 'مُسْلِمُونَ', + }, + Nasab: { + singular: 'مُسْلِمًا', + dual: 'مُسْلِمَيْنِ', + plural: 'مُسْلِمِينَ', + }, + Jar: { + singular: 'مُسْلِمٍ', + dual: 'مُسْلِمَيْنِ', + plural: 'مُسْلِمِينَ', + }, + }, + Feminine: { + Rafa: { + singular: 'مُسْلِمَةٌ', + dual: 'مُسْلِمَتَانِ', + plural: 'مُسْلِمَاتٌ', + }, + Nasab: { + singular: 'مُسْلِمَةً', + dual: 'مُسْلِمَتَيْنِ', + plural: 'مُسْلِمَاتٍ', + }, + Jar: { + singular: 'مُسْلِمَةٍ', + dual: 'مُسْلِمَتَيْنِ', + plural: 'مُسْلِمَاتٍ', + }, + }, + }, + verbPresentTenseChart: { + '3rdPersonMasculine': { + singular: { + pronoun: 'هُوَ', + meaning: 'He helps', + verb: 'يَنْصُرُ', + }, + dual: { + pronoun: 'هُمَا', + meaning: 'They (2) help', + verb: 'يَنْصُرَانِ', + }, + plural: { + pronoun: 'هُمْ', + meaning: 'They help', + verb: 'يَنْصُرُونَ', + }, + }, + '3rdPersonFeminine': { + singular: { + pronoun: 'هِيَ', + meaning: 'She helps', + verb: 'تَنْصُرُ', + }, + dual: { + pronoun: 'هُمَا', + meaning: 'They (2f) help', + verb: 'تَنْصُرَانِ', + }, + plural: { + pronoun: 'هُنَّ', + meaning: 'They (f) help', + verb: 'يَنْصُرْنَ', + }, + }, + '2ndPersonMasculine': { + singular: { + pronoun: 'أَنْتَ', + meaning: 'You help', + verb: 'تَنْصُرُ', + }, + dual: { + pronoun: 'أَنْتُمَا', + meaning: 'You (2) help', + verb: 'تَنْصُرَانِ', + }, + plural: { + pronoun: 'أَنْتُمْ', + meaning: 'You all help', + verb: 'تَنْصُرُونَ', + }, + }, + '2ndPersonFeminine': { + singular: { + pronoun: 'أَنْتِ', + meaning: 'You (f) help', + verb: 'تَنْصُرِينَ', + }, + dual: { + pronoun: 'أَنْتُمَا', + meaning: 'You (2f) help', + verb: 'تَنْصُرَانِ', + }, + plural: { + pronoun: 'أَنْتُنَّ', + meaning: 'You all (f) help', + verb: 'تَنْصُرْنَ', + }, + }, + '1stPerson': { + singular: { + pronoun: 'أَنَا', + meaning: 'I help', + verb: 'أَنْصُرُ', + }, + plural: { + pronoun: 'نَحْنُ', + meaning: 'We help', + verb: 'نَنْصُرُ', + }, + }, + }, + verbPastTenseChart: { + '3rdPersonMasculine': { + singular: { + pronoun: 'هُوَ', + meaning: 'He helped', + verb: 'نَصَرَ', + }, + dual: { + pronoun: 'هُمَا', + meaning: 'They (2) helped', + verb: 'نَصَرَا', + }, + plural: { + pronoun: 'هُمْ', + meaning: 'They helped', + verb: 'نَصَرُوا', + }, + }, + '3rdPersonFeminine': { + singular: { + pronoun: 'هِيَ', + meaning: 'She helped', + verb: 'نَصَرَتْ', + }, + dual: { + pronoun: 'هُمَا', + meaning: 'They (2f) helped', + verb: 'نَصَرَتَا', + }, + plural: { + pronoun: 'هُنَّ', + meaning: 'They (f) helped', + verb: 'نَصَرْنَ', + }, + }, + '2ndPersonMasculine': { + singular: { + pronoun: 'أَنْتَ', + meaning: 'You helped', + verb: 'نَصَرْتَ', + }, + dual: { + pronoun: 'أَنْتُمَا', + meaning: 'You (2) helped', + verb: 'نَصَرْتُمَا', + }, + plural: { + pronoun: 'أَنْتُمْ', + meaning: 'You all helped', + verb: 'نَصَرْتُمْ', + }, + }, + '2ndPersonFeminine': { + singular: { + pronoun: 'أَنْتِ', + meaning: 'You (f) helped', + verb: 'نَصَرْتِ', + }, + dual: { + pronoun: 'أَنْتُمَا', + meaning: 'You (2f) helped', + verb: 'نَصَرْتُمَا', + }, + plural: { + pronoun: 'أَنْتُنَّ', + meaning: 'You all (f) helped', + verb: 'نَصَرْتُنَّ', + }, + }, + '1stPerson': { + singular: { + pronoun: 'أَنَا', + meaning: 'I helped', + verb: 'نَصَرْتُ', + }, + plural: { + pronoun: 'نَحْنُ', + meaning: 'We helped', + verb: 'نَصَرْنَا', + }, + }, + }, + sarfChart: { + columnHeaders: { + pastTense: 'PastTense - فعل ماضى', + presentTense: 'PresentTense - فعل مضارع', + idea: 'Idea - مصدر', + doer: 'Doer - اسم فاعل', + }, + activeVoice: { + pastTense: 'فَتَنَ', + presentTense: 'يَفْتِنُ', + idea: 'فِتْنَةً', + doer: 'فَاتِنٌ', + }, + passiveVoiceLabels: { + pastTense: 'Passive - فعل ماضى مبنى للمجهول', + presentTense: 'Passive - فعل مضارع مبنى للمجهول', + idea: 'مصدر', + doer: 'DoneTo - اسم مفعول', + }, + passiveVoiceForms: { + pastTense: 'فُتِنَ', + presentTense: 'يُفْتَنُ', + idea: 'فُتُونًا', + doer: 'مَفْتُونٌ', + }, + commandingLabels: { + pastTense: 'Commanding - أمر', + presentTense: 'Forbidding - نهى', + idea: 'TimeAndPlace - ظرف', + }, + commandingForms: { + pastTense: 'إِفْتِنْ', + presentTense: 'لَا تَفْتِنْ', + idea: 'مَفْتَنٌ | مَفْتِنٌ | مَفْتَنَةٌ', + }, + }, +}; diff --git a/src/services/syntaxAnalysisService.ts b/src/services/syntaxAnalysisService.ts new file mode 100644 index 0000000000..397413d97d --- /dev/null +++ b/src/services/syntaxAnalysisService.ts @@ -0,0 +1,64 @@ +import type { SyntaxAnalysisResult } from 'types/SyntaxAnalysis'; + +import { SYNTAX_ANALYSIS_MOCK_RESPONSE } from '@/services/syntaxAnalysis.mock'; + +export type SyntaxAnalysisRequest = { + /** Preferred: Uthmani text from the selected `Word` */ + textUthmani: string; + verseKey?: string; +}; + +export type SyntaxAnalysisErrorBody = { + error: string; +}; + +/** When true, `fetchSyntaxAnalysis` returns pasted mock data (see `syntaxAnalysis.mock.ts`). */ +export function isSyntaxAnalysisMockMode(): boolean { + return process.env.NEXT_PUBLIC_SYNTAX_ANALYSIS_MOCK === 'true'; +} + +/** + * Calls the Next.js API route that proxies to an LLM (OpenAI when `OPENAI_API_KEY` is set), + * unless mock mode is on — then returns `SYNTAX_ANALYSIS_MOCK_RESPONSE` with no token. + * + * Must run in the browser or any environment where `/api/syntax/analyze` is reachable (real mode only). + */ +export async function fetchSyntaxAnalysis( + payload: SyntaxAnalysisRequest, +): Promise { + if (isSyntaxAnalysisMockMode()) { + void payload; + await new Promise((r) => { + setTimeout(r, 200); + }); + return structuredClone(SYNTAX_ANALYSIS_MOCK_RESPONSE); + } + + const res = await fetch('/api/syntax/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + const body = (await res.json()) as SyntaxAnalysisResult | SyntaxAnalysisErrorBody; + + if (!res.ok || 'error' in body) { + throw new Error( + 'error' in body ? body.error : `Syntax analysis failed (${res.status})`, + ); + } + + return body; +} + +/** + * Resolves display text for syntax analysis from a Word-like object. + * `WordVerse` does not carry `textUthmani`; use fields on `Word` instead. + */ +export function getWordTextUthmaniForSyntax(word: { + textUthmani?: string; + qpcUthmaniHafs?: string; + text?: string; +}): string { + return (word.textUthmani || word.qpcUthmaniHafs || word.text || '').trim(); +} diff --git a/src/utils/url.ts b/src/utils/url.ts index 6f8ce23be1..0af4b9a0f8 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -9,6 +9,9 @@ export enum QuranFoundationService { QURAN_REFLECT = 'quran-reflect', } +const STAGING_CONTENT_HOST = 'https://staging.quran.com'; +const PRODUCTION_CONTENT_HOST = 'https://api.qurancdn.com'; + export const getCurrentPath = () => { if (typeof window !== 'undefined') { return window.location.href; @@ -68,6 +71,14 @@ export const getBasePath = (): string => }`; export const getProxiedServiceUrl = (service: QuranFoundationService, path: string): string => { + if (service === QuranFoundationService.CONTENT) { + const contentHost = + process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' + ? PRODUCTION_CONTENT_HOST + : STAGING_CONTENT_HOST; + return `${contentHost}${path}`; + } + const PROXY_PATH = `/api/proxy/${service}`; const BASE_PATH = isStaticBuild ? `${process.env.API_GATEWAY_URL}/${service}` diff --git a/types/SyntaxAnalysis.ts b/types/SyntaxAnalysis.ts new file mode 100644 index 0000000000..307634e601 --- /dev/null +++ b/types/SyntaxAnalysis.ts @@ -0,0 +1,100 @@ +/** + * Structured morphology output for an Arabic word (e.g. Uthmani script). + * Returned by `/api/syntax/analyze` and `syntaxAnalysisService`. + */ +/** Root metadata from the analyzer (same shape as /api/syntax/analyze required rootLetter). */ +export type SyntaxAnalysisRootLetter = { + /** Short Arabic label or gloss for the root */ + arabicName: string; + /** Lexical root consonants in Arabic script (often ثلاثي) */ + rootLetter: string; +}; + +export type SyntaxAnalysisBreakdownPart = { + part: string; + meaning: string; +}; + +/** Surface pattern — mirrors API: Arabic category + optional English morphological gloss. */ +export type SyntaxAnalysisPattern = { + /** Morphological description (often English: person, gender, number, form, etc.) */ + wordPattern: string; + /** Arabic grammatical category (e.g. فعل ماضي، اسم فاعل) */ + patternType: string; +}; + +/** One row of اسم declension (singular / dual / plural). */ +export type SyntaxAnalysisIsmCaseRow = { + singular: string; + dual: string; + plural: string; +}; + +/** Declension by grammatical case for one gender. */ +export type SyntaxAnalysisIsmGender = { + Rafa: SyntaxAnalysisIsmCaseRow; + Nasab: SyntaxAnalysisIsmCaseRow; + Jar: SyntaxAnalysisIsmCaseRow; +}; + +/** Full اسم chart (masculine / feminine). */ +export type SyntaxAnalysisIsmChart = { + Masculine: SyntaxAnalysisIsmGender; + Feminine: SyntaxAnalysisIsmGender; +}; + +export type SyntaxAnalysisVerbSlot = { + pronoun: string; + meaning: string; + verb: string; + /** Inflection segment at end of `verb`; rendered in accent color when it matches */ + verbSuffix?: string; +}; + +export type SyntaxAnalysisVerbPerson = { + singular?: SyntaxAnalysisVerbSlot; + dual?: SyntaxAnalysisVerbSlot; + plural?: SyntaxAnalysisVerbSlot; +}; + +/** Verb conjugation grid — same shape for past, present, etc. */ +export type SyntaxAnalysisVerbChart = { + '3rdPersonMasculine': SyntaxAnalysisVerbPerson; + '3rdPersonFeminine': SyntaxAnalysisVerbPerson; + '2ndPersonMasculine': SyntaxAnalysisVerbPerson; + '2ndPersonFeminine': SyntaxAnalysisVerbPerson; + '1stPerson': SyntaxAnalysisVerbPerson; +}; + +/** Four Sarf columns — DOM order with `dir="rtl"` on the table is Past → Present → Idea → Doer (reading RTL). */ +export type SyntaxAnalysisSarfColumnKey = + | 'pastTense' + | 'presentTense' + | 'idea' + | 'doer'; + +/** + * Verb-derived morphology chart (مصدر، اسم فاعل، صيغ أمر/نهي، مجهول، ظرف، إلخ). + * Matches the Study Mode Sarf grid: header row, active forms, passive labels/forms, commanding labels/forms. + */ +export type SyntaxAnalysisSarfChart = { + columnHeaders: Record; + activeVoice: Record; + passiveVoiceLabels: Partial>; + passiveVoiceForms: Partial>; + commandingLabels: Partial>; + commandingForms: Partial>; +}; + +export type SyntaxAnalysisResult = { + rootLetter: SyntaxAnalysisRootLetter; + wordBreakDown: SyntaxAnalysisBreakdownPart[]; + pattern: SyntaxAnalysisPattern; + /** Optional extended charts (e.g. mocks) */ + ismChart?: SyntaxAnalysisIsmChart; + /** @deprecated Use verbPastTenseChart */ + verbChart?: SyntaxAnalysisVerbChart; + verbPresentTenseChart?: SyntaxAnalysisVerbChart; + verbPastTenseChart?: SyntaxAnalysisVerbChart; + sarfChart?: SyntaxAnalysisSarfChart; +}; From 68e00bd7abfaa2cda3afa5f20e52ee9786284aac Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Mon, 11 May 2026 19:57:06 -0500 Subject: [PATCH 02/22] First commit for the quran syntax change using MCP. --- .env.example | 5 +- package.json | 1 + src/lib/syntaxAnalysisQuranMcp.ts | 36 ++ src/lib/syntaxAnalysisQuranMcpMorphology.ts | 179 +++++++ src/pages/api/syntax/analyze.ts | 34 +- tsconfig.json | 3 + yarn.lock | 488 +++++++++++++++++++- 7 files changed, 730 insertions(+), 16 deletions(-) create mode 100644 src/lib/syntaxAnalysisQuranMcp.ts create mode 100644 src/lib/syntaxAnalysisQuranMcpMorphology.ts diff --git a/.env.example b/.env.example index eeb92cb430..44ce2b21d4 100644 --- a/.env.example +++ b/.env.example @@ -32,7 +32,10 @@ NEXT_PUBLIC_SSO_ENABLED=false NEXT_PUBLIC_EMBED_URL=https://quran.com/embed/v1 # Embed Ayah -# Optional: Syntax tab morphology analysis (OpenAI Chat Completions from /api/syntax/analyze) +# Optional: Syntax tab morphology analysis +# Provider: `openai` (default when OPENAI_API_KEY is set) or `quran_mcp` (https://mcp.quran.ai Streamable HTTP — no API key). +# SYNTAX_ANALYSIS_PROVIDER=quran_mcp +# QURAN_SYNTAX_MCP_URL=https://mcp.quran.ai/ # OPENAI_API_KEY= # SYNTAX_ANALYSIS_MODEL=gpt-4o-mini # diff --git a/package.json b/package.json index 7592ae9fd9..7a4168bfa7 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@milkdown/react": "^7.5.0", "@milkdown/transformer": "^7.5.0", "@milkdown/utils": "^7.5.0", + "@modelcontextprotocol/sdk": "^1.12.0", "@next/bundle-analyzer": "^14.2.7", "@novu/headless": "0.24.0", "@radix-ui/react-checkbox": "^1.1.1", diff --git a/src/lib/syntaxAnalysisQuranMcp.ts b/src/lib/syntaxAnalysisQuranMcp.ts new file mode 100644 index 0000000000..5caf2cacf3 --- /dev/null +++ b/src/lib/syntaxAnalysisQuranMcp.ts @@ -0,0 +1,36 @@ +import { Client } from '@modelcontextprotocol/sdk/client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp'; + +import { runFetchWordMorphologyOnClient } from '@/lib/syntaxAnalysisQuranMcpMorphology'; +import type { SyntaxAnalysisResult } from 'types/SyntaxAnalysis'; + +const DEFAULT_QURAN_MCP_URL = 'https://mcp.quran.ai/'; + +export type QuranMcpSyntaxOptions = { + textUthmani: string; + verseKey?: string; + /** Base URL for Streamable HTTP MCP (per https://mcp.quran.ai/documentation ). */ + mcpUrl?: string; +}; + +/** + * Grounded word morphology from [Quran MCP](https://mcp.quran.ai/) via Streamable HTTP. + * @returns {@link SyntaxAnalysisResult} derived from `fetch_word_morphology` (charts omitted). + */ +export async function fetchSyntaxAnalysisViaQuranMcp( + options: QuranMcpSyntaxOptions, +): Promise { + const baseUrl = options.mcpUrl || process.env.QURAN_SYNTAX_MCP_URL || DEFAULT_QURAN_MCP_URL; + const trimmed = baseUrl.trim(); + const url = new URL(trimmed.endsWith('/') ? trimmed : `${trimmed}/`); + + const transport = new StreamableHTTPClientTransport(url); + const client = new Client({ name: 'quran.com-frontend', version: '1.0.0' }); + + try { + await client.connect(transport); + return await runFetchWordMorphologyOnClient(client, options.textUthmani, options.verseKey); + } finally { + await client.close().catch(() => undefined); + } +} diff --git a/src/lib/syntaxAnalysisQuranMcpMorphology.ts b/src/lib/syntaxAnalysisQuranMcpMorphology.ts new file mode 100644 index 0000000000..086a7fff8a --- /dev/null +++ b/src/lib/syntaxAnalysisQuranMcpMorphology.ts @@ -0,0 +1,179 @@ +/* eslint-disable max-lines -- MCP response mapping is verbose but linear */ +import type { Client } from '@modelcontextprotocol/sdk/client'; + +import type { SyntaxAnalysisBreakdownPart, SyntaxAnalysisResult } from 'types/SyntaxAnalysis'; + +/** Verse key as surah:ayah (e.g. 2:255). */ +const AYAH_KEY_RE = /^\s*\d{1,3}:\d{1,3}\s*$/; + +export type MorphologyWord = Record; + +function str(o: MorphologyWord, key: string): string | undefined { + const v = o[key]; + return typeof v === 'string' ? v : undefined; +} + +function obj(o: MorphologyWord, key: string): Record | undefined { + const v = o[key]; + return v && typeof v === 'object' && !Array.isArray(v) + ? (v as Record) + : undefined; +} + +function arr(o: MorphologyWord, key: string): unknown[] | undefined { + const v = o[key]; + return Array.isArray(v) ? v : undefined; +} + +export function isValidAyahKey(verseKey: string | undefined): verseKey is string { + if (!verseKey) return false; + return AYAH_KEY_RE.test(verseKey); +} + +export function pickMorphologyWord( + words: MorphologyWord[], + textUthmani: string, +): MorphologyWord | undefined { + if (!words.length) return undefined; + const t = textUthmani.trim(); + const exact = words.find((w) => str(w, 'text_uthmani')?.trim() === t); + if (exact) return exact; + return ( + words.find( + (w) => + (str(w, 'text_uthmani')?.includes(t) ?? false) || + (t.includes(str(w, 'text_uthmani') || '') && str(w, 'text_uthmani')), + ) ?? words[0] + ); +} + +function englishPatternLine(w: MorphologyWord): string { + const desc = str(w, 'description')?.trim(); + if (desc) return desc; + const g = obj(w, 'grammatical_features'); + if (!g) return str(w, 'translation')?.trim() || 'Morphological analysis (Quran MCP)'; + const parts: string[] = []; + const push = (label: string, key: string) => { + const v = g[key]; + if (v != null && String(v)) parts.push(`${label}: ${v}`); + }; + push('POS', 'part_of_speech'); + push('person', 'person'); + push('gender', 'gender'); + push('number', 'number'); + push('aspect', 'aspect'); + push('mood', 'mood'); + push('case', 'case'); + const vf = g.verb_form; + if (vf != null) parts.push(`verb form: ${vf}`); + return parts.join('; ') || 'Morphological analysis (Quran MCP)'; +} + +function arabicPatternType(w: MorphologyWord): string { + const g = obj(w, 'grammatical_features'); + const pos = String(g?.part_of_speech || '').toLowerCase(); + if (pos.includes('verb')) { + const a = String(g?.aspect || '').toLowerCase(); + if (a.includes('perfect')) return 'فعل ماضي'; + if (a.includes('imperfect')) return 'فعل مضارع'; + if (a.includes('imperative')) return 'فعل أمر'; + return 'فعل'; + } + if (pos.includes('noun') || pos.includes('adjective') || pos.includes('participle')) { + const c = String(g?.case || '').toLowerCase(); + if (c.includes('gen')) return 'اسم مجرور'; + if (c.includes('acc')) return 'اسم منصوب'; + if (c.includes('nom')) return 'اسم مرفوع'; + return 'اسم'; + } + if (pos.includes('prep')) return 'حرف جر'; + if (pos.includes('pron')) return 'ضمير'; + if (pos.includes('particle') || pos.includes('part')) return 'أداة'; + return 'صيغة قرآنية'; +} + +function segmentsToBreakdown(w: MorphologyWord): SyntaxAnalysisBreakdownPart[] { + const segs = arr(w, 'morpheme_segments'); + if (segs?.length) { + return segs.flatMap((raw) => { + if (!raw || typeof raw !== 'object') return []; + const s = raw as Record; + const part = typeof s.text === 'string' ? s.text.trim() : ''; + if (!part) return []; + const g1 = typeof s.grammar_description === 'string' ? s.grammar_description.trim() : ''; + const g2 = typeof s.part_of_speech_name === 'string' ? s.part_of_speech_name.trim() : ''; + const meaning = g1 || g2 || '—'; + return [{ part, meaning }]; + }); + } + const whole = str(w, 'text_uthmani')?.trim() || ''; + if (!whole) return []; + return [{ part: whole, meaning: str(w, 'translation')?.trim() || englishPatternLine(w) }]; +} + +export function morphologyWordToSyntaxResult(w: MorphologyWord): SyntaxAnalysisResult { + const rootLetters = (str(w, 'root') || str(w, 'lemma') || '—').trim() || '—'; + const arabicName = (str(w, 'lemma') || str(w, 'root') || rootLetters).trim() || rootLetters; + return { + rootLetter: { arabicName, rootLetter: rootLetters }, + pattern: { wordPattern: englishPatternLine(w), patternType: arabicPatternType(w) }, + wordBreakDown: segmentsToBreakdown(w), + }; +} + +export function parseToolJsonPayload(result: unknown): unknown { + if (!result || typeof result !== 'object') throw new Error('Invalid MCP tool response'); + const r = result as { + structuredContent?: unknown; + content?: Array<{ type: string; text?: string }>; + isError?: boolean; + }; + if (r.isError) { + const msg = + r.content + ?.filter( + (c): c is { type: 'text'; text: string } => + c.type === 'text' && typeof c.text === 'string', + ) + .map((c) => c.text) + .join('\n') || 'MCP tool returned an error'; + throw new Error(msg); + } + if (r.structuredContent && typeof r.structuredContent === 'object') { + return r.structuredContent; + } + const textBlock = r.content?.find((c) => c.type === 'text' && typeof c.text === 'string'); + if (!textBlock?.text) throw new Error('Empty MCP tool response'); + return JSON.parse(textBlock.text) as unknown; +} + +/** + * Calls `fetch_grounding_rules` then `fetch_word_morphology` on an initialized MCP client. + * @returns {Promise} Normalized result for the resolved word (no optional charts). + */ +export async function runFetchWordMorphologyOnClient( + client: Client, + textUthmani: string, + verseKey?: string, +): Promise { + await client.callTool({ name: 'fetch_grounding_rules', arguments: {} }); + + const morphArgs: Record = {}; + if (isValidAyahKey(verseKey)) { + morphArgs.ayah_key = verseKey.trim(); + morphArgs.word_text = textUthmani; + } else { + morphArgs.word = textUthmani; + } + + const morphResult = await client.callTool({ + name: 'fetch_word_morphology', + arguments: morphArgs, + }); + + const payload = parseToolJsonPayload(morphResult) as { words?: MorphologyWord[] }; + const words = Array.isArray(payload.words) ? payload.words : []; + const picked = pickMorphologyWord(words, textUthmani); + if (!picked) throw new Error('Quran MCP returned no morphology for this word'); + return morphologyWordToSyntaxResult(picked); +} diff --git a/src/pages/api/syntax/analyze.ts b/src/pages/api/syntax/analyze.ts index c46ca9c038..4de75899e3 100644 --- a/src/pages/api/syntax/analyze.ts +++ b/src/pages/api/syntax/analyze.ts @@ -3,6 +3,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import type { NextApiRequest, NextApiResponse } from 'next'; +import { fetchSyntaxAnalysisViaQuranMcp } from '@/lib/syntaxAnalysisQuranMcp'; import type { SyntaxAnalysisIsmChart, SyntaxAnalysisResult, @@ -288,6 +289,21 @@ export default async function handler( const model = process.env.SYNTAX_ANALYSIS_MODEL || process.env.OPENAI_SYNTAX_MODEL || 'gpt-4o-mini'; + const envProvider = process.env.SYNTAX_ANALYSIS_PROVIDER?.trim().toLowerCase(); + /** + * `quran_mcp` — [Quran MCP](https://mcp.quran.ai/documentation) Streamable HTTP (no OpenAI key). + * `openai` — LLM JSON (requires OPENAI_API_KEY). + * Default: OpenAI when a key is set, otherwise Quran MCP. + */ + let syntaxProvider: 'openai' | 'quran_mcp'; + if (envProvider === 'quran_mcp' || envProvider === 'openai') { + syntaxProvider = envProvider; + } else if (apiKey) { + syntaxProvider = 'openai'; + } else { + syntaxProvider = 'quran_mcp'; + } + const { textUthmani, verseKey } = req.body as { textUthmani?: string; verseKey?: string; @@ -301,12 +317,26 @@ export default async function handler( return res.status(400).json({ error: 'textUthmani too long' }); } - if (!apiKey) { + if (syntaxProvider === 'openai' && !apiKey) { return res.status(503).json({ - error: 'Syntax analysis is not configured. Set OPENAI_API_KEY on the server.', + error: + 'Syntax analysis (OpenAI) is not configured. Set OPENAI_API_KEY, or set SYNTAX_ANALYSIS_PROVIDER=quran_mcp to use https://mcp.quran.ai/', }); } + if (syntaxProvider === 'quran_mcp') { + try { + const fromMcp = await fetchSyntaxAnalysisViaQuranMcp({ + textUthmani: text, + verseKey: typeof verseKey === 'string' ? verseKey : undefined, + }); + return res.status(200).json(fromMcp); + } catch (e) { + const message = e instanceof Error ? e.message : 'Quran MCP syntax analysis failed'; + return res.status(502).json({ error: message }); + } + } + const systemPrompt = `You are an expert in Quranic Arabic morphology, صرف (Sarf), and نحو. Respond with ONLY valid JSON (no markdown fences). Include the REQUIRED fields below. When the analyzed word supports them, also include the OPTIONAL chart objects using these exact key names and nesting. diff --git a/tsconfig.json b/tsconfig.json index 2a2c73ec08..bd509ec8bb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,9 @@ "target": "es5", "incremental": true, "paths": { + "@modelcontextprotocol/sdk/*": [ + "./node_modules/@modelcontextprotocol/sdk/dist/esm/*" + ], "@/icons/*": ["./public/icons/*"], "@/public/*": ["./public/*"], "@/dls/*": ["./src/components/dls/*"], diff --git a/yarn.lock b/yarn.lock index d1af74e9a7..dab2294f17 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3446,6 +3446,11 @@ protobufjs "^7.2.4" yargs "^17.7.2" +"@hono/node-server@^1.19.9": + version "1.19.14" + resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.14.tgz#e30f844bc77e3ce7be442aac3b1f73ad8b58d181" + integrity sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw== + "@humanwhocodes/config-array@^0.11.14": version "0.11.14" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b" @@ -3896,6 +3901,29 @@ nanoid "^5.0.0" tslib "^2.5.0" +"@modelcontextprotocol/sdk@^1.12.0": + version "1.29.0" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz#79786d8b525e269de850ac82b1f1f757f3915f44" + integrity sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ== + dependencies: + "@hono/node-server" "^1.19.9" + ajv "^8.17.1" + ajv-formats "^3.0.1" + content-type "^1.0.5" + cors "^2.8.5" + cross-spawn "^7.0.5" + eventsource "^3.0.2" + eventsource-parser "^3.0.0" + express "^5.2.1" + express-rate-limit "^8.2.1" + hono "^4.11.4" + jose "^6.1.3" + json-schema-typed "^8.0.2" + pkce-challenge "^5.0.0" + raw-body "^3.0.0" + zod "^3.25 || ^4.0" + zod-to-json-schema "^3.25.1" + "@next/bundle-analyzer@^14.2.7": version "14.2.7" resolved "https://registry.yarnpkg.com/@next/bundle-analyzer/-/bundle-analyzer-14.2.7.tgz#c7b9595bf57d31dc4c50b91d9afdc21380d7eae6" @@ -7868,6 +7896,14 @@ abstract-leveldown@~0.12.0, abstract-leveldown@~0.12.1: dependencies: xtend "~3.0.0" +accepts@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" + integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== + dependencies: + mime-types "^3.0.0" + negotiator "^1.0.0" + accepts@~1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -7963,6 +7999,13 @@ ajv-formats@^2.1.1: dependencies: ajv "^8.0.0" +ajv-formats@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578" + integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ== + dependencies: + ajv "^8.0.0" + ajv-keywords@^3.1.0, ajv-keywords@^3.5.2: version "3.5.2" resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" @@ -7995,6 +8038,16 @@ ajv@^8.0.0, ajv@^8.0.1, ajv@^8.9.0: require-from-string "^2.0.2" uri-js "^4.2.2" +ajv@^8.17.1: + version "8.20.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.20.0.tgz#304b3636add88ba7d936760dd50ece006dea95f9" + integrity sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + ajv@^8.6.0: version "8.17.1" resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" @@ -8469,6 +8522,21 @@ body-parser@1.20.2: type-is "~1.6.18" unpipe "1.0.0" +body-parser@^2.2.1: + version "2.2.2" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.2.2.tgz#1a32cdb966beaf68de50a9dfbe5b58f83cb8890c" + integrity sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA== + dependencies: + bytes "^3.1.2" + content-type "^1.0.5" + debug "^4.4.3" + http-errors "^2.0.0" + iconv-lite "^0.7.0" + on-finished "^2.4.1" + qs "^6.14.1" + raw-body "^3.0.1" + type-is "^2.0.1" + boolbase@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" @@ -8675,7 +8743,7 @@ busboy@1.6.0: dependencies: streamsearch "^1.1.0" -bytes@3.1.2: +bytes@3.1.2, bytes@^3.1.2, bytes@~3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== @@ -8703,6 +8771,14 @@ cac@^6.7.14: resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" @@ -8714,6 +8790,14 @@ call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bin get-intrinsic "^1.2.4" set-function-length "^1.2.1" +call-bound@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -9190,7 +9274,12 @@ content-disposition@0.5.4: dependencies: safe-buffer "5.2.1" -content-type@~1.0.4, content-type@~1.0.5: +content-disposition@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.1.0.tgz#f3db789c752d45564cc7e9e1e0b31790d4a38e17" + integrity sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g== + +content-type@^1.0.5, content-type@~1.0.4, content-type@~1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== @@ -9210,6 +9299,11 @@ cookie-signature@1.0.6: resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== +cookie-signature@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" + integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== + cookie@0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" @@ -9220,6 +9314,11 @@ cookie@^0.5.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== +cookie@^0.7.1: + version "0.7.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== + cookiejar@^2.1.4: version "2.1.4" resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b" @@ -9242,6 +9341,14 @@ core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== +cors@^2.8.5: + version "2.8.6" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.6.tgz#ff5dd69bd95e547503820d29aba4f8faf8dfec96" + integrity sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw== + dependencies: + object-assign "^4" + vary "^1" + cosmiconfig@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6" @@ -9320,6 +9427,15 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +cross-spawn@^7.0.5: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + crypto-browserify@^3.12.0: version "3.12.0" resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec" @@ -9553,6 +9669,13 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.5, debug@~4.3.6: dependencies: ms "2.1.2" +debug@^4.4.0, debug@^4.4.3: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + decamelize-keys@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.1.tgz#04a2d523b2f18d80d0158a43b895d56dff8d19d8" @@ -9701,7 +9824,7 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== -depd@2.0.0: +depd@2.0.0, depd@^2.0.0, depd@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== @@ -9896,6 +10019,15 @@ dotenv@^16.3.1: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.5.0.tgz#092b49f25f808f020050051d1ff258e404c78692" integrity sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg== +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + duplexer@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" @@ -9961,6 +10093,11 @@ emojis-list@^3.0.0: resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== +encodeurl@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -10171,6 +10308,11 @@ es-define-property@^1.0.0: dependencies: get-intrinsic "^1.2.4" +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + es-errors@^1.0.0, es-errors@^1.1.0, es-errors@^1.2.1, es-errors@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" @@ -10229,6 +10371,13 @@ es-object-atoms@^1.0.0: dependencies: es-errors "^1.3.0" +es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + es-set-tostringtag@^2.0.2, es-set-tostringtag@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz#8bb60f0a440c2e4281962428438d58545af39777" @@ -10358,7 +10507,7 @@ escalade@^3.2.0: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== -escape-html@~1.0.3: +escape-html@^1.0.3, escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== @@ -10751,7 +10900,7 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -etag@~1.8.1: +etag@^1.8.1, etag@~1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== @@ -10789,11 +10938,23 @@ events@3.3.0, events@^3.2.0, events@^3.3.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== +eventsource-parser@^3.0.0, eventsource-parser@^3.0.1: + version "3.0.8" + resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.0.8.tgz#1c792503e4080455d00701bb1f7a1d60734d0e58" + integrity sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ== + eventsource@2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-2.0.2.tgz#76dfcc02930fb2ff339520b6d290da573a9e8508" integrity sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA== +eventsource@^3.0.2: + version "3.0.7" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-3.0.7.tgz#1157622e2f5377bb6aef2114372728ba0c156989" + integrity sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA== + dependencies: + eventsource-parser "^3.0.1" + evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" @@ -10832,6 +10993,13 @@ execa@^8.0.1, execa@~8.0.1: signal-exit "^4.1.0" strip-final-newline "^3.0.0" +express-rate-limit@^8.2.1: + version "8.5.1" + resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-8.5.1.tgz#ee62473d7b3bdf3b27b7be3d7f25c6d13308479a" + integrity sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ== + dependencies: + ip-address "^10.2.0" + express@^4.19.2: version "4.19.2" resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" @@ -10869,6 +11037,40 @@ express@^4.19.2: utils-merge "1.0.1" vary "~1.1.2" +express@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04" + integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw== + dependencies: + accepts "^2.0.0" + body-parser "^2.2.1" + content-disposition "^1.0.0" + content-type "^1.0.5" + cookie "^0.7.1" + cookie-signature "^1.2.1" + debug "^4.4.0" + depd "^2.0.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + finalhandler "^2.1.0" + fresh "^2.0.0" + http-errors "^2.0.0" + merge-descriptors "^2.0.0" + mime-types "^3.0.0" + on-finished "^2.4.1" + once "^1.4.0" + parseurl "^1.3.3" + proxy-addr "^2.0.7" + qs "^6.14.0" + range-parser "^1.2.1" + router "^2.2.0" + send "^1.1.0" + serve-static "^2.2.0" + statuses "^2.0.1" + type-is "^2.0.1" + vary "^1.1.2" + ext@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/ext/-/ext-1.7.0.tgz#0ea4383c0103d60e70be99e9a7f11027a33c4f5f" @@ -11046,6 +11248,18 @@ finalhandler@1.2.0: statuses "2.0.1" unpipe "~1.0.0" +finalhandler@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-2.1.1.tgz#a2c517a6559852bcdb06d1f8bd7f51b68fad8099" + integrity sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA== + dependencies: + debug "^4.4.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + on-finished "^2.4.1" + parseurl "^1.3.3" + statuses "^2.0.1" + find-cache-dir@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-2.1.0.tgz#8d0f94cd13fe43c6c7c261a0d86115ca918c05f7" @@ -11244,6 +11458,11 @@ fresh@0.5.2: resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== +fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" + integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== + from2@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af" @@ -11375,6 +11594,22 @@ get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@ has-symbols "^1.0.3" hasown "^2.0.0" +get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + get-it@^8.1: version "8.4.10" resolved "https://registry.yarnpkg.com/get-it/-/get-it-8.4.10.tgz#b10711bc20e88dbed77fbebd336c2d02480e946d" @@ -11401,6 +11636,14 @@ get-own-enumerable-property-symbols@^3.0.0: resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" integrity sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g== +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + get-stream@^5.1.0: version "5.2.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" @@ -11612,6 +11855,11 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" @@ -11671,6 +11919,11 @@ has-symbols@^1.0.2, has-symbols@^1.0.3: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + has-tostringtag@^1.0.0, has-tostringtag@^1.0.1, has-tostringtag@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" @@ -11757,6 +12010,11 @@ hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: dependencies: react-is "^16.7.0" +hono@^4.11.4: + version "4.12.18" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.18.tgz#f6d301938868c3a8bdb639495f4e326a19181505" + integrity sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ== + hosted-git-info@^2.1.4: version "2.8.9" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" @@ -11836,6 +12094,17 @@ http-errors@2.0.0: statuses "2.0.1" toidentifier "1.0.1" +http-errors@^2.0.0, http-errors@^2.0.1, http-errors@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" + integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== + dependencies: + depd "~2.0.0" + inherits "~2.0.4" + setprototypeof "~1.2.0" + statuses "~2.0.2" + toidentifier "~1.0.1" + http-parser-js@>=0.5.1: version "0.5.8" resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.8.tgz#af23090d9ac4e24573de6f6aecc9d84a48bf20e3" @@ -11918,6 +12187,13 @@ iconv-lite@0.6.3: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" +iconv-lite@^0.7.0, iconv-lite@~0.7.0: + version "0.7.2" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.2.tgz#d0bdeac3f12b4835b7359c2ad89c422a4d1cc72e" + integrity sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + icss-utils@^5.0.0, icss-utils@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" @@ -12082,6 +12358,11 @@ invariant@^2.2.4: dependencies: loose-envify "^1.0.0" +ip-address@^10.2.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.2.0.tgz#805fc178b20c518bd4c8548b24fe30892d7f3206" + integrity sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA== + ipaddr.js@1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" @@ -12374,6 +12655,11 @@ is-potential-custom-element-name@^1.0.1: resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== +is-promise@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" + integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== + is-reference@1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7" @@ -12602,6 +12888,11 @@ jiti@^1.20.0: resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.0.tgz#7c97f8fe045724e136a397f7340475244156105d" integrity sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q== +jose@^6.1.3: + version "6.2.3" + resolved "https://registry.yarnpkg.com/jose/-/jose-6.2.3.tgz#0975197ad973251221c658a3cddc4b951a250c2d" + integrity sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw== + js-cookie@^3.0.1: version "3.0.5" resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc" @@ -12717,6 +13008,11 @@ json-schema-traverse@^1.0.0: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== +json-schema-typed@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz#e98ee7b1899ff4a184534d1f167c288c66bbeff4" + integrity sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA== + json-schema@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" @@ -13237,6 +13533,11 @@ markdown-to-jsx@^7.4.5: resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.5.0.tgz#42ece0c71e842560a7d8bd9f81e7a34515c72150" integrity sha512-RrBNcMHiFPcz/iqIj0n3wclzHXjwS7mzjBNWecKKVhNTIxQepIix6Il/wZCn2Cg5Y1ow2Qi84+eJrryFRWBEWw== +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + mathml-tag-names@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz#4ddadd67308e780cf16a47685878ee27b736a0a3" @@ -13411,6 +13712,11 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== +media-typer@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561" + integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== + memfs@3.4.3: version "3.4.3" resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.4.3.tgz#fc08ac32363b6ea6c95381cabb4d67838180d4e1" @@ -13455,6 +13761,11 @@ merge-descriptors@1.0.1: resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== +merge-descriptors@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808" + integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== + merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -13790,6 +14101,11 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== +mime-db@^1.54.0: + version "1.54.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" + integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== + mime-types@2.1.34: version "2.1.34" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.34.tgz#5a712f9ec1503511a945803640fafe09d3793c24" @@ -13804,6 +14120,13 @@ mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.24, dependencies: mime-db "1.52.0" +mime-types@^3.0.0, mime-types@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.2.tgz#39002d4182575d5af036ffa118100f2524b2e2ab" + integrity sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== + dependencies: + mime-db "^1.54.0" + mime@1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" @@ -13963,7 +14286,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3, ms@^2.1.1: +ms@2.1.3, ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -13993,6 +14316,11 @@ negotiator@0.6.3: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== +negotiator@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" + integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== + neo-async@^2.5.0, neo-async@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" @@ -14225,7 +14553,7 @@ nypm@^0.3.3: pathe "^1.1.2" ufo "^1.4.0" -object-assign@^4.0.1, object-assign@^4.1.1: +object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== @@ -14235,6 +14563,11 @@ object-inspect@^1.13.1: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff" integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + object-is@^1.1.5: version "1.1.6" resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07" @@ -14338,7 +14671,7 @@ ohash@^1.1.3: resolved "https://registry.yarnpkg.com/ohash/-/ohash-1.1.3.tgz#f12c3c50bfe7271ce3fd1097d42568122ccdcf07" integrity sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw== -on-finished@2.4.1: +on-finished@2.4.1, on-finished@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== @@ -14566,7 +14899,7 @@ parse5@^7.1.1: dependencies: entities "^4.4.0" -parseurl@~1.3.3: +parseurl@^1.3.3, parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== @@ -14645,6 +14978,11 @@ path-to-regexp@0.1.7: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== +path-to-regexp@^8.0.0: + version "8.4.2" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz#795c420c4f7ca45c5b887366f622ee0c9852cccd" + integrity sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA== + path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" @@ -14782,6 +15120,11 @@ pirates@^4.0.6: resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg== +pkce-challenge@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz#3b4446865b17b1745e9ace2016a31f48ddf6230d" + integrity sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ== + pkg-dir@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3" @@ -15259,7 +15602,7 @@ protobufjs@^7.2.4: "@types/node" ">=13.7.0" long "^5.0.0" -proxy-addr@~2.0.7: +proxy-addr@^2.0.7, proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== @@ -15331,6 +15674,13 @@ qs@^6.11.0, qs@^6.11.2: dependencies: side-channel "^1.0.6" +qs@^6.14.0, qs@^6.14.1: + version "6.15.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.1.tgz#bdb55aed06bfac257a90c44a446a73fba5575c8f" + integrity sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg== + dependencies: + side-channel "^1.1.0" + querystring-es3@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" @@ -15393,6 +15743,16 @@ raw-body@2.5.2: iconv-lite "0.4.24" unpipe "1.0.0" +raw-body@^3.0.0, raw-body@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.2.tgz#3e3ada5ae5568f9095d84376fd3a49b8fb000a51" + integrity sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA== + dependencies: + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.7.0" + unpipe "~1.0.0" + react-colorful@^5.1.2: version "5.6.1" resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b" @@ -16145,6 +16505,17 @@ rope-sequence@^1.3.0: resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.4.tgz#df85711aaecd32f1e756f76e43a415171235d425" integrity sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ== +router@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/router/-/router-2.2.0.tgz#019be620b711c87641167cc79b99090f00b146ef" + integrity sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ== + dependencies: + debug "^4.4.0" + depd "^2.0.0" + is-promise "^4.0.0" + parseurl "^1.3.3" + path-to-regexp "^8.0.0" + run-async@^2.4.0: version "2.4.1" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" @@ -16339,6 +16710,23 @@ send@0.18.0: range-parser "~1.2.1" statuses "2.0.1" +send@^1.1.0, send@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/send/-/send-1.2.1.tgz#9eab743b874f3550f40a26867bf286ad60d3f3ed" + integrity sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ== + dependencies: + debug "^4.4.3" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + fresh "^2.0.0" + http-errors "^2.0.1" + mime-types "^3.0.2" + ms "^2.1.3" + on-finished "^2.4.1" + range-parser "^1.2.1" + statuses "^2.0.2" + serialize-javascript@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa" @@ -16363,6 +16751,16 @@ serve-static@1.15.0: parseurl "~1.3.3" send "0.18.0" +serve-static@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-2.2.1.tgz#7f186a4a4e5f5b663ad7a4294ff1bf37cf0e98a9" + integrity sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw== + dependencies: + encodeurl "^2.0.0" + escape-html "^1.0.3" + parseurl "^1.3.3" + send "^1.2.0" + set-function-length@^1.2.1: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" @@ -16390,7 +16788,7 @@ setimmediate@^1.0.4: resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== -setprototypeof@1.2.0: +setprototypeof@1.2.0, setprototypeof@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== @@ -16456,6 +16854,35 @@ shimmer@^1.2.1: resolved "https://registry.yarnpkg.com/shimmer/-/shimmer-1.2.1.tgz#610859f7de327b587efebf501fb43117f9aff337" integrity sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw== +side-channel-list@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127" + integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + side-channel@^1.0.4, side-channel@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" @@ -16466,6 +16893,17 @@ side-channel@^1.0.4, side-channel@^1.0.6: get-intrinsic "^1.2.4" object-inspect "^1.13.1" +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + siginfo@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" @@ -16679,6 +17117,11 @@ statuses@2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== +statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" + integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== + std-env@^3.3.3, std-env@^3.7.0: version "3.7.0" resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.7.0.tgz#c9f7386ced6ecf13360b6c6c55b8aaa4ef7481d2" @@ -17346,7 +17789,7 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" -toidentifier@1.0.1: +toidentifier@1.0.1, toidentifier@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== @@ -17550,6 +17993,15 @@ type-fest@^4.1.0, type-fest@^4.8.3: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.9.0.tgz#d29c8efe5b1e703feeb29cef23d887b2f479844d" integrity sha512-KS/6lh/ynPGiHD/LnAobrEFq3Ad4pBzOlJ1wAnJx9N4EYoqFhMfLIBjUT2UEx4wg5ZE+cC1ob6DCSpppVo+rtg== +type-is@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.0.1.tgz#64f6cf03f92fce4015c2b224793f6bdd4b068c97" + integrity sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw== + dependencies: + content-type "^1.0.5" + media-typer "^1.1.0" + mime-types "^3.0.0" + type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" @@ -17914,7 +18366,7 @@ validator@^13.7.0: resolved "https://registry.yarnpkg.com/validator/-/validator-13.11.0.tgz#23ab3fd59290c61248364eabf4067f04955fbb1b" integrity sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ== -vary@~1.1.2: +vary@^1, vary@^1.1.2, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== @@ -18703,11 +19155,21 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== +zod-to-json-schema@^3.25.1: + version "3.25.2" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz#3fa799a7badd554541472fb65843fdc460b2e5aa" + integrity sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA== + zod@3.22.3: version "3.22.3" resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.3.tgz#2fbc96118b174290d94e8896371c95629e87a060" integrity sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug== +"zod@^3.25 || ^4.0": + version "4.4.3" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.4.3.tgz#b680f172885d18bbebf21a834ea25e55a1bbf356" + integrity sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ== + zwitch@^2.0.0: version "2.0.4" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" From ea620b4504eaa56b10c669e5b40f49f2ab65f440 Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Mon, 11 May 2026 22:18:53 -0500 Subject: [PATCH 03/22] Removed OpenAI call and Sarf-verb chart --- .env.example | 8 +- .../QuranReader/SyntaxView/SyntaxBody.tsx | 13 +- src/lib/syntaxAnalysisCharts.ts | 238 ++++++++++ src/lib/syntaxAnalysisQuranMcp.ts | 14 +- src/lib/syntaxAnalysisQuranMcpMorphology.ts | 62 ++- src/lib/syntaxChartsFromMcp.ts | 222 +++++++++ src/pages/api/syntax/analyze.ts | 429 +----------------- src/services/syntaxAnalysis.mock.ts | 2 +- 8 files changed, 543 insertions(+), 445 deletions(-) create mode 100644 src/lib/syntaxAnalysisCharts.ts create mode 100644 src/lib/syntaxChartsFromMcp.ts diff --git a/.env.example b/.env.example index 44ce2b21d4..a69f3cfb55 100644 --- a/.env.example +++ b/.env.example @@ -32,12 +32,8 @@ NEXT_PUBLIC_SSO_ENABLED=false NEXT_PUBLIC_EMBED_URL=https://quran.com/embed/v1 # Embed Ayah -# Optional: Syntax tab morphology analysis -# Provider: `openai` (default when OPENAI_API_KEY is set) or `quran_mcp` (https://mcp.quran.ai Streamable HTTP — no API key). -# SYNTAX_ANALYSIS_PROVIDER=quran_mcp +# Optional: Syntax tab — morphology + paradigm + charts via Quran MCP (https://mcp.quran.ai/) # QURAN_SYNTAX_MCP_URL=https://mcp.quran.ai/ -# OPENAI_API_KEY= -# SYNTAX_ANALYSIS_MODEL=gpt-4o-mini # -# Set to true to skip API/OpenAI — uses pasted JSON from src/services/syntaxAnalysis.mock.ts +# Set to true to skip MCP — uses pasted JSON from src/services/syntaxAnalysis.mock.ts # NEXT_PUBLIC_SYNTAX_ANALYSIS_MOCK=true \ No newline at end of file diff --git a/src/components/QuranReader/SyntaxView/SyntaxBody.tsx b/src/components/QuranReader/SyntaxView/SyntaxBody.tsx index 54358eec78..559dcd8fe3 100644 --- a/src/components/QuranReader/SyntaxView/SyntaxBody.tsx +++ b/src/components/QuranReader/SyntaxView/SyntaxBody.tsx @@ -165,24 +165,19 @@ const SyntaxBody: React.FC = (props) => { ))} - -
{JSON.stringify(analysis, null, 2)}
+ /> */} + )} - {selectedWord && ( -
- Raw word payload -
{JSON.stringify(selectedWord, null, 2)}
-
- )} + } diff --git a/src/lib/syntaxAnalysisCharts.ts b/src/lib/syntaxAnalysisCharts.ts new file mode 100644 index 0000000000..1ee7421e74 --- /dev/null +++ b/src/lib/syntaxAnalysisCharts.ts @@ -0,0 +1,238 @@ +/* eslint-disable max-lines -- chart normalization mirrors API contract */ +import type { + SyntaxAnalysisIsmChart, + SyntaxAnalysisResult, + SyntaxAnalysisSarfChart, + SyntaxAnalysisSarfColumnKey, + SyntaxAnalysisVerbChart, + SyntaxAnalysisVerbSlot, +} from 'types/SyntaxAnalysis'; + +const SYNTAX_SARF_KEYS: SyntaxAnalysisSarfColumnKey[] = [ + 'pastTense', + 'presentTense', + 'idea', + 'doer', +]; + +function isSarfFullColumnStrings(v: unknown): v is Record { + if (!v || typeof v !== 'object') return false; + const o = v as Record; + return SYNTAX_SARF_KEYS.every((k) => typeof o[k] === 'string'); +} + +export function normalizeSarfChart(raw: unknown): SyntaxAnalysisSarfChart | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const s = raw as Record; + if (!isSarfFullColumnStrings(s.columnHeaders) || !isSarfFullColumnStrings(s.activeVoice)) { + return undefined; + } + const partialStrings = (key: string): Partial> => { + const v = s[key]; + if (!v || typeof v !== 'object') return {}; + const o = v as Record; + const entries = SYNTAX_SARF_KEYS.filter((k) => typeof o[k] === 'string').map((k) => [ + k, + o[k], + ]) as [SyntaxAnalysisSarfColumnKey, string][]; + return Object.fromEntries(entries) as Partial>; + }; + return { + columnHeaders: s.columnHeaders, + activeVoice: s.activeVoice, + passiveVoiceLabels: partialStrings('passiveVoiceLabels'), + passiveVoiceForms: partialStrings('passiveVoiceForms'), + commandingLabels: partialStrings('commandingLabels'), + commandingForms: partialStrings('commandingForms'), + }; +} + +const ISM_CASE_KEYS = ['Rafa', 'Nasab', 'Jar'] as const; +const ISM_NUMBER_KEYS = ['singular', 'dual', 'plural'] as const; + +export function normalizeIsmChart(raw: unknown): SyntaxAnalysisIsmChart | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const chart = raw as Record; + const valid = (['Masculine', 'Feminine'] as const).every((gender) => { + const g = chart[gender]; + if (!g || typeof g !== 'object') return false; + const go = g as Record; + return ISM_CASE_KEYS.every((caseName) => { + const row = go[caseName]; + if (!row || typeof row !== 'object') return false; + const ro = row as Record; + return ISM_NUMBER_KEYS.every((num) => typeof ro[num] === 'string'); + }); + }); + return valid ? (raw as SyntaxAnalysisIsmChart) : undefined; +} + +const VERB_CHART_PERSON_KEYS = [ + '3rdPersonMasculine', + '3rdPersonFeminine', + '2ndPersonMasculine', + '2ndPersonFeminine', +] as const; + +const VERB_CHART_ALL_KEYS: readonly (keyof SyntaxAnalysisVerbChart)[] = [ + ...VERB_CHART_PERSON_KEYS, + '1stPerson', +]; + +function isVerbSlot(v: unknown): v is { pronoun: string; meaning: string; verb: string } { + if (!v || typeof v !== 'object') return false; + const o = v as Record; + return ( + typeof o.pronoun === 'string' && typeof o.meaning === 'string' && typeof o.verb === 'string' + ); +} + +function normalizeVerbChartStrict(raw: unknown): SyntaxAnalysisVerbChart | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const o = raw as Record; + const fourOk = VERB_CHART_PERSON_KEYS.every((key) => { + const block = o[key]; + if (!block || typeof block !== 'object') return false; + const b = block as Record; + return (['singular', 'dual', 'plural'] as const).every((num) => isVerbSlot(b[num])); + }); + if (!fourOk) return undefined; + const first = o['1stPerson']; + if (!first || typeof first !== 'object') return undefined; + const fb = first as Record; + if (!isVerbSlot(fb.singular) || !isVerbSlot(fb.plural)) return undefined; + return raw as SyntaxAnalysisVerbChart; +} + +function normalizeVerbChartBestEffort(raw: unknown): SyntaxAnalysisVerbChart | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const o = raw as Record; + const out: Partial = {}; + + VERB_CHART_ALL_KEYS.forEach((personKey) => { + const block = o[personKey]; + if (!block || typeof block !== 'object') return; + const b = block as Record; + + if (personKey === '1stPerson') { + if (!isVerbSlot(b.singular) || !isVerbSlot(b.plural)) return; + out['1stPerson'] = { + singular: b.singular as SyntaxAnalysisVerbSlot, + plural: b.plural as SyntaxAnalysisVerbSlot, + }; + return; + } + + const sg = b.singular; + const du = b.dual; + const pl = b.plural; + if (!isVerbSlot(sg) || !isVerbSlot(du) || !isVerbSlot(pl)) return; + out[personKey] = { + singular: sg as SyntaxAnalysisVerbSlot, + dual: du as SyntaxAnalysisVerbSlot, + plural: pl as SyntaxAnalysisVerbSlot, + }; + }); + + if (Object.keys(out).length === 0) return undefined; + return out as SyntaxAnalysisVerbChart; +} + +export function normalizeVerbChart(raw: unknown): SyntaxAnalysisVerbChart | undefined { + return normalizeVerbChartStrict(raw) ?? normalizeVerbChartBestEffort(raw); +} + +const VERB_PRESENT_CHART_JSON_KEYS = [ + 'verbPresentTenseChart', + 'verbPresentChart', + 'presentTenseVerbChart', +] as const; + +function pickFirstVerbChartRaw(r: Record, keys: readonly string[]): unknown { + const key = keys.find((k) => { + const v = r[k]; + return Boolean(v && typeof v === 'object'); + }); + return key ? r[key] : undefined; +} + +function verbTensePatternHints( + patternType: string, + wordPattern: string, +): { + looksPresent: boolean; + looksPast: boolean; +} { + const pt = patternType; + const wp = wordPattern; + const looksPresent = + /مضارع/.test(pt) || /مضارع/.test(wp) || /\b(imperfect|present\s+tense|\bpresent\b)/i.test(wp); + const looksPast = + /ماض[يى]/.test(pt) || /ماض[يى]/.test(wp) || /\b(perfect|past\s+tense|\bpast\b)/i.test(wp); + return { looksPresent, looksPast }; +} + +/* eslint-disable react-func/max-lines-per-function -- verb merge mirrors API rules */ +function mergeVerbChartsOntoResult( + result: SyntaxAnalysisResult, + r: Record, + patternType: string, + wordPattern: string, +): SyntaxAnalysisResult { + const { looksPresent, looksPast } = verbTensePatternHints(patternType, wordPattern); + + const legacyVerbChart = normalizeVerbChart(r.verbChart); + let verbPresentTenseChart = normalizeVerbChart( + pickFirstVerbChartRaw(r, VERB_PRESENT_CHART_JSON_KEYS), + ); + let verbPastTenseChart = normalizeVerbChart(r.verbPastTenseChart); + + const ambiguousTense = looksPresent && looksPast; + if (!ambiguousTense) { + if (!verbPresentTenseChart && legacyVerbChart && looksPresent) { + verbPresentTenseChart = legacyVerbChart; + } + if (!verbPastTenseChart && legacyVerbChart && looksPast) { + verbPastTenseChart = legacyVerbChart; + } + } + + let verbChartOut: SyntaxAnalysisVerbChart | undefined = legacyVerbChart; + if ( + verbChartOut && + (verbPresentTenseChart === verbChartOut || verbPastTenseChart === verbChartOut) + ) { + verbChartOut = undefined; + } + + let out = result; + if (verbPresentTenseChart) out = { ...out, verbPresentTenseChart }; + if (verbPastTenseChart) out = { ...out, verbPastTenseChart }; + if (verbChartOut) out = { ...out, verbChart: verbChartOut }; + return out; +} +/* eslint-enable react-func/max-lines-per-function */ + +/** + * Merges optional chart keys from a partial model payload onto an existing base result + * (same rules as full `/api/syntax/analyze` OpenAI normalization for charts). + * @returns {SyntaxAnalysisResult} Base plus any valid optional charts from `rawCharts`. + */ +export function applyOptionalChartsToResult( + base: SyntaxAnalysisResult, + rawCharts: unknown, +): SyntaxAnalysisResult { + if (!rawCharts || typeof rawCharts !== 'object') return base; + const r = rawCharts as Record; + const { patternType, wordPattern } = base.pattern; + + let result: SyntaxAnalysisResult = { ...base }; + const sarfChart = normalizeSarfChart(r.sarfChart); + if (sarfChart) result = { ...result, sarfChart }; + + result = mergeVerbChartsOntoResult(result, r, patternType, wordPattern); + + const ismChart = normalizeIsmChart(r.ismChart); + if (ismChart) result = { ...result, ismChart }; + return result; +} diff --git a/src/lib/syntaxAnalysisQuranMcp.ts b/src/lib/syntaxAnalysisQuranMcp.ts index 5caf2cacf3..c1747b7ac2 100644 --- a/src/lib/syntaxAnalysisQuranMcp.ts +++ b/src/lib/syntaxAnalysisQuranMcp.ts @@ -1,7 +1,9 @@ import { Client } from '@modelcontextprotocol/sdk/client'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp'; -import { runFetchWordMorphologyOnClient } from '@/lib/syntaxAnalysisQuranMcpMorphology'; +import { applyOptionalChartsToResult } from '@/lib/syntaxAnalysisCharts'; +import { buildOptionalChartsFromMcp } from '@/lib/syntaxChartsFromMcp'; +import { runMcpSyntaxStudyOnClient } from '@/lib/syntaxAnalysisQuranMcpMorphology'; import type { SyntaxAnalysisResult } from 'types/SyntaxAnalysis'; const DEFAULT_QURAN_MCP_URL = 'https://mcp.quran.ai/'; @@ -14,8 +16,9 @@ export type QuranMcpSyntaxOptions = { }; /** - * Grounded word morphology from [Quran MCP](https://mcp.quran.ai/) via Streamable HTTP. - * @returns {@link SyntaxAnalysisResult} derived from `fetch_word_morphology` (charts omitted). + * Grounded word study from [Quran MCP](https://mcp.quran.ai/) (`fetch_word_morphology` + `fetch_word_paradigm`). + * Sarf and verb charts are derived from MCP paradigm stems (same JSON shapes as the legacy analyzer). + * @returns {@link SyntaxAnalysisResult} with optional charts when paradigm data is available. */ export async function fetchSyntaxAnalysisViaQuranMcp( options: QuranMcpSyntaxOptions, @@ -29,7 +32,10 @@ export async function fetchSyntaxAnalysisViaQuranMcp( try { await client.connect(transport); - return await runFetchWordMorphologyOnClient(client, options.textUthmani, options.verseKey); + const bundle = await runMcpSyntaxStudyOnClient(client, options.textUthmani, options.verseKey); + return bundle.base; + //const rawCharts = buildOptionalChartsFromMcp(bundle.pickedWord, bundle.paradigm); + //return applyOptionalChartsToResult(bundle.base, rawCharts); } finally { await client.close().catch(() => undefined); } diff --git a/src/lib/syntaxAnalysisQuranMcpMorphology.ts b/src/lib/syntaxAnalysisQuranMcpMorphology.ts index 086a7fff8a..54ca735233 100644 --- a/src/lib/syntaxAnalysisQuranMcpMorphology.ts +++ b/src/lib/syntaxAnalysisQuranMcpMorphology.ts @@ -147,15 +147,46 @@ export function parseToolJsonPayload(result: unknown): unknown { return JSON.parse(textBlock.text) as unknown; } +export async function runFetchWordParadigmOnClient( + client: Client, + pickedWord: MorphologyWord, + textUthmani: string, + verseKey?: string, +): Promise { + try { + const args: Record = {}; + if (isValidAyahKey(verseKey)) { + args.ayah_key = verseKey.trim(); + args.word_text = textUthmani; + } else { + const lemma = str(pickedWord, 'lemma'); + if (!lemma) return null; + args.lemma = lemma; + } + const res = await client.callTool({ name: 'fetch_word_paradigm', arguments: args }); + return parseToolJsonPayload(res); + } catch { + return null; + } +} + +export type McpSyntaxStudyBundle = { + base: SyntaxAnalysisResult; + /** Resolved morphology row for `textUthmani` (used to build optional charts from MCP). */ + pickedWord: MorphologyWord; + morphologyResponse: unknown; + paradigm: unknown | null; +}; + /** - * Calls `fetch_grounding_rules` then `fetch_word_morphology` on an initialized MCP client. - * @returns {Promise} Normalized result for the resolved word (no optional charts). + * Morphology + optional paradigm from Quran MCP (grounding + tools). + * @returns {Promise} Base UI result, resolved word row, and raw MCP payloads for chart building. */ -export async function runFetchWordMorphologyOnClient( +export async function runMcpSyntaxStudyOnClient( client: Client, textUthmani: string, verseKey?: string, -): Promise { +): Promise { await client.callTool({ name: 'fetch_grounding_rules', arguments: {} }); const morphArgs: Record = {}; @@ -175,5 +206,26 @@ export async function runFetchWordMorphologyOnClient( const words = Array.isArray(payload.words) ? payload.words : []; const picked = pickMorphologyWord(words, textUthmani); if (!picked) throw new Error('Quran MCP returned no morphology for this word'); - return morphologyWordToSyntaxResult(picked); + + const paradigm = await runFetchWordParadigmOnClient(client, picked, textUthmani, verseKey); + + return { + base: morphologyWordToSyntaxResult(picked), + pickedWord: picked, + morphologyResponse: payload, + paradigm, + }; +} + +/** + * Calls `fetch_grounding_rules` then `fetch_word_morphology` on an initialized MCP client. + * @returns {Promise} Normalized result for the resolved word (no optional charts). + */ +export async function runFetchWordMorphologyOnClient( + client: Client, + textUthmani: string, + verseKey?: string, +): Promise { + const { base } = await runMcpSyntaxStudyOnClient(client, textUthmani, verseKey); + return base; } diff --git a/src/lib/syntaxChartsFromMcp.ts b/src/lib/syntaxChartsFromMcp.ts new file mode 100644 index 0000000000..7da2f9b104 --- /dev/null +++ b/src/lib/syntaxChartsFromMcp.ts @@ -0,0 +1,222 @@ +import type { MorphologyWord } from '@/lib/syntaxAnalysisQuranMcpMorphology'; +import type { + SyntaxAnalysisVerbChart, + SyntaxAnalysisVerbSlot, +} from 'types/SyntaxAnalysis'; + +type ParadigmStem = { stem: string; description: string }; + +function str(w: MorphologyWord, key: string): string | undefined { + const v = w[key]; + return typeof v === 'string' ? v : undefined; +} + +function asStemRows(raw: unknown): ParadigmStem[] { + if (!Array.isArray(raw)) return []; + return raw.flatMap((item) => { + if (!item || typeof item !== 'object') return []; + const o = item as Record; + const stem = typeof o.stem === 'string' ? o.stem.trim() : ''; + const description = typeof o.description === 'string' ? o.description.trim() : ''; + if (!stem) return []; + return [{ stem, description: description || stem }]; + }); +} + +type ParadigmPayload = { + perfect: ParadigmStem[]; + imperfect: ParadigmStem[]; + imperative: ParadigmStem[]; + lemma: string; + root: string; + gloss: string; +}; + +function firstLemmaGloss(payload: Record): string { + const cands = payload.candidate_lemmas; + if (!Array.isArray(cands) || !cands.length) return ''; + const first = cands[0]; + if (!first || typeof first !== 'object') return ''; + const g = (first as Record).gloss; + return typeof g === 'string' ? g.trim() : ''; +} + +function unwrapParadigmPayload(raw: unknown): ParadigmPayload | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + const inner = o.paradigm; + const bucket = + inner && typeof inner === 'object' && !Array.isArray(inner) + ? (inner as Record) + : o; + const perfect = asStemRows(bucket.perfect); + const imperfect = asStemRows(bucket.imperfect); + const imperative = asStemRows(bucket.imperative); + const lemma = typeof o.lemma === 'string' ? o.lemma.trim() : ''; + const root = typeof o.root === 'string' ? o.root.trim() : ''; + const gloss = firstLemmaGloss(o) || str(o as unknown as MorphologyWord, 'gloss')?.trim() || ''; + if (!perfect.length && !imperfect.length && !imperative.length) return null; + return { perfect, imperfect, imperative, lemma, root, gloss }; +} + +/** Maps English paradigm descriptions to our verb-grid slot coordinates. */ +type SlotCoord = { + chartKey: keyof SyntaxAnalysisVerbChart; + number: 'singular' | 'dual' | 'plural'; +}; + +function slotFromDescription(description: string): SlotCoord | null { + const d = description.toLowerCase(); + const has = (re: RegExp) => re.test(d); + + let person: '1' | '2' | '3' | null = null; + if (has(/\b1st\b/) || has(/\bfirst person\b/)) person = '1'; + else if (has(/\b2nd\b/) || has(/\bsecond person\b/)) person = '2'; + else if (has(/\b3rd\b/) || has(/\bthird person\b/)) person = '3'; + if (!person) return null; + + let number: 'singular' | 'dual' | 'plural' = 'singular'; + if (has(/\bdual\b/)) number = 'dual'; + else if (has(/\bplural\b/)) number = 'plural'; + + if (person === '1') { + const n: 'singular' | 'plural' = has(/\bplural\b/) ? 'plural' : 'singular'; + return { chartKey: '1stPerson', number: n }; + } + + const feminine = has(/\bfeminine\b/) && !has(/\bmasculine feminine\b/); + const chartKey = (() => { + if (person === '3') return feminine ? '3rdPersonFeminine' : '3rdPersonMasculine'; + if (person === '2') return feminine ? '2ndPersonFeminine' : '2ndPersonMasculine'; + return '3rdPersonMasculine'; + })(); + + return { chartKey, number }; +} + +function stemToSlot(row: ParadigmStem): SyntaxAnalysisVerbSlot { + return { + pronoun: '—', + meaning: row.description, + verb: row.stem, + }; +} + +function buildVerbChartFromStems(stems: ParadigmStem[]): SyntaxAnalysisVerbChart | undefined { + if (!stems.length) return undefined; + + const byCoord = new Map(); + const used = new Set(); + for (const row of stems) { + const coord = slotFromDescription(row.description); + if (!coord) continue; + const key = + coord.chartKey === '1stPerson' + ? `1stPerson:${coord.number}` + : `${coord.chartKey}:${coord.number}`; + if (!byCoord.has(key)) byCoord.set(key, row); + } + + const fallbackQueue = [...stems]; + const takeFallback = (): ParadigmStem => { + const next = fallbackQueue.find((s) => !used.has(s)) ?? stems[0]; + used.add(next); + return next; + }; + + const pick = (chartKey: keyof SyntaxAnalysisVerbChart, number: 'singular' | 'dual' | 'plural') => { + const mapKey = `${chartKey}:${number}`; + const hit = byCoord.get(mapKey); + if (hit) { + used.add(hit); + return stemToSlot(hit); + } + return stemToSlot(takeFallback()); + }; + + const chart: SyntaxAnalysisVerbChart = { + '3rdPersonMasculine': { + singular: pick('3rdPersonMasculine', 'singular'), + dual: pick('3rdPersonMasculine', 'dual'), + plural: pick('3rdPersonMasculine', 'plural'), + }, + '3rdPersonFeminine': { + singular: pick('3rdPersonFeminine', 'singular'), + dual: pick('3rdPersonFeminine', 'dual'), + plural: pick('3rdPersonFeminine', 'plural'), + }, + '2ndPersonMasculine': { + singular: pick('2ndPersonMasculine', 'singular'), + dual: pick('2ndPersonMasculine', 'dual'), + plural: pick('2ndPersonMasculine', 'plural'), + }, + '2ndPersonFeminine': { + singular: pick('2ndPersonFeminine', 'singular'), + dual: pick('2ndPersonFeminine', 'dual'), + plural: pick('2ndPersonFeminine', 'plural'), + }, + '1stPerson': { + singular: pick('1stPerson', 'singular'), + plural: pick('1stPerson', 'plural'), + }, + }; + + return chart; +} + +/** + * Builds optional chart payloads (same JSON keys as the former OpenAI chart pass) + * from Quran MCP morphology + `fetch_word_paradigm` stems. + */ +export function buildOptionalChartsFromMcp( + pickedWord: MorphologyWord, + paradigmRaw: unknown, +): Record { + const out: Record = {}; + const payload = unwrapParadigmPayload(paradigmRaw); + const surface = str(pickedWord, 'text_uthmani')?.trim() || ''; + const translation = str(pickedWord, 'translation')?.trim() || ''; + + if (payload && (payload.perfect.length || payload.imperfect.length || payload.imperative.length)) { + const lemmaOrRoot = payload.lemma || payload.root || surface || '—'; + const glossBit = payload.gloss ? ` (${payload.gloss})` : ''; + const ideaCell = payload.lemma ? `${payload.lemma}${glossBit}` : lemmaOrRoot; + + const firstPerfect = payload.perfect[0]?.stem || '—'; + const firstImperfect = payload.imperfect[0]?.stem || '—'; + const firstImperative = payload.imperative[0]?.stem; + + out.sarfChart = { + columnHeaders: { + pastTense: 'Past — فعل ماضٍ', + presentTense: 'Present — فعل مضارع', + idea: 'Lemma — المصدر / الجذر', + doer: 'Gloss — معنى', + }, + activeVoice: { + pastTense: firstPerfect, + presentTense: firstImperfect, + idea: ideaCell, + doer: payload.gloss || translation || payload.root || '—', + }, + passiveVoiceLabels: {}, + passiveVoiceForms: {}, + commandingLabels: firstImperative + ? { pastTense: 'Imperative — صيغة أمر (عيّنة من القرآن)' } + : {}, + commandingForms: firstImperative + ? { + pastTense: firstImperative, + ...(payload.imperative[1]?.stem ? { presentTense: payload.imperative[1].stem } : {}), + } + : {}, + }; + + const past = buildVerbChartFromStems(payload.perfect); + const present = buildVerbChartFromStems(payload.imperfect); + if (past) out.verbPastTenseChart = past; + if (present) out.verbPresentTenseChart = present; + } + + return out; +} diff --git a/src/pages/api/syntax/analyze.ts b/src/pages/api/syntax/analyze.ts index 4de75899e3..de96a0d977 100644 --- a/src/pages/api/syntax/analyze.ts +++ b/src/pages/api/syntax/analyze.ts @@ -1,282 +1,16 @@ -/* eslint-disable max-lines */ -/* eslint-disable react-func/max-lines-per-function */ -/* eslint-disable @typescript-eslint/naming-convention */ import type { NextApiRequest, NextApiResponse } from 'next'; import { fetchSyntaxAnalysisViaQuranMcp } from '@/lib/syntaxAnalysisQuranMcp'; -import type { - SyntaxAnalysisIsmChart, - SyntaxAnalysisResult, - SyntaxAnalysisSarfChart, - SyntaxAnalysisSarfColumnKey, - SyntaxAnalysisVerbChart, - SyntaxAnalysisVerbSlot, -} from 'types/SyntaxAnalysis'; +import type { SyntaxAnalysisResult } from 'types/SyntaxAnalysis'; type ErrorBody = { error: string }; const MAX_WORD_LENGTH = 200; -function extractJsonFromContent(content: string): unknown { - const trimmed = content.trim(); - const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)```$/m); - const jsonStr = fenced ? fenced[1].trim() : trimmed; - return JSON.parse(jsonStr); -} - -const SYNTAX_SARF_KEYS: SyntaxAnalysisSarfColumnKey[] = [ - 'pastTense', - 'presentTense', - 'idea', - 'doer', -]; - -function isSarfFullColumnStrings(v: unknown): v is Record { - if (!v || typeof v !== 'object') return false; - const o = v as Record; - return SYNTAX_SARF_KEYS.every((k) => typeof o[k] === 'string'); -} - -function normalizeSarfChart(raw: unknown): SyntaxAnalysisSarfChart | undefined { - if (!raw || typeof raw !== 'object') return undefined; - const s = raw as Record; - if (!isSarfFullColumnStrings(s.columnHeaders) || !isSarfFullColumnStrings(s.activeVoice)) { - return undefined; - } - const partialStrings = (key: string): Partial> => { - const v = s[key]; - if (!v || typeof v !== 'object') return {}; - const o = v as Record; - const entries = SYNTAX_SARF_KEYS.filter((k) => typeof o[k] === 'string').map((k) => [ - k, - o[k], - ]) as [SyntaxAnalysisSarfColumnKey, string][]; - return Object.fromEntries(entries) as Partial>; - }; - return { - columnHeaders: s.columnHeaders, - activeVoice: s.activeVoice, - passiveVoiceLabels: partialStrings('passiveVoiceLabels'), - passiveVoiceForms: partialStrings('passiveVoiceForms'), - commandingLabels: partialStrings('commandingLabels'), - commandingForms: partialStrings('commandingForms'), - }; -} - -const ISM_CASE_KEYS = ['Rafa', 'Nasab', 'Jar'] as const; -const ISM_NUMBER_KEYS = ['singular', 'dual', 'plural'] as const; - -function normalizeIsmChart(raw: unknown): SyntaxAnalysisIsmChart | undefined { - if (!raw || typeof raw !== 'object') return undefined; - const chart = raw as Record; - const valid = (['Masculine', 'Feminine'] as const).every((gender) => { - const g = chart[gender]; - if (!g || typeof g !== 'object') return false; - const go = g as Record; - return ISM_CASE_KEYS.every((caseName) => { - const row = go[caseName]; - if (!row || typeof row !== 'object') return false; - const ro = row as Record; - return ISM_NUMBER_KEYS.every((num) => typeof ro[num] === 'string'); - }); - }); - return valid ? (raw as SyntaxAnalysisIsmChart) : undefined; -} - -const VERB_CHART_PERSON_KEYS = [ - '3rdPersonMasculine', - '3rdPersonFeminine', - '2ndPersonMasculine', - '2ndPersonFeminine', -] as const; - -/** Full iteration order including 1st person (not part of the four person-number rows). */ -const VERB_CHART_ALL_KEYS: readonly (keyof SyntaxAnalysisVerbChart)[] = [ - ...VERB_CHART_PERSON_KEYS, - '1stPerson', -]; - -function isVerbSlot(v: unknown): v is { pronoun: string; meaning: string; verb: string } { - if (!v || typeof v !== 'object') return false; - const o = v as Record; - return ( - typeof o.pronoun === 'string' && typeof o.meaning === 'string' && typeof o.verb === 'string' - ); -} - -/** - * Strict grid: every person row must be complete (otherwise the whole chart is dropped). - * @returns {SyntaxAnalysisVerbChart | undefined} Parsed verb chart, or undefined if invalid. - */ -function normalizeVerbChartStrict(raw: unknown): SyntaxAnalysisVerbChart | undefined { - if (!raw || typeof raw !== 'object') return undefined; - const o = raw as Record; - const fourOk = VERB_CHART_PERSON_KEYS.every((key) => { - const block = o[key]; - if (!block || typeof block !== 'object') return false; - const b = block as Record; - return (['singular', 'dual', 'plural'] as const).every((num) => isVerbSlot(b[num])); - }); - if (!fourOk) return undefined; - const first = o['1stPerson']; - if (!first || typeof first !== 'object') return undefined; - const fb = first as Record; - if (!isVerbSlot(fb.singular) || !isVerbSlot(fb.plural)) return undefined; - return raw as SyntaxAnalysisVerbChart; -} - /** - * Keep every person block that is individually valid so one bad row (e.g. incomplete 1st person) - * does not strip the entire present/past chart from the API response. - * @returns {SyntaxAnalysisVerbChart | undefined} Chart with only valid person blocks, or undefined. + * POST `/api/syntax/analyze` — morphology + optional sarf/verb charts via + * [Quran MCP](https://mcp.quran.ai/documentation) (Streamable HTTP). */ -function normalizeVerbChartBestEffort(raw: unknown): SyntaxAnalysisVerbChart | undefined { - if (!raw || typeof raw !== 'object') return undefined; - const o = raw as Record; - const out: Partial = {}; - - VERB_CHART_ALL_KEYS.forEach((personKey) => { - const block = o[personKey]; - if (!block || typeof block !== 'object') return; - const b = block as Record; - - if (personKey === '1stPerson') { - if (!isVerbSlot(b.singular) || !isVerbSlot(b.plural)) return; - out['1stPerson'] = { - singular: b.singular as SyntaxAnalysisVerbSlot, - plural: b.plural as SyntaxAnalysisVerbSlot, - }; - return; - } - - const sg = b.singular; - const du = b.dual; - const pl = b.plural; - if (!isVerbSlot(sg) || !isVerbSlot(du) || !isVerbSlot(pl)) return; - out[personKey] = { - singular: sg as SyntaxAnalysisVerbSlot, - dual: du as SyntaxAnalysisVerbSlot, - plural: pl as SyntaxAnalysisVerbSlot, - }; - }); - - if (Object.keys(out).length === 0) return undefined; - return out as SyntaxAnalysisVerbChart; -} - -function normalizeVerbChart(raw: unknown): SyntaxAnalysisVerbChart | undefined { - return normalizeVerbChartStrict(raw) ?? normalizeVerbChartBestEffort(raw); -} - -const VERB_PRESENT_CHART_JSON_KEYS = [ - 'verbPresentTenseChart', - 'verbPresentChart', - 'presentTenseVerbChart', -] as const; - -function pickFirstVerbChartRaw(r: Record, keys: readonly string[]): unknown { - const key = keys.find((k) => { - const v = r[k]; - return Boolean(v && typeof v === 'object'); - }); - return key ? r[key] : undefined; -} - -function verbTensePatternHints( - patternType: string, - wordPattern: string, -): { - looksPresent: boolean; - looksPast: boolean; -} { - const looksPresent = - /مضارع/.test(patternType) || /\b(imperfect|present\s+tense|\bpresent\b)/i.test(wordPattern); - const looksPast = - /ماض[يى]/.test(patternType) || /\b(perfect|past\s+tense|\bpast\b)/i.test(wordPattern); - return { looksPresent, looksPast }; -} - -function normalizeResult(raw: unknown): SyntaxAnalysisResult | null { - if (!raw || typeof raw !== 'object') return null; - const r = raw as Record; - const root = r.rootLetter as Record | undefined; - const pattern = r.pattern as Record | undefined; - const breakdown = r.wordBreakDown; - - if ( - !root || - typeof root.arabicName !== 'string' || - typeof root.rootLetter !== 'string' || - !pattern || - typeof pattern.wordPattern !== 'string' || - typeof pattern.patternType !== 'string' || - !Array.isArray(breakdown) - ) { - return null; - } - - const { patternType, wordPattern } = pattern; - - const parts: SyntaxAnalysisResult['wordBreakDown'] = breakdown.flatMap((item) => { - if (!item || typeof item !== 'object') return []; - const p = item as Record; - if (typeof p.part === 'string' && typeof p.meaning === 'string') { - return [{ part: p.part, meaning: p.meaning }]; - } - return []; - }); - - const base: SyntaxAnalysisResult = { - rootLetter: { - arabicName: root.arabicName, - rootLetter: root.rootLetter, - }, - wordBreakDown: parts, - pattern: { - wordPattern: pattern.wordPattern, - patternType: pattern.patternType, - }, - }; - - let result: SyntaxAnalysisResult = base; - const sarfChart = normalizeSarfChart(r.sarfChart); - if (sarfChart) result = { ...result, sarfChart }; - - const { looksPresent, looksPast } = verbTensePatternHints(patternType, wordPattern); - - const legacyVerbChart = normalizeVerbChart(r.verbChart); - let verbPresentTenseChart = normalizeVerbChart( - pickFirstVerbChartRaw(r, VERB_PRESENT_CHART_JSON_KEYS), - ); - let verbPastTenseChart = normalizeVerbChart(r.verbPastTenseChart); - - const ambiguousTense = looksPresent && looksPast; - if (!ambiguousTense) { - if (!verbPresentTenseChart && legacyVerbChart && looksPresent) { - verbPresentTenseChart = legacyVerbChart; - } - if (!verbPastTenseChart && legacyVerbChart && looksPast) { - verbPastTenseChart = legacyVerbChart; - } - } - - let verbChartOut: SyntaxAnalysisVerbChart | undefined = legacyVerbChart; - if ( - verbChartOut && - (verbPresentTenseChart === verbChartOut || verbPastTenseChart === verbChartOut) - ) { - verbChartOut = undefined; - } - - if (verbPresentTenseChart) result = { ...result, verbPresentTenseChart }; - if (verbPastTenseChart) result = { ...result, verbPastTenseChart }; - if (verbChartOut) result = { ...result, verbChart: verbChartOut }; - - const ismChart = normalizeIsmChart(r.ismChart); - if (ismChart) result = { ...result, ismChart }; - return result; -} - export default async function handler( req: NextApiRequest, res: NextApiResponse, @@ -285,25 +19,6 @@ export default async function handler( return res.status(405).json({ error: 'Method not allowed' }); } - const apiKey = process.env.OPENAI_API_KEY; - const model = - process.env.SYNTAX_ANALYSIS_MODEL || process.env.OPENAI_SYNTAX_MODEL || 'gpt-4o-mini'; - - const envProvider = process.env.SYNTAX_ANALYSIS_PROVIDER?.trim().toLowerCase(); - /** - * `quran_mcp` — [Quran MCP](https://mcp.quran.ai/documentation) Streamable HTTP (no OpenAI key). - * `openai` — LLM JSON (requires OPENAI_API_KEY). - * Default: OpenAI when a key is set, otherwise Quran MCP. - */ - let syntaxProvider: 'openai' | 'quran_mcp'; - if (envProvider === 'quran_mcp' || envProvider === 'openai') { - syntaxProvider = envProvider; - } else if (apiKey) { - syntaxProvider = 'openai'; - } else { - syntaxProvider = 'quran_mcp'; - } - const { textUthmani, verseKey } = req.body as { textUthmani?: string; verseKey?: string; @@ -317,140 +32,14 @@ export default async function handler( return res.status(400).json({ error: 'textUthmani too long' }); } - if (syntaxProvider === 'openai' && !apiKey) { - return res.status(503).json({ - error: - 'Syntax analysis (OpenAI) is not configured. Set OPENAI_API_KEY, or set SYNTAX_ANALYSIS_PROVIDER=quran_mcp to use https://mcp.quran.ai/', - }); - } - - if (syntaxProvider === 'quran_mcp') { - try { - const fromMcp = await fetchSyntaxAnalysisViaQuranMcp({ - textUthmani: text, - verseKey: typeof verseKey === 'string' ? verseKey : undefined, - }); - return res.status(200).json(fromMcp); - } catch (e) { - const message = e instanceof Error ? e.message : 'Quran MCP syntax analysis failed'; - return res.status(502).json({ error: message }); - } - } - - const systemPrompt = `You are an expert in Quranic Arabic morphology, صرف (Sarf), and نحو. -Respond with ONLY valid JSON (no markdown fences). Include the REQUIRED fields below. When the analyzed word supports them, also include the OPTIONAL chart objects using these exact key names and nesting. - -REQUIRED: -- rootLetter: { "arabicName": string, "rootLetter": string } - - arabicName: short Arabic gloss or label for the root (may repeat or describe the letters). - - rootLetter: the lexical root as Arabic consonants (usually three letters in Arabic script). -- wordBreakDown: [ { "part": string, "meaning": string }, ... ] -- pattern: { "wordPattern": string, "patternType": string } - - patternType: concise Arabic grammatical category for this surface form (e.g. فعل ماضي، فعل مضارع، اسم فاعل، مصدر). - - wordPattern: fuller morphological description of THIS token — typically English (person, gender, number, verb form/bāb, noun case, etc.), e.g. "3rd person masculine singular (form IV) imperfect verb". - -OPTIONAL — include when relevant (omit entirely if not applicable). Prefer this order in your JSON object when multiple charts apply: - -1) sarfChart — verb-derived morphology table (active / passive / commanding rows for the UI): -{ - "sarfChart": { - "columnHeaders": { - "pastTense": string, - "presentTense": string, - "idea": string, - "doer": string - }, - "activeVoice": { "pastTense": string, "presentTense": string, "idea": string, "doer": string }, - "passiveVoiceLabels": { same four keys, strings (row labels e.g. Passive + Arabic grammar terms) }, - "passiveVoiceForms": { same four keys, Arabic strings }, - "commandingLabels": { optional keys among the four; strings for أمر / نهى / ظرف }, - "commandingForms": { same optional keys; Arabic; multiple ظرف variants may use " | " }, - For commanding rows omit "doer" or leave unused cells absent if there is no أمر/نهى/ظرف counterpart under doer. - } -} -- columnHeaders: human-readable titles per column, e.g. "PastTense - فعل ماضى", "PresentTense - فعل مضارع", "Idea - مصدر", "Doer - اسم فاعل". -- Align passiveVoiceLabels with passiveVoiceForms; commandingLabels with commandingForms. - -2) verbPresentTenseChart & verbPastTenseChart — same structure for مضارع and ماضي conjugations (full grid): -Each chart object has keys exactly: -"3rdPersonMasculine" | "3rdPersonFeminine" | "2ndPersonMasculine" | "2ndPersonFeminine" | "1stPerson" -- For 3rd/2nd persons each value is: { "singular": verbSlot, "dual": verbSlot, "plural": verbSlot } -- For "1stPerson": { "singular": verbSlot, "plural": verbSlot } only (no dual). -- verbSlot = { "pronoun": string (Arabic), "meaning": string (short English), "verb": string (Arabic conjugated form) } - -3) verbChart — legacy optional key; same object shape as verbPastTenseChart (full conjugation grid). Include when returned separately from verbPastTenseChart if needed. - -4) ismChart — اسم declension grid for a singular noun/adjective template: -{ - "ismChart": { - "Masculine": { - "Rafa": { "singular": string, "dual": string, "plural": string }, - "Nasab": { "singular": string, "dual": string, "plural": string }, - "Jar": { "singular": string, "dual": string, "plural": string } - }, - "Feminine": { - "Rafa": { "singular": string, "dual": string, "plural": string }, - "Nasab": { "singular": string, "dual": string, "plural": string }, - "Jar": { "singular": string, "dual": string, "plural": string } - } - } -} - -Use Arabic script for Arabic forms and pronouns; keep English glosses concise.`; - - const userPrompt = `Verse context: ${verseKey || 'unknown'} -Arabic word (Uthmani): ${text} - -Analyze this single word and fill the JSON.`; - try { - const openaiRes = await fetch('https://api.openai.com/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model, - temperature: 0.2, - response_format: { type: 'json_object' }, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt }, - ], - }), + const fromMcp = await fetchSyntaxAnalysisViaQuranMcp({ + textUthmani: text, + verseKey: typeof verseKey === 'string' ? verseKey : undefined, }); - - if (!openaiRes.ok) { - const errText = await openaiRes.text(); - return res.status(502).json({ - error: `OpenAI error (${openaiRes.status}): ${errText.slice(0, 200)}`, - }); - } - - const completion = (await openaiRes.json()) as { - choices?: Array<{ message?: { content?: string } }>; - }; - const content = completion.choices?.[0]?.message?.content; - if (!content) { - return res.status(502).json({ error: 'Empty model response' }); - } - - let parsed: unknown; - try { - parsed = extractJsonFromContent(content); - } catch { - return res.status(502).json({ error: 'Model returned invalid JSON' }); - } - - const normalized = normalizeResult(parsed); - if (!normalized) { - return res.status(502).json({ error: 'Could not normalize model output' }); - } - - return res.status(200).json(normalized); + return res.status(200).json(fromMcp); } catch (e) { - const message = e instanceof Error ? e.message : 'Syntax analysis failed'; - return res.status(500).json({ error: message }); + const message = e instanceof Error ? e.message : 'Quran MCP syntax analysis failed'; + return res.status(502).json({ error: message }); } } diff --git a/src/services/syntaxAnalysis.mock.ts b/src/services/syntaxAnalysis.mock.ts index b23fb23087..936384b85b 100644 --- a/src/services/syntaxAnalysis.mock.ts +++ b/src/services/syntaxAnalysis.mock.ts @@ -4,7 +4,7 @@ import type { SyntaxAnalysisResult } from 'types/SyntaxAnalysis'; /** * Paste a full `SyntaxAnalysisResult` JSON object here while - * `NEXT_PUBLIC_SYNTAX_ANALYSIS_MOCK=true` is set. No OpenAI/API call is made. + * `NEXT_PUBLIC_SYNTAX_ANALYSIS_MOCK=true` is set. No MCP/API call is made. * * Tip: paste from an API/tools response, keeping valid TypeScript/JSON shapes. */ From 0ce3897e0ae55f03a8580af875f77f61547fff19 Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Tue, 12 May 2026 22:51:10 -0500 Subject: [PATCH 04/22] Replaced icon with search icon. --- .../StudyModeModal/StudyModeBodyTabs.tsx | 4 +-- .../QuranReader/SyntaxView/SyntaxBody.tsx | 25 +++++++------------ 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBodyTabs.tsx b/src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBodyTabs.tsx index b9f41c76e9..fd927d0d28 100644 --- a/src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBodyTabs.tsx +++ b/src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBodyTabs.tsx @@ -21,12 +21,12 @@ import LightbulbOnIcon from '@/icons/lightbulb-on.svg'; import LightbulbIcon from '@/icons/lightbulb.svg'; import QiraatIcon from '@/icons/qiraat-icon.svg'; import RelatedVerseIcon from '@/icons/related-verses.svg'; +import SearchIcon from '@/icons/search.svg'; import { AyahHadithsResponse } from '@/types/Hadith'; import AyahQuestionsResponse from '@/types/QuestionsAndAnswers/AyahQuestionsResponse'; import Word from '@/types/Word'; import QuestionType from '@/types/QuestionsAndAnswers/QuestionType'; import { toLocalizedNumber } from '@/utils/locale'; -import { LineIcon } from 'react-share'; const Loading = () => (
@@ -167,7 +167,7 @@ export const useStudyModeTabs = ({ { id: StudyModeTabId.SYNTAX, label: 'Syntax', - icon: , + icon: , onClick: () => handleTabClick(StudyModeTabId.SYNTAX), condition: true, }, diff --git a/src/components/QuranReader/SyntaxView/SyntaxBody.tsx b/src/components/QuranReader/SyntaxView/SyntaxBody.tsx index 559dcd8fe3..d94efd52c9 100644 --- a/src/components/QuranReader/SyntaxView/SyntaxBody.tsx +++ b/src/components/QuranReader/SyntaxView/SyntaxBody.tsx @@ -28,7 +28,7 @@ interface SyntaxBodyProps { /** * Syntax tab body — layout and font scaling match TafsirBody (tafsirFontScale + generate-font-scales). - * Morphology JSON comes from `/api/syntax/analyze` (OpenAI when configured). + * Morphology JSON comes from `/api/syntax/analyze` (Quran MCP). */ const SyntaxBody: React.FC = (props) => { const { selectedWord, scrollToTop, chapterId, verseNumber } = props; @@ -130,22 +130,18 @@ const SyntaxBody: React.FC = (props) => {
Pattern - -

-

+

{analysis.pattern.wordPattern} {' '} - — + — {analysis.pattern.patternType}

- -

- - - -
Word breakdown @@ -165,19 +161,16 @@ const SyntaxBody: React.FC = (props) => { ))}
- {/* */} - + /> )} - - } From 5bd74ff8cc05f3eb2e209b1a494052618b27449a Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Wed, 13 May 2026 23:00:50 -0500 Subject: [PATCH 05/22] Added markdown file about the design and list of file added/updated. --- docs/syntax-study-mode-design.md | 177 +++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 docs/syntax-study-mode-design.md diff --git a/docs/syntax-study-mode-design.md b/docs/syntax-study-mode-design.md new file mode 100644 index 0000000000..f4c08f7428 --- /dev/null +++ b/docs/syntax-study-mode-design.md @@ -0,0 +1,177 @@ +# Syntax analysis & Study Mode — design notes + +This document describes the feature work on the **current branch** compared to **`origin/production`**: a new **Syntax** experience in Quran Reader **Study Mode**, backed by a **Next.js API route** that calls **Quran MCP** for morphology (and optional paradigm-derived charts). + +**Baseline:** `git diff origin/production...HEAD` +**External reference:** [Quran MCP documentation](https://mcp.quran.ai/documentation) (Streamable HTTP, `fetch_grounding_rules`, `fetch_word_morphology`, `fetch_word_paradigm`). + +--- + +## 1. Summary + +| Area | Change | +|------|--------| +| **Study Mode** | New bottom tab **`syntax`** with search-style icon; opens grammatical analysis for the **selected word** in the verse. | +| **UI** | New **Syntax** view: layout/skeleton, morphology text, optional **sarf / verb / ism** charts (types in `types/SyntaxAnalysis.ts`). | +| **API** | `POST /api/syntax/analyze` — validates input, calls server-side Quran MCP client, returns `SyntaxAnalysisResult`. | +| **Data** | Morphology from MCP mapped to `rootLetter`, `pattern`, `wordBreakDown`; paradigm stems optionally merged into charts via `syntaxChartsFromMcp` + `syntaxAnalysisCharts`. | +| **Mock** | `NEXT_PUBLIC_SYNTAX_ANALYSIS_MOCK=true` skips MCP and returns `syntaxAnalysis.mock.ts`. | +| **Infra / other** | `@modelcontextprotocol/sdk` dependency; TS path alias for MCP ESM; content proxy URL behavior; middleware `_next/data` handling scoped to production; Verse font tweak in tafsir/translation mode. | + +--- + +## 2. User-facing flow (Study Mode) + +1. User opens **Study Mode** on a verse (existing flow). +2. User selects a **word** in the verse (word tap / selection used elsewhere for tafsir, etc.). +3. User taps the **Syntax** tab (icon: `public/icons/search.svg`, same `color` pattern as Tafsir’s `BookIcon`). +4. **`StudyModeSyntaxTab`** loads **`SyntaxBody`** (dynamic import + skeleton). +5. **`SyntaxBody`** uses **SWR** with a key derived from `selectedWord` location, Uthmani text, and `verseKey`. +6. **`fetchSyntaxAnalysis`** posts to **`/api/syntax/analyze`** unless mock mode is on. +7. Response drives **morphology** copy and **`SyntaxAnalysisCharts`** when `verb*`, `sarfChart`, or `ismChart` are present and pass normalization. + +**Key files:** `StudyModeBottomActions` (`StudyModeTabId.SYNTAX`), `StudyModeBodyTabs.tsx`, `StudyModeBody.tsx`, `StudyModeModal/index.tsx`, `tabs/StudyModeSyntaxTab.tsx`, `SyntaxView/*`. + +--- + +## 3. API: `POST /api/syntax/analyze` + +| Item | Detail | +|------|--------| +| **Path** | `/api/syntax/analyze` | +| **File** | `src/pages/api/syntax/analyze.ts` | +| **Method** | `POST` only (`405` otherwise). | +| **Body (JSON)** | `{ "textUthmani": string, "verseKey"?: string }` | +| **Validation** | `textUthmani` required, non-empty after trim; max length **200** characters. | +| **Success** | `200` + `SyntaxAnalysisResult` | +| **Errors** | `400` (bad input), `502` + `{ error: string }` (MCP failure / thrown error). | + +**`verseKey` format:** When present, should match `surah:ayah` (e.g. `2:255`) so morphology and paradigm calls can use **ayah-scoped** MCP arguments (see `isValidAyahKey` in morphology helper). + +**Client:** `src/services/syntaxAnalysisService.ts` — `fetch('/api/syntax/analyze', { method: 'POST', ... })`, throws `Error` with server message when `!res.ok` or body contains `error`. + +--- + +## 4. Quran MCP server flow + +All MCP calls run **on the server** inside `fetchSyntaxAnalysisViaQuranMcp` (`src/lib/syntaxAnalysisQuranMcp.ts`) so the browser never holds MCP transport credentials beyond same-origin API. + +### 4.1 Connection + +- **Transport:** `@modelcontextprotocol/sdk` `StreamableHTTPClientTransport`. +- **URL:** `process.env.QURAN_SYNTAX_MCP_URL` or default `https://mcp.quran.ai/` (trailing slash normalized). +- **Client name:** `quran.com-frontend` (version `1.0.0`). + +### 4.2 Tool sequence (`runMcpSyntaxStudyOnClient` in `syntaxAnalysisQuranMcpMorphology.ts`) + +1. **`fetch_grounding_rules`** — session grounding per Quran MCP docs. +2. **`fetch_word_morphology`** + - If `verseKey` is valid `surah:ayah`: arguments `ayah_key`, `word_text`. + - Else: argument `word` = Uthmani text. +3. **Parse** structured payload → `words[]`; **pick** row matching `textUthmani` (`pickMorphologyWord`). +4. **`fetch_word_paradigm`** (best-effort; may return `null` on error) + - If valid ayah key: `ayah_key` + `word_text`. + - Else: `lemma` from picked word when available. +5. **Return bundle:** `base` (`SyntaxAnalysisResult` from morphology), `pickedWord`, `morphologyResponse`, `paradigm`. + +### 4.3 Mapping morphology → `SyntaxAnalysisResult` + +- **`morphologyWordToSyntaxResult`:** builds `rootLetter`, `pattern` (English line from `description` / `grammatical_features`, Arabic-ish `patternType` from POS/aspect/case), `wordBreakDown` from `morpheme_segments` or whole word + translation. + +### 4.4 Optional charts + +- **`syntaxChartsFromMcp.ts`** — builds partial chart objects from **paradigm** stems (`perfect` / `imperfect` / `imperative`) and picked word metadata where applicable. +- **`syntaxAnalysisCharts.ts`** — `applyOptionalChartsToResult`, `normalizeSarfChart`, `normalizeVerbChart`, `normalizeIsmChart`, and verb present/past merge rules aligned with the UI tables. + +`fetchSyntaxAnalysisViaQuranMcp` merges: `applyOptionalChartsToResult(bundle.base, buildOptionalChartsFromMcp(bundle.pickedWord, bundle.paradigm))`. + +--- + +## 5. Types & UI charts + +- **`types/SyntaxAnalysis.ts`** — `SyntaxAnalysisResult` and optional `sarfChart`, `verbPresentTenseChart`, `verbPastTenseChart`, `verbChart`, `ismChart`. +- **`SyntaxChartTables.tsx`** — renders grids when props are defined. +- **`useSyntaxChartArabicTypography.ts`** — shared Arabic typography for Syntax view. + +--- + +## 6. Configuration & mock + +| Variable | Role | +|----------|------| +| `QURAN_SYNTAX_MCP_URL` | Optional override for MCP Streamable HTTP base URL. | +| `NEXT_PUBLIC_SYNTAX_ANALYSIS_MOCK` | When `true`, client returns `SYNTAX_ANALYSIS_MOCK_RESPONSE` (no `/api/syntax/analyze` call). | + +Documented in `.env.example` (syntax / MCP section). + +--- + +## 7. Supporting / unrelated branch diffs (still listed) + +- **`middleware.ts`** — `_next/data` 404 behavior only in **production** (avoids dev disruption). +- **`src/utils/url.ts`** — `getProxiedServiceUrl` for `CONTENT` service uses staging vs production CDN hosts. +- **`tsconfig.json`** — path alias `@modelcontextprotocol/sdk/*` → ESM dist (bundler resolution). +- **`package.json` / `yarn.lock`** — adds `@modelcontextprotocol/sdk`. +- **`VerseText.module.scss`** — mobile `tafsirOrTranslationMode` font scale factor adjusted (`1.2` → `0.75` of `--font-size`). + +--- + +## 8. File change list (`origin/production...HEAD`) + +| Status | Path | Short description | +|--------|------|---------------------| +| M | `.env.example` | Document Quran MCP URL and syntax mock flag. | +| M | `package.json` | Add `@modelcontextprotocol/sdk`. | +| M | `yarn.lock` | Lockfile for new dependency. | +| M | `tsconfig.json` | MCP SDK path alias. | +| M | `src/middleware.ts` | Gate `_next/data` 404 on `NODE_ENV === 'production'`. | +| M | `src/utils/url.ts` | Direct CDN URLs for content service proxy branch. | +| M | `src/components/Verse/VerseText.module.scss` | Tafsir/translation mode font sizing tweak. | +| M | `src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBody.tsx` | Wire Syntax tab panel / props (e.g. `selectedWord`). | +| M | `src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBodyTabs.tsx` | Register `StudyModeSyntaxTab`, tab config, **Search** icon for Syntax. | +| M | `src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBottomActions/index.tsx` | Add `StudyModeTabId.SYNTAX`. | +| M | `src/components/QuranReader/ReadingView/StudyModeModal/index.tsx` | Study Mode state / layout for Syntax tab. | +| A | `.../tabs/StudyModeSyntaxTab.tsx` | Lazy tab shell: scroll container + dynamic `SyntaxBody`. | +| A | `src/components/QuranReader/SyntaxView/SyntaxBody.tsx` | SWR + morphology UI + charts. | +| A | `src/components/QuranReader/SyntaxView/SyntaxChartTables.tsx` | Sarf / verb / ism tables. | +| A | `src/components/QuranReader/SyntaxView/SyntaxSkeleton.tsx` | Loading UI for dynamic import. | +| A | `src/components/QuranReader/SyntaxView/SyntaxSkeleton.module.scss` | Skeleton styles. | +| A | `src/components/QuranReader/SyntaxView/SyntaxTabLayout.tsx` | Shared tab layout / scroll hook export. | +| A | `src/components/QuranReader/SyntaxView/SyntaxTabLayout.module.scss` | Layout styles. | +| A | `src/components/QuranReader/SyntaxView/SyntaxView.module.scss` | Syntax body styles. | +| A | `src/components/QuranReader/SyntaxView/useSyntaxChartArabicTypography.ts` | Arabic font helpers for charts/body. | +| A | `src/lib/syntaxAnalysisCharts.ts` | Normalize + merge optional charts onto base result. | +| A | `src/lib/syntaxAnalysisQuranMcp.ts` | MCP client connect + fetch bundle + merge charts. | +| A | `src/lib/syntaxAnalysisQuranMcpMorphology.ts` | MCP tool calls, pick word, map to `SyntaxAnalysisResult`. | +| A | `src/lib/syntaxChartsFromMcp.ts` | Build chart-shaped JSON from paradigm + picked word. | +| A | `src/pages/api/syntax/analyze.ts` | POST API handler calling `fetchSyntaxAnalysisViaQuranMcp`. | +| A | `src/services/syntaxAnalysis.mock.ts` | Full mock `SyntaxAnalysisResult` for local UI. | +| A | `src/services/syntaxAnalysisService.ts` | Client fetch + mock gate + `getWordTextUthmaniForSyntax`. | +| A | `types/SyntaxAnalysis.ts` | Shared TS types for API + UI. | + +--- + +## 9. Diagram (high level) + +```mermaid +sequenceDiagram + participant U as Browser + participant SB as SyntaxBody + participant API as POST /api/syntax/analyze + participant MCP as Quran MCP (Streamable HTTP) + + U->>SB: Select word, open Syntax tab + SB->>API: JSON textUthmani, verseKey + API->>MCP: connect + grounding + morphology + paradigm + MCP-->>API: words + paradigm payload + API-->>SB: SyntaxAnalysisResult (+ optional charts) + SB-->>U: Render morphology + charts +``` + +--- + +## 10. Maintenance notes + +- Regenerate this file list anytime with: + `git fetch origin production && git diff --name-status origin/production...HEAD` +- If MCP tools or response shapes change upstream, update **`syntaxAnalysisQuranMcpMorphology.ts`** and **`syntaxChartsFromMcp.ts`** together so the API contract in **`types/SyntaxAnalysis.ts`** stays satisfied. From 6cae285a945f7063430a0f292441646e6f783609 Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Sat, 16 May 2026 12:04:31 -0500 Subject: [PATCH 06/22] Removed changes needed for local setup and updated doc. --- docs/syntax-study-mode-design.md | 33 ++++++++++++++++++++++++++------ src/middleware.ts | 2 +- src/utils/url.ts | 11 ----------- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/docs/syntax-study-mode-design.md b/docs/syntax-study-mode-design.md index f4c08f7428..afbed6d029 100644 --- a/docs/syntax-study-mode-design.md +++ b/docs/syntax-study-mode-design.md @@ -16,7 +16,7 @@ This document describes the feature work on the **current branch** compared to * | **API** | `POST /api/syntax/analyze` — validates input, calls server-side Quran MCP client, returns `SyntaxAnalysisResult`. | | **Data** | Morphology from MCP mapped to `rootLetter`, `pattern`, `wordBreakDown`; paradigm stems optionally merged into charts via `syntaxChartsFromMcp` + `syntaxAnalysisCharts`. | | **Mock** | `NEXT_PUBLIC_SYNTAX_ANALYSIS_MOCK=true` skips MCP and returns `syntaxAnalysis.mock.ts`. | -| **Infra / other** | `@modelcontextprotocol/sdk` dependency; TS path alias for MCP ESM; content proxy URL behavior; middleware `_next/data` handling scoped to production; Verse font tweak in tafsir/translation mode. | +| **Infra / other** | `@modelcontextprotocol/sdk` dependency; TS path alias for MCP ESM; Verse font tweak in tafsir/translation mode. `middleware.ts` and `url.ts` match **`origin/production`** (no Syntax-specific edits). | --- @@ -106,10 +106,33 @@ Documented in `.env.example` (syntax / MCP section). --- -## 7. Supporting / unrelated branch diffs (still listed) +## 7. Middleware & URL utilities (current behavior, same as production) + +These files are **not** part of the Syntax feature contract. They are documented here because an earlier branch revision briefly changed them; the **current** sources match **`origin/production`**. + +### 7.1 `src/middleware.ts` + +| Behavior | Detail | +|----------|--------| +| **`_next/data` requests** | If `req.url` includes `_next/data`, respond with **`404`** and an empty body. Intended to force a **full page reload** after a new deployment instead of serving stale client-side navigation payloads. Applies in **all** environments (including local `yarn dev`), not gated on `NODE_ENV`. | +| **Ramadan routes** | Paths containing `/ramadan2026` or `/ramadanchallenge` (case-insensitive) redirect to the **lowercase** pathname when the URL is not already lowercase. | +| **Everything else** | `NextResponse.next()`. | + +**Implication for local dev:** Client transitions that rely on `_next/data` JSON may get `404` from middleware; a hard refresh or full navigation is expected after deploys. This is unrelated to `/api/syntax/analyze`. + +### 7.2 `src/utils/url.ts` + +`getProxiedServiceUrl(service, path)` builds backend URLs for Quran Foundation services. There is **no** special branch for `QuranFoundationService.CONTENT` that bypasses the app proxy. + +| Condition | Base URL | +|-----------|----------| +| **Static build** (`isStaticBuild`) | `${API_GATEWAY_URL}/${service}${path}` | +| **Otherwise** | `${getBasePath()}/api/proxy/${service}${path}` where `getBasePath()` is `http://` or `https://` + `NEXT_PUBLIC_VERCEL_URL` depending on `NEXT_PUBLIC_VERCEL_ENV === 'development'`. | + +All services in `QuranFoundationService` (`search`, `auth`, `content`, `quran-reflect`) use the same proxy pattern. Syntax analysis does **not** call this helper; it uses **`POST /api/syntax/analyze`** → Quran MCP on the server. + +### 7.3 Other branch diffs (Syntax-related infra) -- **`middleware.ts`** — `_next/data` 404 behavior only in **production** (avoids dev disruption). -- **`src/utils/url.ts`** — `getProxiedServiceUrl` for `CONTENT` service uses staging vs production CDN hosts. - **`tsconfig.json`** — path alias `@modelcontextprotocol/sdk/*` → ESM dist (bundler resolution). - **`package.json` / `yarn.lock`** — adds `@modelcontextprotocol/sdk`. - **`VerseText.module.scss`** — mobile `tafsirOrTranslationMode` font scale factor adjusted (`1.2` → `0.75` of `--font-size`). @@ -124,8 +147,6 @@ Documented in `.env.example` (syntax / MCP section). | M | `package.json` | Add `@modelcontextprotocol/sdk`. | | M | `yarn.lock` | Lockfile for new dependency. | | M | `tsconfig.json` | MCP SDK path alias. | -| M | `src/middleware.ts` | Gate `_next/data` 404 on `NODE_ENV === 'production'`. | -| M | `src/utils/url.ts` | Direct CDN URLs for content service proxy branch. | | M | `src/components/Verse/VerseText.module.scss` | Tafsir/translation mode font sizing tweak. | | M | `src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBody.tsx` | Wire Syntax tab panel / props (e.g. `selectedWord`). | | M | `src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBodyTabs.tsx` | Register `StudyModeSyntaxTab`, tab config, **Search** icon for Syntax. | diff --git a/src/middleware.ts b/src/middleware.ts index cf5535c9f8..f77dbc759a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; export default function middleware(req: NextRequest) { // If the request is for _next/data, return a 404 response // This forces a full page reload when a new deployment is made - if (process.env.NODE_ENV === 'production' && req.url.includes('_next/data')) { + if (req.url.includes('_next/data')) { return new NextResponse(null, { status: 404 }); } diff --git a/src/utils/url.ts b/src/utils/url.ts index 0af4b9a0f8..6f8ce23be1 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -9,9 +9,6 @@ export enum QuranFoundationService { QURAN_REFLECT = 'quran-reflect', } -const STAGING_CONTENT_HOST = 'https://staging.quran.com'; -const PRODUCTION_CONTENT_HOST = 'https://api.qurancdn.com'; - export const getCurrentPath = () => { if (typeof window !== 'undefined') { return window.location.href; @@ -71,14 +68,6 @@ export const getBasePath = (): string => }`; export const getProxiedServiceUrl = (service: QuranFoundationService, path: string): string => { - if (service === QuranFoundationService.CONTENT) { - const contentHost = - process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' - ? PRODUCTION_CONTENT_HOST - : STAGING_CONTENT_HOST; - return `${contentHost}${path}`; - } - const PROXY_PATH = `/api/proxy/${service}`; const BASE_PATH = isStaticBuild ? `${process.env.API_GATEWAY_URL}/${service}` From d6e35b67ca8fa14ae79f3d1606480855f17ba3ad Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Mon, 18 May 2026 18:40:53 -0500 Subject: [PATCH 07/22] ci: pin Node.js to 18 via .nvmrc for Netlify builds --- src/middleware.ts | 2 +- src/utils/url.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/middleware.ts b/src/middleware.ts index f77dbc759a..cf5535c9f8 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; export default function middleware(req: NextRequest) { // If the request is for _next/data, return a 404 response // This forces a full page reload when a new deployment is made - if (req.url.includes('_next/data')) { + if (process.env.NODE_ENV === 'production' && req.url.includes('_next/data')) { return new NextResponse(null, { status: 404 }); } diff --git a/src/utils/url.ts b/src/utils/url.ts index 6f8ce23be1..0af4b9a0f8 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -9,6 +9,9 @@ export enum QuranFoundationService { QURAN_REFLECT = 'quran-reflect', } +const STAGING_CONTENT_HOST = 'https://staging.quran.com'; +const PRODUCTION_CONTENT_HOST = 'https://api.qurancdn.com'; + export const getCurrentPath = () => { if (typeof window !== 'undefined') { return window.location.href; @@ -68,6 +71,14 @@ export const getBasePath = (): string => }`; export const getProxiedServiceUrl = (service: QuranFoundationService, path: string): string => { + if (service === QuranFoundationService.CONTENT) { + const contentHost = + process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' + ? PRODUCTION_CONTENT_HOST + : STAGING_CONTENT_HOST; + return `${contentHost}${path}`; + } + const PROXY_PATH = `/api/proxy/${service}`; const BASE_PATH = isStaticBuild ? `${process.env.API_GATEWAY_URL}/${service}` From e97acb8fb7fab4c7bae3ff2bafbd37a983a4d200 Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Mon, 18 May 2026 20:19:07 -0500 Subject: [PATCH 08/22] Added redirects for netlify --- src/_redirects | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/_redirects diff --git a/src/_redirects b/src/_redirects new file mode 100644 index 0000000000..f8243379a0 --- /dev/null +++ b/src/_redirects @@ -0,0 +1 @@ +/* /index.html 200 \ No newline at end of file From 912bebd70d88e8e03906e56cabc9091c28d69ff9 Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Mon, 18 May 2026 20:35:06 -0500 Subject: [PATCH 09/22] added netlify.toml to resolve 404 error in netlify --- src/netlify.toml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/netlify.toml diff --git a/src/netlify.toml b/src/netlify.toml new file mode 100644 index 0000000000..59d90da776 --- /dev/null +++ b/src/netlify.toml @@ -0,0 +1,9 @@ +[build] + command = "npm run build" + publish = "/build" + base = "/" + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 \ No newline at end of file From c0d838f29bf78c908dad038b614529464e6c3aec Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Mon, 18 May 2026 21:51:58 -0500 Subject: [PATCH 10/22] added netlify.toml and redirect at root level --- src/_redirects => _redirects | 0 src/netlify.toml => netlify.toml | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/_redirects => _redirects (100%) rename src/netlify.toml => netlify.toml (100%) diff --git a/src/_redirects b/_redirects similarity index 100% rename from src/_redirects rename to _redirects diff --git a/src/netlify.toml b/netlify.toml similarity index 100% rename from src/netlify.toml rename to netlify.toml From 35dd1a8a5d3de2e484921341bc0a8bfdcdfe0f93 Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Mon, 18 May 2026 21:59:10 -0500 Subject: [PATCH 11/22] Updated netlify based on the AI suggestion --- _redirects | 1 - netlify.toml | 14 +++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) delete mode 100644 _redirects diff --git a/_redirects b/_redirects deleted file mode 100644 index f8243379a0..0000000000 --- a/_redirects +++ /dev/null @@ -1 +0,0 @@ -/* /index.html 200 \ No newline at end of file diff --git a/netlify.toml b/netlify.toml index 59d90da776..49a22a755f 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,9 +1,9 @@ +# Next.js on Netlify — https://docs.netlify.com/integrations/frameworks/next-js/ [build] - command = "npm run build" - publish = "/build" - base = "/" + command = "yarn build" -[[redirects]] - from = "/*" - to = "/index.html" - status = 200 \ No newline at end of file +[build.environment] + NODE_VERSION = "18" + +[[plugins]] + package = "@netlify/plugin-nextjs" From 217e7e34a27349e312f06ebad5f8c029fd77af99 Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Mon, 18 May 2026 22:20:36 -0500 Subject: [PATCH 12/22] Updated files for fixing yarn build issue --- .../StudyModeModal/StudyModeBodyTabs.tsx | 2 +- .../tabs/StudyModeSyntaxTab.tsx | 5 +++- .../QuranReader/SyntaxView/SyntaxBody.tsx | 10 ++------ .../QuranReader/SyntaxView/SyntaxSkeleton.tsx | 5 ++-- .../SyntaxView/SyntaxTabLayout.tsx | 9 +++++-- src/lib/syntaxAnalysisQuranMcp.ts | 7 +++--- src/lib/syntaxChartsFromMcp.ts | 19 ++++++++++----- src/pages/api/syntax/analyze.ts | 1 + src/services/syntaxAnalysisService.ts | 24 +++++++++++-------- types/SyntaxAnalysis.ts | 6 +---- 10 files changed, 49 insertions(+), 39 deletions(-) diff --git a/src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBodyTabs.tsx b/src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBodyTabs.tsx index fd927d0d28..cbda55accd 100644 --- a/src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBodyTabs.tsx +++ b/src/components/QuranReader/ReadingView/StudyModeModal/StudyModeBodyTabs.tsx @@ -24,8 +24,8 @@ import RelatedVerseIcon from '@/icons/related-verses.svg'; import SearchIcon from '@/icons/search.svg'; import { AyahHadithsResponse } from '@/types/Hadith'; import AyahQuestionsResponse from '@/types/QuestionsAndAnswers/AyahQuestionsResponse'; -import Word from '@/types/Word'; import QuestionType from '@/types/QuestionsAndAnswers/QuestionType'; +import Word from '@/types/Word'; import { toLocalizedNumber } from '@/utils/locale'; const Loading = () => ( diff --git a/src/components/QuranReader/ReadingView/StudyModeModal/tabs/StudyModeSyntaxTab.tsx b/src/components/QuranReader/ReadingView/StudyModeModal/tabs/StudyModeSyntaxTab.tsx index 49816d4f62..ea5da99a79 100644 --- a/src/components/QuranReader/ReadingView/StudyModeModal/tabs/StudyModeSyntaxTab.tsx +++ b/src/components/QuranReader/ReadingView/StudyModeModal/tabs/StudyModeSyntaxTab.tsx @@ -3,7 +3,10 @@ import React from 'react'; import dynamic from 'next/dynamic'; import SyntaxSkeleton from '@/components/QuranReader/SyntaxView/SyntaxSkeleton'; -import { useSyntaxTabScroll, syntaxTabStyles as styles } from '@/components/QuranReader/SyntaxView/SyntaxTabLayout'; +import { + useSyntaxTabScroll, + syntaxTabStyles as styles, +} from '@/components/QuranReader/SyntaxView/SyntaxTabLayout'; import Word from '@/types/Word'; const SyntaxBody = dynamic(() => import('@/components/QuranReader/SyntaxView/SyntaxBody'), { diff --git a/src/components/QuranReader/SyntaxView/SyntaxBody.tsx b/src/components/QuranReader/SyntaxView/SyntaxBody.tsx index d94efd52c9..0680cee26d 100644 --- a/src/components/QuranReader/SyntaxView/SyntaxBody.tsx +++ b/src/components/QuranReader/SyntaxView/SyntaxBody.tsx @@ -130,14 +130,8 @@ const SyntaxBody: React.FC = (props) => {
Pattern -

- {analysis.pattern.wordPattern} - {' '} - — +

+ {analysis.pattern.wordPattern} — {analysis.pattern.patternType} diff --git a/src/components/QuranReader/SyntaxView/SyntaxSkeleton.tsx b/src/components/QuranReader/SyntaxView/SyntaxSkeleton.tsx index 8810be24c6..eac2a1416f 100644 --- a/src/components/QuranReader/SyntaxView/SyntaxSkeleton.tsx +++ b/src/components/QuranReader/SyntaxView/SyntaxSkeleton.tsx @@ -1,11 +1,12 @@ import range from 'lodash/range'; -import Skeleton from '@/dls/Skeleton/Skeleton'; - import styles from './SyntaxSkeleton.module.scss'; +import Skeleton from '@/dls/Skeleton/Skeleton'; + /** * Loading placeholder for Syntax view content (Study Mode dynamic import). + * @returns {React.ReactElement} Skeleton lines for Syntax tab loading state. */ const SyntaxSkeleton = () => { return ( diff --git a/src/components/QuranReader/SyntaxView/SyntaxTabLayout.tsx b/src/components/QuranReader/SyntaxView/SyntaxTabLayout.tsx index e4765632c0..7aeb799709 100644 --- a/src/components/QuranReader/SyntaxView/SyntaxTabLayout.tsx +++ b/src/components/QuranReader/SyntaxView/SyntaxTabLayout.tsx @@ -1,10 +1,10 @@ import React, { useRef, useCallback, ReactNode } from 'react'; +import styles from './SyntaxTabLayout.module.scss'; + import { FontSizeType } from '@/components/QuranReader/ReadingView/StudyModeModal/FontSizeControl'; import StudyModeControlsHeader from '@/components/QuranReader/ReadingView/StudyModeModal/StudyModeControlsHeader'; -import styles from './SyntaxTabLayout.module.scss'; - interface SyntaxTabLayoutProps { selectionControl: ReactNode; body: ReactNode; @@ -13,6 +13,7 @@ interface SyntaxTabLayoutProps { /** * Layout for Syntax (grammar analytics) content in Study Mode — mirrors StudyModeTabLayout pattern. + * @returns {React.ReactElement} Syntax tab chrome with font controls and body slot. */ const SyntaxTabLayout: React.FC = ({ selectionControl, @@ -29,6 +30,10 @@ const SyntaxTabLayout: React.FC = ({ export default SyntaxTabLayout; +/** + * Scroll container ref + scroll-to-top for Syntax tab content. + * @returns {{ containerRef: React.RefObject, scrollToTop: () => void }} + */ export const useSyntaxTabScroll = () => { const containerRef = useRef(null); diff --git a/src/lib/syntaxAnalysisQuranMcp.ts b/src/lib/syntaxAnalysisQuranMcp.ts index c1747b7ac2..8ab8380ba2 100644 --- a/src/lib/syntaxAnalysisQuranMcp.ts +++ b/src/lib/syntaxAnalysisQuranMcp.ts @@ -2,8 +2,8 @@ import { Client } from '@modelcontextprotocol/sdk/client'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp'; import { applyOptionalChartsToResult } from '@/lib/syntaxAnalysisCharts'; -import { buildOptionalChartsFromMcp } from '@/lib/syntaxChartsFromMcp'; import { runMcpSyntaxStudyOnClient } from '@/lib/syntaxAnalysisQuranMcpMorphology'; +import { buildOptionalChartsFromMcp } from '@/lib/syntaxChartsFromMcp'; import type { SyntaxAnalysisResult } from 'types/SyntaxAnalysis'; const DEFAULT_QURAN_MCP_URL = 'https://mcp.quran.ai/'; @@ -33,9 +33,8 @@ export async function fetchSyntaxAnalysisViaQuranMcp( try { await client.connect(transport); const bundle = await runMcpSyntaxStudyOnClient(client, options.textUthmani, options.verseKey); - return bundle.base; - //const rawCharts = buildOptionalChartsFromMcp(bundle.pickedWord, bundle.paradigm); - //return applyOptionalChartsToResult(bundle.base, rawCharts); + const rawCharts = buildOptionalChartsFromMcp(bundle.pickedWord, bundle.paradigm); + return applyOptionalChartsToResult(bundle.base, rawCharts); } finally { await client.close().catch(() => undefined); } diff --git a/src/lib/syntaxChartsFromMcp.ts b/src/lib/syntaxChartsFromMcp.ts index 7da2f9b104..c3b6d2e921 100644 --- a/src/lib/syntaxChartsFromMcp.ts +++ b/src/lib/syntaxChartsFromMcp.ts @@ -1,8 +1,8 @@ +/* eslint-disable max-lines -- paradigm stem mapping is linear but verbose */ +/* eslint-disable max-lines-per-function, react-func/max-lines-per-function */ +/* eslint-disable no-restricted-syntax, no-continue, import/prefer-default-export */ import type { MorphologyWord } from '@/lib/syntaxAnalysisQuranMcpMorphology'; -import type { - SyntaxAnalysisVerbChart, - SyntaxAnalysisVerbSlot, -} from 'types/SyntaxAnalysis'; +import type { SyntaxAnalysisVerbChart, SyntaxAnalysisVerbSlot } from 'types/SyntaxAnalysis'; type ParadigmStem = { stem: string; description: string }; @@ -124,7 +124,10 @@ function buildVerbChartFromStems(stems: ParadigmStem[]): SyntaxAnalysisVerbChart return next; }; - const pick = (chartKey: keyof SyntaxAnalysisVerbChart, number: 'singular' | 'dual' | 'plural') => { + const pick = ( + chartKey: keyof SyntaxAnalysisVerbChart, + number: 'singular' | 'dual' | 'plural', + ) => { const mapKey = `${chartKey}:${number}`; const hit = byCoord.get(mapKey); if (hit) { @@ -167,6 +170,7 @@ function buildVerbChartFromStems(stems: ParadigmStem[]): SyntaxAnalysisVerbChart /** * Builds optional chart payloads (same JSON keys as the former OpenAI chart pass) * from Quran MCP morphology + `fetch_word_paradigm` stems. + * @returns {Record} Partial chart fields for `applyOptionalChartsToResult`. */ export function buildOptionalChartsFromMcp( pickedWord: MorphologyWord, @@ -177,7 +181,10 @@ export function buildOptionalChartsFromMcp( const surface = str(pickedWord, 'text_uthmani')?.trim() || ''; const translation = str(pickedWord, 'translation')?.trim() || ''; - if (payload && (payload.perfect.length || payload.imperfect.length || payload.imperative.length)) { + if ( + payload && + (payload.perfect.length || payload.imperfect.length || payload.imperative.length) + ) { const lemmaOrRoot = payload.lemma || payload.root || surface || '—'; const glossBit = payload.gloss ? ` (${payload.gloss})` : ''; const ideaCell = payload.lemma ? `${payload.lemma}${glossBit}` : lemmaOrRoot; diff --git a/src/pages/api/syntax/analyze.ts b/src/pages/api/syntax/analyze.ts index de96a0d977..4fe5362627 100644 --- a/src/pages/api/syntax/analyze.ts +++ b/src/pages/api/syntax/analyze.ts @@ -10,6 +10,7 @@ const MAX_WORD_LENGTH = 200; /** * POST `/api/syntax/analyze` — morphology + optional sarf/verb charts via * [Quran MCP](https://mcp.quran.ai/documentation) (Streamable HTTP). + * @returns {Promise} JSON body: `SyntaxAnalysisResult` or `{ error }`. */ export default async function handler( req: NextApiRequest, diff --git a/src/services/syntaxAnalysisService.ts b/src/services/syntaxAnalysisService.ts index 397413d97d..21ec52121b 100644 --- a/src/services/syntaxAnalysisService.ts +++ b/src/services/syntaxAnalysisService.ts @@ -1,6 +1,5 @@ -import type { SyntaxAnalysisResult } from 'types/SyntaxAnalysis'; - import { SYNTAX_ANALYSIS_MOCK_RESPONSE } from '@/services/syntaxAnalysis.mock'; +import type { SyntaxAnalysisResult } from 'types/SyntaxAnalysis'; export type SyntaxAnalysisRequest = { /** Preferred: Uthmani text from the selected `Word` */ @@ -12,22 +11,25 @@ export type SyntaxAnalysisErrorBody = { error: string; }; -/** When true, `fetchSyntaxAnalysis` returns pasted mock data (see `syntaxAnalysis.mock.ts`). */ +/** + * When true, `fetchSyntaxAnalysis` returns pasted mock data (see `syntaxAnalysis.mock.ts`). + * @returns {boolean} Whether client-side mock mode is enabled. + */ export function isSyntaxAnalysisMockMode(): boolean { return process.env.NEXT_PUBLIC_SYNTAX_ANALYSIS_MOCK === 'true'; } /** - * Calls the Next.js API route that proxies to an LLM (OpenAI when `OPENAI_API_KEY` is set), - * unless mock mode is on — then returns `SYNTAX_ANALYSIS_MOCK_RESPONSE` with no token. + * Calls the Next.js API route `/api/syntax/analyze` (Quran MCP on the server), + * unless mock mode is on — then returns `SYNTAX_ANALYSIS_MOCK_RESPONSE` with no network call. * * Must run in the browser or any environment where `/api/syntax/analyze` is reachable (real mode only). + * @returns {Promise} Morphology result and optional charts. */ export async function fetchSyntaxAnalysis( payload: SyntaxAnalysisRequest, ): Promise { if (isSyntaxAnalysisMockMode()) { - void payload; await new Promise((r) => { setTimeout(r, 200); }); @@ -36,16 +38,17 @@ export async function fetchSyntaxAnalysis( const res = await fetch('/api/syntax/analyze', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name + 'Content-Type': 'application/json', + }, body: JSON.stringify(payload), }); const body = (await res.json()) as SyntaxAnalysisResult | SyntaxAnalysisErrorBody; if (!res.ok || 'error' in body) { - throw new Error( - 'error' in body ? body.error : `Syntax analysis failed (${res.status})`, - ); + throw new Error('error' in body ? body.error : `Syntax analysis failed (${res.status})`); } return body; @@ -54,6 +57,7 @@ export async function fetchSyntaxAnalysis( /** * Resolves display text for syntax analysis from a Word-like object. * `WordVerse` does not carry `textUthmani`; use fields on `Word` instead. + * @returns {string} Uthmani (or fallback) surface form for the token. */ export function getWordTextUthmaniForSyntax(word: { textUthmani?: string; diff --git a/types/SyntaxAnalysis.ts b/types/SyntaxAnalysis.ts index 307634e601..90186e8015 100644 --- a/types/SyntaxAnalysis.ts +++ b/types/SyntaxAnalysis.ts @@ -67,11 +67,7 @@ export type SyntaxAnalysisVerbChart = { }; /** Four Sarf columns — DOM order with `dir="rtl"` on the table is Past → Present → Idea → Doer (reading RTL). */ -export type SyntaxAnalysisSarfColumnKey = - | 'pastTense' - | 'presentTense' - | 'idea' - | 'doer'; +export type SyntaxAnalysisSarfColumnKey = 'pastTense' | 'presentTense' | 'idea' | 'doer'; /** * Verb-derived morphology chart (مصدر، اسم فاعل، صيغ أمر/نهي، مجهول، ظرف، إلخ). From b6f33b422843e6cf9d75510ebef6888134d9e46a Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Mon, 18 May 2026 22:37:10 -0500 Subject: [PATCH 13/22] Updated netlify to fix folder issue --- netlify.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netlify.toml b/netlify.toml index 49a22a755f..7bb5fffde6 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,6 +1,10 @@ # Next.js on Netlify — https://docs.netlify.com/integrations/frameworks/next-js/ +# +# Publish must NOT match the site base directory (Netlify rejects publish === base). +# Clear "Publish directory" in the Netlify UI if it is set to "." or the repo root. [build] command = "yarn build" + publish = ".next" [build.environment] NODE_VERSION = "18" From 662fa437e31ebe71d348acc70f0316344705b36d Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Tue, 19 May 2026 09:19:54 -0500 Subject: [PATCH 14/22] Added environment variable in netlify --- netlify.toml | 25 ++++++++++++++++++++++--- src/utils/url.ts | 26 ++++++++++++++++++++++---- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/netlify.toml b/netlify.toml index 7bb5fffde6..5d14735ed4 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,13 +1,32 @@ # Next.js on Netlify — https://docs.netlify.com/integrations/frameworks/next-js/ # -# Publish must NOT match the site base directory (Netlify rejects publish === base). -# Clear "Publish directory" in the Netlify UI if it is set to "." or the repo root. +# Do NOT set "Publish directory" in the Netlify UI to .next, public, dist, or build. +# @netlify/plugin-nextjs sets the correct deploy output automatically. [build] command = "yarn build" - publish = ".next" [build.environment] NODE_VERSION = "18" + NEXT_PUBLIC_VERCEL_ENV = "production" + # Required in Netlify UI (Site settings → Environment variables): + # NEXT_PUBLIC_VERCEL_URL = your-site-name.netlify.app (hostname only, no https://) + # ALLOWED_ORIGINS = quran.com,test.quran.com,your-site-name.netlify.app + # Copy other vars from .env.example / .env.local as needed (API_GATEWAY_URL, tokens, etc.) + NEXT_PUBLIC_SERVER_SENTRY_ENABLED=false + NEXT_PUBLIC_CLIENT_SENTRY_ENABLED=true + NODE_TLS_REJECT_UNAUTHORIZED=0 #set this only when SSL is self signed + SIGNATURE_TOKEN=1234 + INTERNAL_CLIENT_ID=QDC_WEB + ALLOWED_ORIGINS=quran.com,test.quran.com + PROXY_SIGNATURE_TOKEN=123456 + NEXT_PUBLIC_AUTH_PROFILE_TIMEOUT_MS=4000 # timeout (ms) before forcing incomplete profile redirect + NEXT_PUBLIC_QURAN_REFLECT_URL=https://quranreflect.com + NEXT_PUBLIC_SSO_ENABLED=false + NEXT_PUBLIC_EMBED_URL=https://quran.com/embed/v1 # Embed Ayah + API_GATEWAY_URL=https://api.quran.com + SYNTAX_ANALYSIS_PROVIDER=quran_mcp + QURAN_SYNTAX_MCP_URL=https://mcp.quran.ai/ + NEXT_PUBLIC_SYNTAX_ANALYSIS_MOCK=false [[plugins]] package = "@netlify/plugin-nextjs" diff --git a/src/utils/url.ts b/src/utils/url.ts index 0af4b9a0f8..637fe6d9ff 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -55,6 +55,22 @@ export const navigateToExternalUrl = (url: string) => { } }; +function resolveDeployHost(): string { + const fromEnv = process.env.NEXT_PUBLIC_VERCEL_URL?.trim(); + if (fromEnv) { + return fromEnv.replace(/^https?:\/\//, '').replace(/\/$/, ''); + } + const netlifyUrl = process.env.URL || process.env.DEPLOY_PRIME_URL; + if (netlifyUrl) { + try { + return new URL(netlifyUrl).host; + } catch { + // fall through + } + } + return process.env.NEXT_PUBLIC_VERCEL_ENV === 'development' ? 'localhost:3000' : 'quran.com'; +} + /** * Get the base path of the current deployment on Vercel/local machine * e.g. http://localhost @@ -62,13 +78,15 @@ export const navigateToExternalUrl = (url: string) => { * if we want to construct a full path e.g. when we add alternate languages * meta tags. * + * On Netlify, falls back to `URL` / `DEPLOY_PRIME_URL` when `NEXT_PUBLIC_VERCEL_URL` is unset. + * * @see https://vercel.com/docs/concepts/projects/environment-variables * @returns {string} */ -export const getBasePath = (): string => - `${process.env.NEXT_PUBLIC_VERCEL_ENV === 'development' ? 'http' : 'https'}://${ - process.env.NEXT_PUBLIC_VERCEL_URL - }`; +export const getBasePath = (): string => { + const isDev = process.env.NEXT_PUBLIC_VERCEL_ENV === 'development'; + return `${isDev ? 'http' : 'https'}://${resolveDeployHost()}`; +}; export const getProxiedServiceUrl = (service: QuranFoundationService, path: string): string => { if (service === QuranFoundationService.CONTENT) { From bb12c32dcd0d0a6db2bb3fefb1dd1fd4d983ce86 Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Tue, 19 May 2026 09:25:12 -0500 Subject: [PATCH 15/22] Added environment variable in netlify --- .env.example | 3 +++ netlify.toml | 5 ++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index a69f3cfb55..a02e534a7e 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ NEXT_PUBLIC_VERCEL_ENV=development NEXT_PUBLIC_VERCEL_URL=localhost:3000 +# Netlify deploy: set NEXT_PUBLIC_VERCEL_ENV=production and +# NEXT_PUBLIC_VERCEL_URL=your-site.netlify.app (hostname only). +# Add your Netlify hostname to ALLOWED_ORIGINS for /api/proxy to work in the browser. SENTRY_AUTH_TOKEN= LOKALISE_API_KEY= LOKALISE_PROJECT_ID= diff --git a/netlify.toml b/netlify.toml index 5d14735ed4..ca3a13af9d 100644 --- a/netlify.toml +++ b/netlify.toml @@ -9,15 +9,14 @@ NODE_VERSION = "18" NEXT_PUBLIC_VERCEL_ENV = "production" # Required in Netlify UI (Site settings → Environment variables): - # NEXT_PUBLIC_VERCEL_URL = your-site-name.netlify.app (hostname only, no https://) - # ALLOWED_ORIGINS = quran.com,test.quran.com,your-site-name.netlify.app + NEXT_PUBLIC_VERCEL_URL = chimerical-cajeta-2e6cb3.netlify.app + ALLOWED_ORIGINS = quran.com,test.quran.com,chimerical-cajeta-2e6cb3.netlify.app/ # Copy other vars from .env.example / .env.local as needed (API_GATEWAY_URL, tokens, etc.) NEXT_PUBLIC_SERVER_SENTRY_ENABLED=false NEXT_PUBLIC_CLIENT_SENTRY_ENABLED=true NODE_TLS_REJECT_UNAUTHORIZED=0 #set this only when SSL is self signed SIGNATURE_TOKEN=1234 INTERNAL_CLIENT_ID=QDC_WEB - ALLOWED_ORIGINS=quran.com,test.quran.com PROXY_SIGNATURE_TOKEN=123456 NEXT_PUBLIC_AUTH_PROFILE_TIMEOUT_MS=4000 # timeout (ms) before forcing incomplete profile redirect NEXT_PUBLIC_QURAN_REFLECT_URL=https://quranreflect.com From 854f7435b6a480365ca140b8a0f92b667e8b80ef Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Tue, 19 May 2026 09:31:40 -0500 Subject: [PATCH 16/22] Added environment variable in netlify --- netlify.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netlify.toml b/netlify.toml index ca3a13af9d..d295367480 100644 --- a/netlify.toml +++ b/netlify.toml @@ -9,8 +9,8 @@ NODE_VERSION = "18" NEXT_PUBLIC_VERCEL_ENV = "production" # Required in Netlify UI (Site settings → Environment variables): - NEXT_PUBLIC_VERCEL_URL = chimerical-cajeta-2e6cb3.netlify.app - ALLOWED_ORIGINS = quran.com,test.quran.com,chimerical-cajeta-2e6cb3.netlify.app/ + NEXT_PUBLIC_VERCEL_URL ="chimerical-cajeta-2e6cb3.netlify.app" + ALLOWED_ORIGINS = quran.com,test.quran.com,chimerical-cajeta-2e6cb3.netlify.app # Copy other vars from .env.example / .env.local as needed (API_GATEWAY_URL, tokens, etc.) NEXT_PUBLIC_SERVER_SENTRY_ENABLED=false NEXT_PUBLIC_CLIENT_SENTRY_ENABLED=true From 60883468526dbfd7051392c944a1797b8f70644d Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Tue, 19 May 2026 09:39:24 -0500 Subject: [PATCH 17/22] Added environment variable in netlify --- netlify.toml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/netlify.toml b/netlify.toml index d295367480..a35361ede2 100644 --- a/netlify.toml +++ b/netlify.toml @@ -10,21 +10,21 @@ NEXT_PUBLIC_VERCEL_ENV = "production" # Required in Netlify UI (Site settings → Environment variables): NEXT_PUBLIC_VERCEL_URL ="chimerical-cajeta-2e6cb3.netlify.app" - ALLOWED_ORIGINS = quran.com,test.quran.com,chimerical-cajeta-2e6cb3.netlify.app + ALLOWED_ORIGINS ="quran.com,test.quran.com,chimerical-cajeta-2e6cb3.netlify.app" # Copy other vars from .env.example / .env.local as needed (API_GATEWAY_URL, tokens, etc.) NEXT_PUBLIC_SERVER_SENTRY_ENABLED=false NEXT_PUBLIC_CLIENT_SENTRY_ENABLED=true NODE_TLS_REJECT_UNAUTHORIZED=0 #set this only when SSL is self signed SIGNATURE_TOKEN=1234 - INTERNAL_CLIENT_ID=QDC_WEB + INTERNAL_CLIENT_ID="QDC_WEB" PROXY_SIGNATURE_TOKEN=123456 NEXT_PUBLIC_AUTH_PROFILE_TIMEOUT_MS=4000 # timeout (ms) before forcing incomplete profile redirect - NEXT_PUBLIC_QURAN_REFLECT_URL=https://quranreflect.com + NEXT_PUBLIC_QURAN_REFLECT_URL="https://quranreflect.com" NEXT_PUBLIC_SSO_ENABLED=false - NEXT_PUBLIC_EMBED_URL=https://quran.com/embed/v1 # Embed Ayah - API_GATEWAY_URL=https://api.quran.com - SYNTAX_ANALYSIS_PROVIDER=quran_mcp - QURAN_SYNTAX_MCP_URL=https://mcp.quran.ai/ + NEXT_PUBLIC_EMBED_URL="https://quran.com/embed/v1" # Embed Ayah + API_GATEWAY_URL="https://api.quran.com" + SYNTAX_ANALYSIS_PROVIDER="quran_mcp" + QURAN_SYNTAX_MCP_URL="https://mcp.quran.ai/" NEXT_PUBLIC_SYNTAX_ANALYSIS_MOCK=false [[plugins]] From 5b02bb0243e779136f263c54585829fce39fc38c Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Tue, 19 May 2026 09:47:16 -0500 Subject: [PATCH 18/22] Added environment variable in netlify --- netlify.toml | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/netlify.toml b/netlify.toml index a35361ede2..584317c2c3 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,31 +1,31 @@ # Next.js on Netlify — https://docs.netlify.com/integrations/frameworks/next-js/ # -# Do NOT set "Publish directory" in the Netlify UI to .next, public, dist, or build. -# @netlify/plugin-nextjs sets the correct deploy output automatically. +# Do NOT set "Publish directory" in the Netlify UI (.next, public, dist, build). +# @netlify/plugin-nextjs sets the deploy output automatically. +# +# All [build.environment] values MUST be quoted strings (Netlify rejects bare true/false/numbers). +# Prefer Site settings → Environment variables for secrets instead of committing them here. [build] command = "yarn build" [build.environment] NODE_VERSION = "18" NEXT_PUBLIC_VERCEL_ENV = "production" - # Required in Netlify UI (Site settings → Environment variables): - NEXT_PUBLIC_VERCEL_URL ="chimerical-cajeta-2e6cb3.netlify.app" - ALLOWED_ORIGINS ="quran.com,test.quran.com,chimerical-cajeta-2e6cb3.netlify.app" - # Copy other vars from .env.example / .env.local as needed (API_GATEWAY_URL, tokens, etc.) - NEXT_PUBLIC_SERVER_SENTRY_ENABLED=false - NEXT_PUBLIC_CLIENT_SENTRY_ENABLED=true - NODE_TLS_REJECT_UNAUTHORIZED=0 #set this only when SSL is self signed - SIGNATURE_TOKEN=1234 - INTERNAL_CLIENT_ID="QDC_WEB" - PROXY_SIGNATURE_TOKEN=123456 - NEXT_PUBLIC_AUTH_PROFILE_TIMEOUT_MS=4000 # timeout (ms) before forcing incomplete profile redirect - NEXT_PUBLIC_QURAN_REFLECT_URL="https://quranreflect.com" - NEXT_PUBLIC_SSO_ENABLED=false - NEXT_PUBLIC_EMBED_URL="https://quran.com/embed/v1" # Embed Ayah - API_GATEWAY_URL="https://api.quran.com" - SYNTAX_ANALYSIS_PROVIDER="quran_mcp" - QURAN_SYNTAX_MCP_URL="https://mcp.quran.ai/" - NEXT_PUBLIC_SYNTAX_ANALYSIS_MOCK=false + NEXT_PUBLIC_VERCEL_URL = "chimerical-cajeta-2e6cb3.netlify.app" + ALLOWED_ORIGINS = "quran.com,test.quran.com,chimerical-cajeta-2e6cb3.netlify.app" + NEXT_PUBLIC_SERVER_SENTRY_ENABLED = "false" + NEXT_PUBLIC_CLIENT_SENTRY_ENABLED = "true" + NODE_TLS_REJECT_UNAUTHORIZED = "0" + SIGNATURE_TOKEN = "1234" + INTERNAL_CLIENT_ID = "QDC_WEB" + PROXY_SIGNATURE_TOKEN = "123456" + NEXT_PUBLIC_AUTH_PROFILE_TIMEOUT_MS = "4000" + NEXT_PUBLIC_QURAN_REFLECT_URL = "https://quranreflect.com" + NEXT_PUBLIC_SSO_ENABLED = "false" + NEXT_PUBLIC_EMBED_URL = "https://quran.com/embed/v1" + API_GATEWAY_URL = "https://api.quran.com" + QURAN_SYNTAX_MCP_URL = "https://mcp.quran.ai/" + NEXT_PUBLIC_SYNTAX_ANALYSIS_MOCK = "false" [[plugins]] package = "@netlify/plugin-nextjs" From 67f45a8e23831b4f511654f4bc140de790407f12 Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Tue, 19 May 2026 16:53:00 -0500 Subject: [PATCH 19/22] Updated the nodejs version for vercel --- netlify.toml | 14 +++++++++----- package.json | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/netlify.toml b/netlify.toml index 584317c2c3..f3795a13a7 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,15 +1,19 @@ # Next.js on Netlify — https://docs.netlify.com/integrations/frameworks/next-js/ # -# Do NOT set "Publish directory" in the Netlify UI (.next, public, dist, build). -# @netlify/plugin-nextjs sets the deploy output automatically. +# Publish MUST differ from site base (Netlify rejects publish === base). +# @netlify/plugin-nextjs may adjust the publish path during build; `.next` satisfies validation. # -# All [build.environment] values MUST be quoted strings (Netlify rejects bare true/false/numbers). -# Prefer Site settings → Environment variables for secrets instead of committing them here. +# Netlify UI (Build & deploy → Build settings): +# Base directory: leave empty if this repo root IS the app (not a monorepo subfolder). +# Publish directory: leave EMPTY so this file wins — do not set "." or "/" in the UI. +# +# All [build.environment] values MUST be quoted strings. [build] command = "yarn build" + publish = ".next" [build.environment] - NODE_VERSION = "18" + NODE_VERSION = "24" NEXT_PUBLIC_VERCEL_ENV = "production" NEXT_PUBLIC_VERCEL_URL = "chimerical-cajeta-2e6cb3.netlify.app" ALLOWED_ORIGINS = "quran.com,test.quran.com,chimerical-cajeta-2e6cb3.netlify.app" diff --git a/package.json b/package.json index 7a4168bfa7..7bed4f2b64 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "main": "index.js", "license": "MIT", "engines": { - "node": "18.x" + "node": "24.x" }, "scripts": { "dev": "next dev", From 860a90537f906008913ffc6c50855609bdc7c184 Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Tue, 19 May 2026 17:02:09 -0500 Subject: [PATCH 20/22] Added vercel.json for vercel --- vercel.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 vercel.json diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000000..1f79b32d94 --- /dev/null +++ b/vercel.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "nextjs", + "buildCommand": "yarn build", + "installCommand": "yarn install" +} From fc2feb53ae97844d713ea2778158054da15790f6 Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Tue, 19 May 2026 17:39:39 -0500 Subject: [PATCH 21/22] Updated the 404 logic for vercel --- src/middleware.ts | 2 +- src/utils/url.ts | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/middleware.ts b/src/middleware.ts index cf5535c9f8..f505da369e 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; export default function middleware(req: NextRequest) { // If the request is for _next/data, return a 404 response // This forces a full page reload when a new deployment is made - if (process.env.NODE_ENV === 'production' && req.url.includes('_next/data')) { + if (process.env.NODE_ENV === 'development' && req.url.includes('_next/data')) { return new NextResponse(null, { status: 404 }); } diff --git a/src/utils/url.ts b/src/utils/url.ts index 637fe6d9ff..67eac1ebed 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -90,10 +90,7 @@ export const getBasePath = (): string => { export const getProxiedServiceUrl = (service: QuranFoundationService, path: string): string => { if (service === QuranFoundationService.CONTENT) { - const contentHost = - process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' - ? PRODUCTION_CONTENT_HOST - : STAGING_CONTENT_HOST; + const contentHost = STAGING_CONTENT_HOST; return `${contentHost}${path}`; } From f1c362a8b2cd6dca11ce0f9eba36d25b560e7f7c Mon Sep 17 00:00:00 2001 From: Shahid Raza Date: Tue, 19 May 2026 17:46:45 -0500 Subject: [PATCH 22/22] Fixed build issue for varcel --- src/utils/url.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/url.ts b/src/utils/url.ts index 67eac1ebed..48319a5257 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -10,7 +10,7 @@ export enum QuranFoundationService { } const STAGING_CONTENT_HOST = 'https://staging.quran.com'; -const PRODUCTION_CONTENT_HOST = 'https://api.qurancdn.com'; +// const PRODUCTION_CONTENT_HOST = 'https://api.qurancdn.com'; export const getCurrentPath = () => { if (typeof window !== 'undefined') {