feat(menu): PHP 기준 공통 메뉴를 런타임 설정으로 이관
Gateway와 게임 공통 메뉴를 영속 JSON에서 읽고 다음 페이지 로드에 반영한다. 운영 PHP의 항목과 순서, desktop/mobile geometry 및 정보 동작을 보존하고 검증·복구 문서를 추가한다.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig';
|
||||
import { z } from 'zod';
|
||||
|
||||
const zId = z.string().min(1).max(80).regex(/^[a-z0-9][a-z0-9-]*$/u);
|
||||
const zLabel = z.string().min(1).max(80);
|
||||
const zInternalPath = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(500)
|
||||
.refine((value) => value.startsWith('/') && !value.startsWith('//'), '내부 경로는 /로 시작해야 합니다.');
|
||||
const zExternalHref = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(1000)
|
||||
.refine(
|
||||
(value) =>
|
||||
(value.startsWith('/') && !value.startsWith('///')) ||
|
||||
value.startsWith('https://') ||
|
||||
value.startsWith('http://'),
|
||||
'링크는 /, //, https:// 또는 http://로 시작해야 합니다.'
|
||||
);
|
||||
|
||||
const zNavigationLink = z
|
||||
.object({
|
||||
kind: z.literal('link'),
|
||||
id: zId,
|
||||
label: zLabel,
|
||||
to: zInternalPath.optional(),
|
||||
href: zExternalHref.optional(),
|
||||
action: z.literal('show-version').optional(),
|
||||
newTab: z.boolean().optional(),
|
||||
showWhen: z.enum(['always', 'npc-enabled']).optional(),
|
||||
highlightWhen: z.enum(['nation-betting', 'vote']).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, context) => {
|
||||
const destinations = [value.to, value.href, value.action].filter(Boolean);
|
||||
if (destinations.length !== 1) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: '메뉴 링크에는 to, href, action 중 하나만 필요합니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const zNavigationDivider = z.object({ kind: z.literal('divider'), id: zId }).strict();
|
||||
const zNavigationChild = z.union([zNavigationLink, zNavigationDivider]);
|
||||
const zNavigationEntry = z.union([
|
||||
zNavigationLink,
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('group'),
|
||||
id: zId,
|
||||
label: zLabel,
|
||||
items: z.array(zNavigationChild).min(1).max(30),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('split'),
|
||||
id: zId,
|
||||
main: zNavigationLink,
|
||||
items: z.array(zNavigationChild).min(1).max(30),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
|
||||
export const zRuntimeNavigationConfig: z.ZodType<RuntimeNavigationConfig> = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
gateway: z
|
||||
.object({
|
||||
brand: z.object({ label: zLabel, to: zInternalPath }).strict(),
|
||||
items: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: zId,
|
||||
label: zLabel,
|
||||
href: zExternalHref,
|
||||
newTab: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(30),
|
||||
})
|
||||
.strict(),
|
||||
game: z.object({ items: z.array(zNavigationEntry).min(1).max(20) }).strict(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export class RuntimeNavigationConfigStore {
|
||||
constructor(
|
||||
private readonly overridePath: string | null,
|
||||
private readonly defaultPath: string
|
||||
) {}
|
||||
|
||||
async get(): Promise<RuntimeNavigationConfig> {
|
||||
const configPath = await this.resolveConfigPath();
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(await fs.readFile(configPath, 'utf8')) as unknown;
|
||||
} catch (error) {
|
||||
throw new Error(`메뉴 설정 파일을 읽지 못했습니다: ${configPath}`, { cause: error });
|
||||
}
|
||||
const parsed = zRuntimeNavigationConfig.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`메뉴 설정 파일이 올바르지 않습니다: ${configPath}: ${parsed.error.message}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
private async resolveConfigPath(): Promise<string> {
|
||||
if (!this.overridePath) return this.defaultPath;
|
||||
try {
|
||||
await fs.access(this.overridePath);
|
||||
return this.overridePath;
|
||||
} catch (error) {
|
||||
const code = error instanceof Error && 'code' in error ? error.code : undefined;
|
||||
if (code === 'ENOENT') return this.defaultPath;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user