diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx
index 0d21736..78263c3 100644
--- a/components/admin/guide-section-editor.tsx
+++ b/components/admin/guide-section-editor.tsx
@@ -57,8 +57,10 @@ import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation
import { DeleteConditionDialog } from "@/components/admin/delete-condition-dialog";
import { MediaUploader } from "@/components/admin/media-uploader";
import { ScrollableNav } from "@/components/public/scrollable-nav";
+import { LunarisText } from "@/components/public/lunaris-text";
import { TeamArtifactIcon } from "@/components/public/team-search";
import { SimpleTooltip } from "@/components/tooltip";
+import type { CatalogTextNode } from "@/lib/catalog/lunaris";
import {
Accordion,
AccordionContent,
@@ -299,6 +301,7 @@ type CatalogConstellation = {
level: number;
name: string;
description: string;
+ descriptionNodes?: CatalogTextNode[];
iconUrl: string | null;
};
type CatalogTalent = {
@@ -2474,6 +2477,7 @@ function ConstellationEditor({
name: `C${index + 1}`,
description:
savedRows.find((row) => row.level === index + 1)?.detail ?? "",
+ descriptionNodes: undefined,
iconUrl: null,
}));
return source.map((constellation) => {
@@ -2643,9 +2647,11 @@ function ConstellationEditor({
/>
) : null}
- C{row.level} · {row.name}
+ C{row.level} - {row.name}
- {row.description}
+
+
+
diff --git a/components/public/lunaris-text.tsx b/components/public/lunaris-text.tsx
new file mode 100644
index 0000000..7ef6f81
--- /dev/null
+++ b/components/public/lunaris-text.tsx
@@ -0,0 +1,34 @@
+import { Fragment, type ReactNode } from "react";
+
+import { TermDetail } from "@/components/public/term-detail";
+import type { CatalogTextNode } from "@/lib/catalog/lunaris";
+
+function renderNodes(nodes: CatalogTextNode[]): ReactNode {
+ return nodes.map((node, index) => {
+ if (node.type === "text") return {node.value};
+ const children = renderNodes(node.children);
+ if (node.type === "color") {
+ return {children};
+ }
+ if (!node.detail) return {children};
+ return (
+
+ );
+ });
+}
+
+export function LunarisText({
+ nodes,
+ fallback,
+}: {
+ nodes?: CatalogTextNode[];
+ fallback: string;
+}) {
+ return <>{nodes?.length ? renderNodes(nodes) : fallback}>;
+}
diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx
index 9567ce3..06921e6 100644
--- a/components/public/structured-guide.tsx
+++ b/components/public/structured-guide.tsx
@@ -34,6 +34,7 @@ import { WeaponDisplayMode, WeaponDisplayModeProvider } from "@/components/publi
import { ArtifactImageExport } from "@/components/public/artifact-image-export";
import { GuideExportBranding } from "@/components/public/guide-export-branding";
import { ConstellationImage } from "@/components/public/constellation-image";
+import { LunarisText } from "@/components/public/lunaris-text";
import { ConditionSwitcher } from "@/components/public/condition-switcher";
import { MaterialPoster } from "@/components/public/material-poster";
import { RichTextContent } from "@/components/rich-text-content";
@@ -1226,12 +1227,14 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
{metric(row.cumulativePercent)}
- C{row.level} · {row.name}
+ C{row.level} - {row.name}
- {row.detail}
+
+
+
))}
diff --git a/components/public/term-detail.tsx b/components/public/term-detail.tsx
index 66f5121..df8f687 100644
--- a/components/public/term-detail.tsx
+++ b/components/public/term-detail.tsx
@@ -1,7 +1,7 @@
"use client";
import { InfoIcon } from "lucide-react";
-import { useSyncExternalStore } from "react";
+import { useSyncExternalStore, type ReactNode } from "react";
import {
Drawer,
@@ -29,33 +29,37 @@ function getSnapshot() {
return window.matchMedia(finePointerQuery).matches;
}
-function Trigger({ label }: { label: string }) {
+function Trigger({ label, showIcon }: { label: ReactNode; showIcon: boolean }) {
return (
-
+
{label}
-
+ {showIcon ? : null}
);
}
export function TermDetail({
label,
+ title,
detail,
+ showIcon = true,
}: {
- label: string;
+ label: ReactNode;
+ title: string;
detail: string;
+ showIcon?: boolean;
}) {
const finePointer = useSyncExternalStore(subscribe, getSnapshot, () => false);
if (finePointer) {
return (
-
-
+
+
- {label}
- {detail}
+ {title}
+ {detail}
);
@@ -63,13 +67,13 @@ export function TermDetail({
return (
-
-
+
+
- {label}
- {detail}
+ {title}
+ {detail}
diff --git a/lib/catalog/lunaris-text.test.tsx b/lib/catalog/lunaris-text.test.tsx
new file mode 100644
index 0000000..287b04e
--- /dev/null
+++ b/lib/catalog/lunaris-text.test.tsx
@@ -0,0 +1,45 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+
+import { LunarisText } from "@/components/public/lunaris-text";
+import type { CatalogTextNode } from "@/lib/catalog/lunaris";
+
+describe("LunarisText", () => {
+ it("renders source colors and an accessible interactive term", () => {
+ const nodes: CatalogTextNode[] = [
+ {
+ type: "link",
+ target: "N11500003",
+ detail: {
+ name: "Marvelous Splendor",
+ description: "First line\nSecond line",
+ },
+ children: [{ type: "text", value: "Marvelous Splendor" }],
+ },
+ { type: "text", value: " deals " },
+ {
+ type: "color",
+ value: "#99FFFFFF",
+ children: [{ type: "text", value: "Cryo AoE DMG" }],
+ },
+ ];
+
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain("Marvelous Splendor");
+ expect(html).toContain("aria-label=\"เปิดรายละเอียด Marvelous Splendor\"");
+ expect(html).toContain("hover:bg-primary/15");
+ expect(html).toContain("hover:decoration-2 hover:decoration-primary");
+ expect(html).toContain("color:#99FFFFFF");
+ expect(html).toContain("Cryo AoE DMG");
+ expect(html).not.toContain("fallback");
+ });
+
+ it("uses plain text for catalogs without structured nodes", () => {
+ expect(renderToStaticMarkup(
+ ,
+ )).toContain("Legacy detail");
+ });
+});
diff --git a/lib/catalog/lunaris.test.ts b/lib/catalog/lunaris.test.ts
index b6c1a03..e040a7b 100644
--- a/lib/catalog/lunaris.test.ts
+++ b/lib/catalog/lunaris.test.ts
@@ -8,6 +8,7 @@ import {
characterDetailSchema,
cleanLunarisText,
constellationSnapshot,
+ parseLunarisText,
nameCardImageName,
splashImageName,
supportsWeaponType,
@@ -41,6 +42,58 @@ describe("Lunaris character data", () => {
expect(cleanLunarisText("First line\\n\\nSecond line")).toBe("First line\n\nSecond line");
});
+ it("parses Lunaris links and colors into safe display nodes", () => {
+ const links = new Map([["N11500003", {
+ name: "Marvelous Splendor",
+ description: "A special stack effect.",
+ }]]);
+
+ expect(parseLunarisText(
+ "Gain {LINK#N11500003}Marvelous Splendor{/LINK} and deal Cryo AoE DMG.",
+ links,
+ )).toEqual([
+ { type: "text", value: "Gain " },
+ {
+ type: "link",
+ target: "N11500003",
+ detail: { name: "Marvelous Splendor", description: "A special stack effect." },
+ children: [{ type: "text", value: "Marvelous Splendor" }],
+ },
+ { type: "text", value: " and deal " },
+ {
+ type: "color",
+ value: "#99FFFFFF",
+ children: [{ type: "text", value: "Cryo AoE DMG" }],
+ },
+ { type: "text", value: "." },
+ ]);
+ });
+
+ it("resolves named skill links and ignores malformed display tags", () => {
+ const skills = new Map([["Slow Dance", {
+ name: "Slow Dance",
+ description: "Skill detail",
+ }]]);
+
+ expect(parseLunarisText(
+ "{LINK#S11502}Slow Dance{/LINK}\nSafe",
+ new Map(),
+ skills,
+ )).toEqual([
+ {
+ type: "link",
+ target: "S11502",
+ detail: { name: "Slow Dance", description: "Skill detail" },
+ children: [{
+ type: "color",
+ value: "#FFD780FF",
+ children: [{ type: "text", value: "Slow Dance" }],
+ }],
+ },
+ { type: "text", value: "\nSafe" },
+ ]);
+ });
+
it("normalizes constellation levels and icon keys", () => {
const result = constellationSnapshot({
constellations: {
@@ -51,6 +104,7 @@ describe("Lunaris character data", () => {
level: 1,
name: "First",
description: "Detail",
+ descriptionNodes: [{ type: "text", value: "Detail" }],
iconKey: "catalog/1/constellations/UI_Talent_Test_01.webp",
}]);
});
diff --git a/lib/catalog/lunaris.ts b/lib/catalog/lunaris.ts
index ed39746..be2eaa8 100644
--- a/lib/catalog/lunaris.ts
+++ b/lib/catalog/lunaris.ts
@@ -4,9 +4,25 @@ export interface CatalogConstellation {
level: number;
name: string;
description: string;
+ descriptionNodes?: CatalogTextNode[];
iconKey: string | null;
}
+export interface CatalogTextDetail {
+ name: string;
+ description: string;
+}
+
+export type CatalogTextNode =
+ | { type: "text"; value: string }
+ | { type: "color"; value: string; children: CatalogTextNode[] }
+ | {
+ type: "link";
+ target: string;
+ detail: CatalogTextDetail | null;
+ children: CatalogTextNode[];
+ };
+
export interface CatalogTalent {
type: "NA" | "E" | "Q";
name: string;
@@ -52,6 +68,12 @@ const skillSchema = z.object({
icon: z.string(),
});
+const hyperlinkSchema = z.object({
+ id: z.number().int(),
+ name: z.string(),
+ description: z.string(),
+});
+
const attributeSchema = z.record(z.string(), z.union([z.number(), z.string()]));
const levelingSchema = z.record(z.string(), z.record(z.string(), z.number().int()));
@@ -62,6 +84,7 @@ export const characterDetailSchema = z.object({
attributes: z.array(attributeSchema).nullish(),
}).nullish(),
constellations: z.record(z.string(), constellationSchema).nullish(),
+ hyperlinks: z.array(hyperlinkSchema).nullish(),
skills: z.object({
normalattack: skillSchema.optional(),
elementalskill: skillSchema.optional(),
@@ -85,6 +108,79 @@ export function cleanLunarisText(value: string): string {
.trim();
}
+const lunarisTagPattern = /(\{LINK#[^}]+\}|\{\/LINK\}||<\/color>|<[^>]+>)/giu;
+
+function textFromNodes(nodes: CatalogTextNode[]): string {
+ return nodes.map((node) =>
+ node.type === "text" ? node.value : textFromNodes(node.children)
+ ).join("");
+}
+
+export function parseLunarisText(
+ value: string,
+ linkDetails: ReadonlyMap = new Map(),
+ namedDetails: ReadonlyMap = new Map(),
+): CatalogTextNode[] {
+ const appendText = (children: CatalogTextNode[], text: string) => {
+ const previous = children.at(-1);
+ if (previous?.type === "text") previous.value += text;
+ else children.push({ type: "text", value: text });
+ };
+ const root: CatalogTextNode[] = [];
+ const stack: Array<{
+ type: "root" | "color" | "link";
+ children: CatalogTextNode[];
+ node?: Extract;
+ }> = [{ type: "root", children: root }];
+ const source = value
+ .replace(/\\n/gu, "\n")
+ .replace(/^"|"$/gu, "");
+ let offset = 0;
+
+ for (const match of source.matchAll(lunarisTagPattern)) {
+ const index = match.index;
+ const current = stack.at(-1)!;
+ if (index > offset) {
+ appendText(current.children, source.slice(offset, index));
+ }
+
+ const tag = match[0];
+ const link = /^\{LINK#([^}]+)\}$/iu.exec(tag);
+ const color = /^$/iu.exec(tag);
+ if (link) {
+ const node: Extract = {
+ type: "link",
+ target: link[1],
+ detail: linkDetails.get(link[1]) ?? null,
+ children: [],
+ };
+ current.children.push(node);
+ stack.push({ type: "link", children: node.children, node });
+ } else if (color) {
+ const node: Extract = {
+ type: "color",
+ value: color[1],
+ children: [],
+ };
+ current.children.push(node);
+ stack.push({ type: "color", children: node.children, node });
+ } else if (tag.toUpperCase() === "{/LINK}" && stack.at(-1)?.type === "link") {
+ const frame = stack.pop();
+ if (frame?.node?.type === "link" && !frame.node.detail) {
+ frame.node.detail = namedDetails.get(textFromNodes(frame.children).trim()) ?? null;
+ }
+ } else if (tag.toLowerCase() === "" && stack.at(-1)?.type === "color") {
+ stack.pop();
+ }
+ offset = index + tag.length;
+ }
+
+ if (offset < source.length) {
+ appendText(stack.at(-1)!.children, source.slice(offset));
+ }
+ return root;
+}
+
export function avatarIconName(value: string): string {
return value.replace(/^UI_Gacha_AvatarIcon_/u, "UI_AvatarIcon_");
}
@@ -113,6 +209,28 @@ export function constellationSnapshot(
iconKey: (icon: string) => string,
): CatalogConstellation[] {
const result: CatalogConstellation[] = [];
+ const linkDetails = new Map(
+ (value.hyperlinks ?? []).map((item) => [
+ `N${item.id}`,
+ {
+ name: cleanLunarisText(item.name),
+ description: cleanLunarisText(item.description),
+ },
+ ]),
+ );
+ const namedDetails = new Map(
+ [
+ value.skills?.normalattack,
+ value.skills?.elementalskill,
+ value.skills?.elementalburst,
+ ].flatMap((skill) => skill ? [[
+ cleanLunarisText(skill.name),
+ {
+ name: cleanLunarisText(skill.name),
+ description: cleanLunarisText(skill.description),
+ },
+ ] as const] : []),
+ );
for (const index of Array.from({ length: 6 }, (_, itemIndex) => itemIndex)) {
const level = index + 1;
const item = value.constellations?.[`const${level}`];
@@ -121,6 +239,7 @@ export function constellationSnapshot(
level,
name: cleanLunarisText(item.name),
description: cleanLunarisText(item.description),
+ descriptionNodes: parseLunarisText(item.description, linkDetails, namedDetails),
iconKey: iconKey(item.icon),
});
}
diff --git a/lib/catalog/sync.ts b/lib/catalog/sync.ts
index 61fd784..72a8f3a 100644
--- a/lib/catalog/sync.ts
+++ b/lib/catalog/sync.ts
@@ -33,7 +33,8 @@ import { customTravelers, isUpstreamTraveler } from "@/lib/catalog/travelers";
import { withCatalogSyncLock } from "@/lib/catalog/sync-lock";
import { getRedisClient, redisEventPrefix } from "@/lib/redis/client";
import {
- compareLunarisVersions,
+ catalogFormatVersion,
+ catalogSnapshotIsCurrent,
lunarisVersionSchema,
} from "@/lib/catalog/version";
import { getMediaStorage } from "@/lib/media/storage";
@@ -53,7 +54,10 @@ const versionSchema = z.object({
version: lunarisVersionSchema,
timestamp: z.number().int().optional(),
});
-const currentVersionSchema = z.object({ version: lunarisVersionSchema });
+const currentVersionSchema = z.object({
+ version: lunarisVersionSchema,
+ formatVersion: z.number().int().optional().default(1),
+});
const characterSchema = z.record(
z.string(),
z.object({
@@ -636,7 +640,10 @@ async function syncCatalogVersion(version: string, control?: SyncControl): Promi
);
});
await control?.report("database", 1, 1, "บันทึก Catalog แล้ว");
- await storage.write("catalog/current.json", JSON.stringify({ version }), {
+ await storage.write("catalog/current.json", JSON.stringify({
+ version,
+ formatVersion: catalogFormatVersion,
+ }), {
type: "application/json",
acl: "public-read",
});
@@ -717,10 +724,14 @@ async function executeCatalogSyncJob(initial: CatalogSyncJob) {
}
export async function getCurrentCatalogVersion(): Promise {
+ return (await getCurrentCatalogState())?.version ?? null;
+}
+
+async function getCurrentCatalogState() {
const storage = await getMediaStorage();
const current = storage.file("catalog/current.json");
if (!(await current.exists())) return null;
- return currentVersionSchema.parse(await current.json()).version;
+ return currentVersionSchema.parse(await current.json());
}
async function hasCatalogRecords(): Promise {
@@ -743,10 +754,13 @@ export async function processCatalogSync(
);
return withCatalogSyncLock(async () => {
- const currentVersion = await getCurrentCatalogVersion();
+ const current = await getCurrentCatalogState();
if (
- currentVersion &&
- compareLunarisVersions(version, currentVersion) <= 0 &&
+ catalogSnapshotIsCurrent(
+ version,
+ current?.version ?? null,
+ current?.formatVersion ?? null,
+ ) &&
await hasCatalogRecords()
) {
return "unchanged";
diff --git a/lib/catalog/version.test.ts b/lib/catalog/version.test.ts
index cceee25..b18988a 100644
--- a/lib/catalog/version.test.ts
+++ b/lib/catalog/version.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
+ catalogFormatVersion,
+ catalogSnapshotIsCurrent,
compareLunarisVersions,
parseLunarisVersionChange,
} from "./version";
@@ -29,4 +31,10 @@ Odette`),
expect(compareLunarisVersions("7.0.54", "7.0.54.0")).toBe(0);
expect(compareLunarisVersions("7.0.53.2", "7.0.54")).toBe(-1);
});
+
+ it("refreshes a same-version catalog when its local format is outdated", () => {
+ expect(catalogSnapshotIsCurrent("7.0.54", "7.0.54", null)).toBe(false);
+ expect(catalogSnapshotIsCurrent("7.0.54", "7.0.54", catalogFormatVersion - 1)).toBe(false);
+ expect(catalogSnapshotIsCurrent("7.0.54", "7.0.54", catalogFormatVersion)).toBe(true);
+ });
});
diff --git a/lib/catalog/version.ts b/lib/catalog/version.ts
index 2cfb375..9d5af62 100644
--- a/lib/catalog/version.ts
+++ b/lib/catalog/version.ts
@@ -1,6 +1,7 @@
import * as z from "zod";
export const lunarisVersionSchema = z.string().regex(/^\d+(?:\.\d+)+$/u);
+export const catalogFormatVersion = 2;
export interface LunarisVersionChange {
previousVersion: string;
@@ -31,3 +32,15 @@ export function compareLunarisVersions(left: string, right: string): number {
}
return 0;
}
+
+export function catalogSnapshotIsCurrent(
+ requestedVersion: string,
+ currentVersion: string | null,
+ currentFormatVersion: number | null,
+): boolean {
+ return Boolean(
+ currentVersion &&
+ compareLunarisVersions(requestedVersion, currentVersion) <= 0 &&
+ currentFormatVersion === catalogFormatVersion
+ );
+}
diff --git a/lib/guides/queries.ts b/lib/guides/queries.ts
index b843c3a..3946315 100644
--- a/lib/guides/queries.ts
+++ b/lib/guides/queries.ts
@@ -472,6 +472,7 @@ async function getGuideContent(slug: string, publicOnly: boolean, selectedSectio
...recommendation,
name: catalog?.name ?? `C${recommendation.level}`,
detail: catalog?.description ?? recommendation.detail,
+ detailNodes: catalog?.descriptionNodes,
iconUrl: catalog?.iconKey ? publicMediaUrl(catalog.iconKey) : null,
};
}),