Skip to content

Commit 5f2c308

Browse files
committed
feat(festival): support info markdown
1 parent e61ba3e commit 5f2c308

File tree

8 files changed

+1387
-12
lines changed

8 files changed

+1387
-12
lines changed

package-lock.json

Lines changed: 1163 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
"format": "prettier --write .",
1313
"format:check": "prettier --check .",
1414
"preview": "vite preview",
15+
"test": "vitest --config vite.config.test.ts",
16+
"test:ui": "vitest --ui --config vite.config.test.ts",
17+
"test:coverage": "vitest run --coverage --config vite.config.test.ts",
1518
"test:e2e": "playwright test",
1619
"test:e2e:ui": "playwright test --ui",
1720
"test:e2e:headed": "playwright test --headed",
@@ -59,15 +62,18 @@
5962
"@tanstack/react-query": "^5.56.2",
6063
"@tanstack/react-query-devtools": "^5.81.2",
6164
"@tanstack/react-query-persist-client": "^5.85.9",
65+
"@types/marked": "^5.0.2",
6266
"class-variance-authority": "^0.7.1",
6367
"clsx": "^2.1.1",
6468
"cmdk": "^1.0.0",
6569
"date-fns": "^4.1.0",
6670
"date-fns-tz": "^3.2.0",
6771
"embla-carousel-react": "^8.3.0",
72+
"franc": "^6.2.0",
6873
"idb": "^8.0.3",
6974
"input-otp": "^1.2.4",
7075
"lucide-react": "^0.462.0",
76+
"marked": "^16.3.0",
7177
"next-themes": "^0.3.0",
7278
"react": "^18.3.1",
7379
"react-day-picker": "^9.8.0",
@@ -94,9 +100,11 @@
94100
"@types/react": "^18.3.3",
95101
"@types/react-dom": "^18.3.0",
96102
"@vitejs/plugin-react-swc": "^3.5.0",
103+
"@vitest/ui": "^3.2.4",
97104
"autoprefixer": "^10.4.20",
98105
"globals": "^15.9.0",
99106
"husky": "^9.1.7",
107+
"jsdom": "^27.0.0",
100108
"lint-staged": "^16.1.4",
101109
"lovable-tagger": "^1.1.7",
102110
"oxlint": "^1.11.1",
@@ -106,7 +114,8 @@
106114
"tailwindcss": "^3.4.11",
107115
"tsx": "^4.20.3",
108116
"typescript": "^5.5.3",
109-
"vite": "^5.4.1"
117+
"vite": "^5.4.1",
118+
"vitest": "^3.2.4"
110119
},
111120
"lint-staged": {
112121
"*.{js,jsx,ts,tsx}": [
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, it, expect } from "vitest";
2+
import { detectTextAlignment, getTextAlignmentClasses } from "../textAlignment";
3+
4+
describe("textAlignment", () => {
5+
describe("detectTextAlignment", () => {
6+
it("should detect English as LTR", () => {
7+
const result = detectTextAlignment(
8+
"This is a test in English with multiple sentences to make it long enough for reliable detection.",
9+
);
10+
11+
expect(result.language).toBe("eng");
12+
expect(result.direction).toBe("ltr");
13+
expect(result.textAlign).toBe("left");
14+
});
15+
16+
it("should detect Arabic as RTL", () => {
17+
const result = detectTextAlignment(
18+
"هذا نص تجريبي باللغة العربية لاختبار اكتشاف اللغة والمحاذاة بشكل صحيح",
19+
);
20+
21+
expect(result.language).toBe("arb");
22+
expect(result.direction).toBe("rtl");
23+
expect(result.textAlign).toBe("right");
24+
});
25+
26+
it("should detect Hebrew as RTL", () => {
27+
const result = detectTextAlignment(
28+
"זהו טקסט לבדיקה בעברית כדי לבדוק זיהוי שפה ויישור בצורה נכונה",
29+
);
30+
31+
expect(result.language).toBe("heb");
32+
expect(result.direction).toBe("rtl");
33+
expect(result.textAlign).toBe("right");
34+
});
35+
36+
it("should handle short text with default values", () => {
37+
const result = detectTextAlignment("Hi");
38+
39+
expect(result.language).toBe("und");
40+
expect(result.direction).toBe("ltr");
41+
expect(result.textAlign).toBe("left");
42+
});
43+
44+
it("should handle empty text", () => {
45+
const result = detectTextAlignment("");
46+
47+
expect(result.language).toBe("und");
48+
expect(result.direction).toBe("ltr");
49+
expect(result.textAlign).toBe("left");
50+
});
51+
52+
it("should ignore markdown syntax when detecting language", () => {
53+
const result = detectTextAlignment(
54+
"# This is a **heading** in English with _emphasis_ and `code` blocks for testing language detection",
55+
);
56+
57+
expect(result.language).toBe("eng");
58+
expect(result.direction).toBe("ltr");
59+
expect(result.textAlign).toBe("left");
60+
});
61+
});
62+
63+
describe("getTextAlignmentClasses", () => {
64+
it("should return LTR classes for English text", () => {
65+
const classes = getTextAlignmentClasses(
66+
"This is English text that should be left-aligned with LTR direction.",
67+
);
68+
69+
expect(classes).toBe("ltr text-left");
70+
});
71+
72+
it("should return RTL classes for Arabic text", () => {
73+
const classes = getTextAlignmentClasses(
74+
"هذا نص عربي يجب أن يكون محاذي لليمين مع اتجاه من اليمين لليسار",
75+
);
76+
77+
expect(classes).toBe("rtl text-right");
78+
});
79+
80+
it("should return RTL classes for Hebrew text", () => {
81+
const classes = getTextAlignmentClasses(
82+
"זה טקסט עברי שצריך להיות מיושר לימין עם כיוון מימין לשמאל",
83+
);
84+
85+
expect(classes).toBe("rtl text-right");
86+
});
87+
88+
it("should return default LTR classes for short text", () => {
89+
const classes = getTextAlignmentClasses("Hi");
90+
91+
expect(classes).toBe("ltr text-left");
92+
});
93+
});
94+
});

src/lib/markdown.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { marked } from "marked";
2+
3+
// Configure marked for safe HTML output
4+
marked.setOptions({
5+
breaks: true,
6+
gfm: true,
7+
});
8+
9+
export function parseMarkdown(markdown: string): string {
10+
return marked.parse(markdown) as string;
11+
}

src/lib/textAlignment.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { franc } from "franc";
2+
3+
// Languages that use Right-to-Left (RTL) writing direction
4+
const RTL_LANGUAGES = new Set([
5+
"ara", // Arabic
6+
"arb", // Standard Arabic (franc uses this code)
7+
"heb", // Hebrew
8+
"per", // Persian/Farsi
9+
"fas", // Persian (alternative code)
10+
"pes", // Western Persian
11+
"prs", // Dari Persian
12+
"urd", // Urdu
13+
"pus", // Pashto
14+
"snd", // Sindhi
15+
"uig", // Uyghur
16+
"arc", // Aramaic
17+
"ckb", // Central Kurdish
18+
"div", // Divehi
19+
"kur", // Kurdish
20+
"mzn", // Mazandarani
21+
"syr", // Syriac
22+
]);
23+
24+
export interface TextAlignmentResult {
25+
language: string;
26+
direction: "ltr" | "rtl";
27+
textAlign: "left" | "right" | "center";
28+
}
29+
30+
/**
31+
* Detects the language of text and returns appropriate alignment settings
32+
*/
33+
export function detectTextAlignment(text: string): TextAlignmentResult {
34+
// Remove markdown syntax for better language detection
35+
const cleanText = text
36+
.replace(/[#*_`~[\]()]/g, " ") // Remove markdown symbols
37+
.replace(/\s+/g, " ") // Normalize whitespace
38+
.trim();
39+
40+
// Return default if text is too short for reliable detection
41+
if (cleanText.length < 10) {
42+
return {
43+
language: "und", // undefined language
44+
direction: "ltr",
45+
textAlign: "left",
46+
};
47+
}
48+
49+
// Detect language using franc
50+
const detectedLanguage = franc(cleanText);
51+
52+
// Determine if the language uses RTL writing direction
53+
const isRTL = RTL_LANGUAGES.has(detectedLanguage);
54+
55+
return {
56+
language: detectedLanguage,
57+
direction: isRTL ? "rtl" : "ltr",
58+
textAlign: isRTL ? "right" : "left",
59+
};
60+
}
61+
62+
/**
63+
* Get CSS classes for text alignment based on detected language
64+
*/
65+
export function getTextAlignmentClasses(text: string): string {
66+
const { direction, textAlign } = detectTextAlignment(text);
67+
68+
const directionClass = direction === "rtl" ? "rtl" : "ltr";
69+
const alignmentClass = textAlign === "right" ? "text-right" : "text-left";
70+
71+
return `${directionClass} ${alignmentClass}`;
72+
}

src/pages/EditionView/tabs/InfoTab/InfoText.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
1+
import { parseMarkdown } from "@/lib/markdown";
2+
import { getTextAlignmentClasses } from "@/lib/textAlignment";
3+
14
interface InfoTextProps {
25
infoText?: string;
36
}
47

58
export function InfoText({ infoText }: InfoTextProps) {
9+
const htmlContent = infoText ? parseMarkdown(infoText) : "";
10+
const alignmentClasses = infoText ? getTextAlignmentClasses(infoText) : "";
11+
612
return (
713
<div className="bg-white/5 rounded-lg p-6">
814
<div
9-
className="prose prose-invert max-w-none text-purple-100"
10-
dangerouslySetInnerHTML={{ __html: infoText || "" }}
15+
className={`prose prose-invert max-w-none text-purple-100 ${alignmentClasses}`}
16+
dangerouslySetInnerHTML={{ __html: htmlContent }}
1117
/>
1218
</div>
1319
);

src/pages/admin/festivals/info/FestivalFields/FestivalInfoField.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { Textarea } from "@/components/ui/textarea";
33
import { EditableField } from "./shared/EditableField";
44
import { EditContainer } from "./shared/EditContainer";
55
import { useFestivalInfoMutation } from "@/hooks/queries/festival-info/useFestivalInfoMutation";
6+
import { parseMarkdown } from "@/lib/markdown";
7+
import { getTextAlignmentClasses } from "@/lib/textAlignment";
68

79
interface FestivalInfoFieldProps {
810
festivalId: string;
@@ -31,8 +33,8 @@ export function FestivalInfoField({
3133
>
3234
{infoText ? (
3335
<div
34-
className="prose prose-sm max-w-none"
35-
dangerouslySetInnerHTML={{ __html: infoText }}
36+
className={`prose prose-sm max-w-none ${getTextAlignmentClasses(infoText)}`}
37+
dangerouslySetInnerHTML={{ __html: parseMarkdown(infoText) }}
3638
/>
3739
) : (
3840
<p className="text-muted-foreground italic">No description</p>
@@ -71,7 +73,7 @@ function InfoFieldForm({
7173
>
7274
<Textarea
7375
{...form.register("infoText")}
74-
placeholder="Enter festival information (HTML supported)"
76+
placeholder="Enter festival information (Markdown supported)"
7577
rows={8}
7678
onKeyDown={handleKeyDown}
7779
/>

vite.config.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { defineConfig } from "vitest/config";
2+
import react from "@vitejs/plugin-react-swc";
3+
import path from "path";
4+
5+
export default defineConfig({
6+
plugins: [react()],
7+
test: {
8+
globals: true,
9+
environment: "jsdom",
10+
exclude: [
11+
"**/node_modules/**",
12+
"**/dist/**",
13+
"**/cypress/**",
14+
"**/.{idea,git,cache,output,temp}/**",
15+
"**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*",
16+
"**/tests/e2e/**", // Exclude Playwright E2E tests
17+
],
18+
},
19+
resolve: {
20+
alias: {
21+
"@": path.resolve(__dirname, "./src"),
22+
},
23+
},
24+
});

0 commit comments

Comments
 (0)