feat(guide) : improve extra cards and navigation
CI / Verify (push) Successful in 1m25s
CI / Build immutable images and deploy (push) Failing after 50s

This commit is contained in:
2026-09-04 22:40:27 +07:00 Unverified
parent 5f74ce1de6
commit ced9b41737
16 changed files with 3780 additions and 224 deletions
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { highlightTextSegments } from "@/lib/guides/highlights";
describe("Extra text highlights", () => {
it("colors every exact occurrence while preserving surrounding text", () => {
expect(highlightTextSegments("MASSIVE damage\nMASSIVE range", [
{ text: "MASSIVE", color: "#FF0000" },
])).toEqual([
{ text: "MASSIVE", color: "#FF0000" },
{ text: " damage\n", color: null },
{ text: "MASSIVE", color: "#FF0000" },
{ text: " range", color: null },
]);
});
it("prefers the longer phrase when highlights overlap", () => {
expect(highlightTextSegments("very high damage", [
{ text: "high", color: "#00FF00" },
{ text: "high damage", color: "#FF0000" },
])).toEqual([
{ text: "very ", color: null },
{ text: "high damage", color: "#FF0000" },
]);
});
});
+43
View File
@@ -0,0 +1,43 @@
export interface TextHighlight {
text: string;
color: string;
}
export interface HighlightedTextSegment {
text: string;
color: string | null;
}
export function highlightTextSegments(
value: string,
highlights: readonly TextHighlight[],
): HighlightedTextSegment[] {
const rules = highlights
.filter((highlight) => highlight.text.length > 0)
.toSorted((left, right) => right.text.length - left.text.length);
if (!rules.length) return [{ text: value, color: null }];
const segments: HighlightedTextSegment[] = [];
let cursor = 0;
while (cursor < value.length) {
let matchIndex = -1;
let match: TextHighlight | undefined;
for (const rule of rules) {
const index = value.indexOf(rule.text, cursor);
if (index !== -1 && (matchIndex === -1 || index < matchIndex)) {
matchIndex = index;
match = rule;
}
}
if (!match || matchIndex === -1) {
segments.push({ text: value.slice(cursor), color: null });
break;
}
if (matchIndex > cursor) {
segments.push({ text: value.slice(cursor, matchIndex), color: null });
}
segments.push({ text: match.text, color: match.color });
cursor = matchIndex + match.text.length;
}
return segments;
}
+8 -7
View File
@@ -139,23 +139,24 @@ describe("structured guide validation", () => {
expect(teamSectionSchema.safeParse(input).success).toBe(true);
});
it("accepts Extra card colors only as a valid pair", () => {
it("validates Extra card backgrounds and word highlights", () => {
const row = {
mediaId: null,
heading: "Extra card",
body: "First line\nSecond line",
percent: null,
};
const input = (colors: { backgroundColor: string | null; textColor: string | null }) => ({
const input = (style: { backgroundColor: string | null; highlights: Array<{ text: string; color: string }> }) => ({
expectedVersion: 1,
sections: [{ title: "Extra", rows: [{ ...row, ...colors }] }],
sections: [{ title: "Extra", rows: [{ ...row, ...style }] }],
});
expect(extraSectionSchema.safeParse(input({ backgroundColor: null, textColor: null })).success).toBe(true);
expect(extraSectionSchema.safeParse(input({ backgroundColor: "#24232C", textColor: "#F5F2EA" })).success).toBe(true);
expect(extraSectionSchema.safeParse(input({ backgroundColor: null, highlights: [] })).success).toBe(true);
expect(extraSectionSchema.safeParse(input({ backgroundColor: "#24232C", highlights: [{ text: "MASSIVE", color: "#FF0000" }] })).success).toBe(true);
expect(extraSectionSchema.safeParse({ expectedVersion: 1, sections: [{ title: "Extra", rows: [row] }] }).success).toBe(true);
expect(extraSectionSchema.safeParse(input({ backgroundColor: "#24232C", textColor: null })).success).toBe(false);
expect(extraSectionSchema.safeParse(input({ backgroundColor: "red", textColor: "#F5F2EA" })).success).toBe(false);
expect(extraSectionSchema.safeParse(input({ backgroundColor: "red", highlights: [] })).success).toBe(false);
expect(extraSectionSchema.safeParse(input({ backgroundColor: null, highlights: [{ text: "MASSIVE", color: "red" }] })).success).toBe(false);
expect(extraSectionSchema.safeParse(input({ backgroundColor: null, highlights: [{ text: "MASSIVE", color: "#FF0000" }, { text: "MASSIVE", color: "#00FF00" }] })).success).toBe(false);
});
});
+8 -5
View File
@@ -139,11 +139,14 @@ const extraRowSchema = z.strictObject({
body: z.string().trim().max(2_000),
percent: percent.nullable(),
backgroundColor: hexColor.nullable().default(null),
textColor: hexColor.nullable().default(null),
}).refine(
(row) => (row.backgroundColor === null) === (row.textColor === null),
{ message: "ต้องเลือกสีพื้นหลังและสีข้อความพร้อมกัน", path: ["backgroundColor"] },
);
highlights: z.array(z.strictObject({
text: z.string().trim().min(1).max(80),
color: hexColor,
})).max(20).refine(
(highlights) => new Set(highlights.map((highlight) => highlight.text)).size === highlights.length,
"คำหรือวลีที่ไฮไลต์ต้องไม่ซ้ำกัน",
).default([]),
});
export const extraSectionSchema = z.strictObject({
expectedVersion: z.number().int().positive(),
+1
View File
@@ -116,6 +116,7 @@ export async function generateOgImage({
height: "100%",
objectFit: "cover",
objectPosition: "center",
maskImage: "linear-gradient(90deg, transparent 0%, black 28%, black 100%)",
}}
/>
) : null}