fix: 补 .gitattributes(CRLF for .bat) + README 端口 19540 + 提交 console 锁文件
This commit is contained in:
1 parent
c121111f51
commit
b80c64a115
39 files changed
+4217
-345
No files matched your search
@@ -5,8 +5,19 @@
|
||||
* 直接断言会把服务端字段改名这类问题推迟到运行期某个深处才炸。
|
||||
*/
|
||||
|
||||
/**
|
||||
* FormData / Blob(含 File)/ URLSearchParams 不是普通对象:
|
||||
* 展开或 JSON 序列化都会丢掉内容,必须原样交给 fetch(报名表导入就是被展平才丢文件)。
|
||||
*/
|
||||
export function isRawBody(value: unknown): value is FormData | Blob | URLSearchParams {
|
||||
if (typeof FormData !== 'undefined' && value instanceof FormData) return true
|
||||
if (typeof Blob !== 'undefined' && value instanceof Blob) return true
|
||||
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value) && !isRawBody(value)
|
||||
}
|
||||
|
||||
export function readString(source: unknown, key: string): string | null {
|
||||
@@ -14,3 +25,52 @@ export function readString(source: unknown, key: string): string | null {
|
||||
const value = source[key]
|
||||
return typeof value === 'string' ? value : null
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {}
|
||||
}
|
||||
|
||||
/** 依次取第一个可用值:容忍服务端把数字写成字符串(settings 表存的是文本)。 */
|
||||
export function firstString(...values: unknown[]): string | null {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string') return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function firstNumber(def: number, ...values: unknown[]): number {
|
||||
const found = firstNumberOrNull(...values)
|
||||
return found === null ? def : found
|
||||
}
|
||||
|
||||
export function firstNumberOrNull(...values: unknown[]): number | null {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) return parsed
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function firstBool(def: boolean, ...values: unknown[]): boolean {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'number') return value !== 0
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
|
||||
return true
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
export function emptyToNull(value: string | null): string | null {
|
||||
return value === null || value.trim() === '' ? null : value
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 服务端两类载荷的归一化。
|
||||
*
|
||||
* REST 与 WebSocket 快照对同一份数据用了两套字段名(快照直接序列化 domain 类型),
|
||||
* 归一化放在 shared 层,display 与 admin 各自只认归一后的契约类型。
|
||||
*/
|
||||
|
||||
import {
|
||||
asRecord,
|
||||
emptyToNull,
|
||||
firstBool,
|
||||
firstNumber,
|
||||
firstNumberOrNull,
|
||||
firstString,
|
||||
isRecord,
|
||||
} from './internal'
|
||||
import type { EventSettings, StandingRow } from './types'
|
||||
|
||||
/**
|
||||
* 赛事配置归一化。
|
||||
*
|
||||
* `GET /api/settings` 是嵌套对象,而 WebSocket 快照的 `settings` 是
|
||||
* settings 表的点号扁平 map(`display.rotate_seconds`、`standings.mode`…)。
|
||||
* 两种形状都接受,缺省值对齐服务端 `getSettings`,避免大屏首包读不到字段。
|
||||
*/
|
||||
export function normalizeSettings(raw: unknown): EventSettings {
|
||||
const source = asRecord(raw)
|
||||
const nestedEvent = isRecord(source.event) ? source.event : null
|
||||
const nestedDisplay = isRecord(source.display) ? source.display : null
|
||||
const nestedStandings = isRecord(source.standings) ? source.standings : null
|
||||
const isFlat = nestedEvent === null && nestedDisplay === null && nestedStandings === null
|
||||
|
||||
const name = firstString(isFlat ? source['event.name'] : undefined, nestedEvent?.name) ?? '未命名赛事'
|
||||
const subtitle = emptyToNull(
|
||||
firstString(isFlat ? source['event.subtitle'] : undefined, nestedEvent?.subtitle),
|
||||
)
|
||||
const host = emptyToNull(firstString(isFlat ? source['event.organizer'] : undefined, nestedEvent?.host))
|
||||
const logoPath = emptyToNull(
|
||||
firstString(isFlat ? source['event.logo_path'] : undefined, nestedEvent?.logoPath),
|
||||
)
|
||||
const primaryColor = emptyToNull(
|
||||
firstString(isFlat ? source['display.theme_color'] : undefined, nestedEvent?.primaryColor),
|
||||
)
|
||||
const displayLogo = emptyToNull(
|
||||
firstString(isFlat ? source['display.logo'] : undefined, nestedDisplay?.logo),
|
||||
)
|
||||
|
||||
return {
|
||||
event: { name, subtitle, host, logoPath, primaryColor },
|
||||
display: {
|
||||
background: emptyToNull(
|
||||
firstString(isFlat ? source['display.background'] : undefined, nestedDisplay?.background),
|
||||
),
|
||||
logo: displayLogo ?? logoPath,
|
||||
title: firstString(isFlat ? source['display.title'] : undefined, nestedDisplay?.title) ?? name,
|
||||
subtitle:
|
||||
emptyToNull(
|
||||
firstString(isFlat ? source['display.subtitle'] : undefined, nestedDisplay?.subtitle),
|
||||
) ?? subtitle,
|
||||
primaryColor,
|
||||
rotateSeconds: firstNumber(
|
||||
15,
|
||||
isFlat ? source['display.rotate_seconds'] : undefined,
|
||||
nestedDisplay?.rotateSeconds,
|
||||
),
|
||||
},
|
||||
standings: {
|
||||
individualToTeamWeight: firstNumber(
|
||||
1,
|
||||
isFlat ? source['standings.individual_to_team_weight'] : undefined,
|
||||
nestedStandings?.individualToTeamWeight,
|
||||
),
|
||||
rankingMode:
|
||||
firstString(isFlat ? source['standings.mode'] : undefined, nestedStandings?.rankingMode) ??
|
||||
'points',
|
||||
showIndividualBoard: firstBool(
|
||||
true,
|
||||
isFlat ? source['standings.show_individual_board'] : undefined,
|
||||
isFlat ? source['standings.show_individual_medals'] : undefined,
|
||||
nestedStandings?.showIndividualBoard,
|
||||
),
|
||||
},
|
||||
// 扁平 map 里只有模板编码(scoring.default_profile),解析不出数据库 id,只能置空。
|
||||
defaultProfileId: isFlat ? null : firstNumberOrNull(source.defaultProfileId),
|
||||
requireApproval: firstBool(
|
||||
false,
|
||||
isFlat ? source['results.review_required'] : undefined,
|
||||
source.requireApproval,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 总积分榜归一化。
|
||||
*
|
||||
* 快照用 domain.StandingsRow(`code/name/points/medals`),
|
||||
* REST 用 standingRowDTO(`teamCode/teamName/totalPoints/medalTotal`),两种都接受。
|
||||
*/
|
||||
export function normalizeStandingRows(raw: unknown): StandingRow[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map(normalizeStandingRow)
|
||||
}
|
||||
|
||||
function normalizeStandingRow(raw: unknown): StandingRow {
|
||||
const rec = asRecord(raw)
|
||||
const gold = firstNumber(0, rec.gold)
|
||||
const silver = firstNumber(0, rec.silver)
|
||||
const bronze = firstNumber(0, rec.bronze)
|
||||
const rankChange = rec.rankChange
|
||||
return {
|
||||
rank: firstNumber(0, rec.rank),
|
||||
teamId: firstNumber(0, rec.teamId),
|
||||
teamCode: firstString(rec.teamCode, rec.code) ?? '',
|
||||
teamName: firstString(rec.teamName, rec.name) ?? '',
|
||||
shortName: emptyToNull(firstString(rec.shortName)),
|
||||
color: emptyToNull(firstString(rec.color)),
|
||||
logoPath: emptyToNull(firstString(rec.logoPath)),
|
||||
gold,
|
||||
silver,
|
||||
bronze,
|
||||
medalTotal: firstNumber(gold + silver + bronze, rec.medalTotal, rec.medals),
|
||||
totalPoints: firstNumber(0, rec.totalPoints, rec.points),
|
||||
rankChange: typeof rankChange === 'number' && Number.isFinite(rankChange) ? rankChange : null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 登录态判定。
|
||||
*
|
||||
* 服务端 `/auth/me` 未登录时返回 200 + guest DTO(role = "guest"、username = "guest"),
|
||||
* 而不是 401,所以「user 是否为 null」判不出访客;统一走这里的判定函数。
|
||||
*/
|
||||
|
||||
import type { CurrentUser } from './types'
|
||||
|
||||
/** 服务端 auth.RoleGuest 的字符串值。 */
|
||||
export const GUEST_ROLE = 'guest'
|
||||
|
||||
export function isGuestRole(role: unknown): boolean {
|
||||
return typeof role === 'string' && role.trim().toLowerCase() === GUEST_ROLE
|
||||
}
|
||||
|
||||
/** null(还没探测 / 探测失败)与 guest DTO 都算访客。 */
|
||||
export function isGuestUser(user: CurrentUser | null | undefined): boolean {
|
||||
return user === null || user === undefined || isGuestRole(user.role)
|
||||
}
|
||||
+15
-3
@@ -383,6 +383,11 @@ export interface CurrentUser {
|
||||
username: string
|
||||
displayName: string
|
||||
role: string
|
||||
/**
|
||||
* 由客户端按 role 补出:true 表示这是未登录时 /auth/me 返回的 guest DTO。
|
||||
* 服务端未登录也回 200,不能用「对象是否为 null」判访客,见 shared/session.ts。
|
||||
*/
|
||||
guest?: boolean
|
||||
/** 首次登录或管理员重置密码后为 true,前端应强制引导改密 */
|
||||
mustChangePassword: boolean
|
||||
lastLoginAt: string | null
|
||||
@@ -415,7 +420,7 @@ export interface HealthInfo {
|
||||
// 规则包(见 02-积分与排名规则 第 7 节)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TieBreakKey = 'points' | 'gold' | 'silver' | 'bronze' | 'teamCode'
|
||||
export type TieBreakKey = 'points' | 'gold' | 'silver' | 'bronze' | 'teamCode' | 'name'
|
||||
|
||||
export interface RulePackMeta {
|
||||
code: string
|
||||
@@ -431,7 +436,8 @@ export interface RulePackStandings {
|
||||
tieBreak: TieBreakKey[]
|
||||
medalCountsTeamEvents: boolean
|
||||
showIndividualBoard: boolean
|
||||
showIndividualMedals: boolean
|
||||
/** 服务端规则包会带出该开关;缺省视为 true。 */
|
||||
showIndividualMedals?: boolean
|
||||
}
|
||||
|
||||
export interface RuleProfile {
|
||||
@@ -440,6 +446,8 @@ export interface RuleProfile {
|
||||
awardRanks: number
|
||||
lastPlaceZero: boolean
|
||||
tieMode: TieMode
|
||||
/** 单个模板可覆盖破纪录加分口径;服务端 Canonical 后总会带出。 */
|
||||
recordBonusMode?: RecordBonusMode
|
||||
rules: ScoringProfileRule[]
|
||||
}
|
||||
|
||||
@@ -465,11 +473,13 @@ export interface RuleChannel {
|
||||
scope: ChannelScope
|
||||
basis: ChannelBasis
|
||||
profile: string
|
||||
/** basis 为 aggregate 时的汇总口径(avg/max/min/count),服务端缺省 sum。 */
|
||||
aggregate?: string
|
||||
}
|
||||
|
||||
export interface ChannelTemplateMatch {
|
||||
eventType?: EventType
|
||||
eventCode?: string
|
||||
resultType?: ResultType
|
||||
sportCode?: string
|
||||
}
|
||||
|
||||
@@ -482,6 +492,8 @@ export interface EventOverride {
|
||||
eventCode: string
|
||||
name?: string
|
||||
scored?: boolean
|
||||
/** 单个项目的计分权重覆盖;缺省表示不动。 */
|
||||
multiplier?: number
|
||||
channels?: RuleChannel[]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* 极简 YAML 子集解析器。
|
||||
*
|
||||
* `GET /api/rules/export` 返回的是服务端 `rules.Marshal` 生成的 YAML 文本,
|
||||
* 而前端不引入 YAML 依赖,所以这里只实现规则包实际用到的语法:
|
||||
* 块状映射 / 序列、流式 `[]` / `{}`、单双引号、块标量、数字 / 布尔 / null 标量。
|
||||
* 解析失败一律抛 `YamlParseError`,由调用方转成 PARSE_FAILED 并带上原文。
|
||||
*/
|
||||
|
||||
export class YamlParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'YamlParseError'
|
||||
}
|
||||
}
|
||||
|
||||
interface Line {
|
||||
indent: number
|
||||
text: string
|
||||
}
|
||||
|
||||
interface Parsed {
|
||||
value: unknown
|
||||
next: number
|
||||
}
|
||||
|
||||
export function parseYaml(text: string): unknown {
|
||||
const lines = tokenize(text)
|
||||
if (lines.length === 0) return null
|
||||
const parsed = parseBlock(lines, 0, lines[0].indent)
|
||||
if (parsed.next < lines.length) {
|
||||
throw new YamlParseError(`第 ${parsed.next + 1} 行无法归入任何块:${lines[parsed.next].text}`)
|
||||
}
|
||||
return parsed.value
|
||||
}
|
||||
|
||||
function tokenize(text: string): Line[] {
|
||||
const out: Line[] = []
|
||||
const raw = text.split(/\r?\n/)
|
||||
for (const line of raw) {
|
||||
const withoutComment = stripComment(line)
|
||||
if (withoutComment.trim() === '') continue
|
||||
// 规则包只允许一个 YAML 文档,遇到分隔符直接跳过而不是当成新文档。
|
||||
if (/^\s*(---|\.\.\.)\s*$/.test(withoutComment)) continue
|
||||
out.push({
|
||||
indent: withoutComment.length - withoutComment.trimStart().length,
|
||||
text: withoutComment.trim(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function stripComment(line: string): string {
|
||||
let quote: '"' | "'" | null = null
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i]
|
||||
if (quote === '"') {
|
||||
if (ch === '\\') i++
|
||||
else if (ch === '"') quote = null
|
||||
continue
|
||||
}
|
||||
if (quote === "'") {
|
||||
if (ch === "'") {
|
||||
if (line[i + 1] === "'") i++
|
||||
else quote = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch
|
||||
continue
|
||||
}
|
||||
// YAML 注释必须前面是空白(`a#b` 里的 # 属于标量)。
|
||||
if (ch === '#' && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
function parseBlock(lines: Line[], index: number, indent: number): Parsed {
|
||||
const line = lines[index]
|
||||
if (line === undefined || line.indent < indent) return { value: null, next: index }
|
||||
if (isSequenceItem(line.text)) return parseSequence(lines, index, line.indent)
|
||||
return parseMapping(lines, index, line.indent)
|
||||
}
|
||||
|
||||
function parseMapping(lines: Line[], index: number, indent: number): Parsed {
|
||||
const out: Record<string, unknown> = {}
|
||||
let i = index
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
if (line.indent !== indent || isSequenceItem(line.text)) break
|
||||
const entry = splitEntry(line.text)
|
||||
if (entry === null) throw new YamlParseError(`第 ${i + 1} 行不是「键: 值」:${line.text}`)
|
||||
if (entry.rest === '') {
|
||||
const next = lines[i + 1]
|
||||
if (next !== undefined && next.indent > indent) {
|
||||
const child = parseBlock(lines, i + 1, indent + 1)
|
||||
out[entry.key] = child.value
|
||||
i = child.next
|
||||
} else {
|
||||
out[entry.key] = null
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isBlockScalarHeader(entry.rest)) {
|
||||
const scalar = readBlockScalar(lines, i + 1, indent, entry.rest)
|
||||
out[entry.key] = scalar.value
|
||||
i = scalar.next
|
||||
continue
|
||||
}
|
||||
out[entry.key] = parseScalar(entry.rest)
|
||||
i++
|
||||
}
|
||||
return { value: out, next: i }
|
||||
}
|
||||
|
||||
function parseSequence(lines: Line[], index: number, indent: number): Parsed {
|
||||
const out: unknown[] = []
|
||||
let i = index
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
if (line.indent !== indent || !isSequenceItem(line.text)) break
|
||||
const afterDash = line.text.slice(1)
|
||||
const lead = afterDash.length - afterDash.trimStart().length
|
||||
const rest = afterDash.trimStart()
|
||||
if (rest === '') {
|
||||
const next = lines[i + 1]
|
||||
if (next !== undefined && next.indent > indent) {
|
||||
const child = parseBlock(lines, i + 1, indent + 1)
|
||||
out.push(child.value)
|
||||
i = child.next
|
||||
} else {
|
||||
out.push(null)
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isBlockScalarHeader(rest)) {
|
||||
const scalar = readBlockScalar(lines, i + 1, indent, rest)
|
||||
out.push(scalar.value)
|
||||
i = scalar.next
|
||||
continue
|
||||
}
|
||||
if (isSequenceItem(rest) || splitEntry(rest) !== null) {
|
||||
// `- key: value` 把映射的起始写在同一行:造一条等价的虚拟行再按同一份行数组解析,
|
||||
// 这样后续与虚拟行同缩进的键仍能正确归入本项。
|
||||
const effectiveIndent = indent + 1 + lead
|
||||
const virtual = lines.slice()
|
||||
virtual[i] = { indent: effectiveIndent, text: rest }
|
||||
const child = isSequenceItem(rest)
|
||||
? parseSequence(virtual, i, effectiveIndent)
|
||||
: parseMapping(virtual, i, effectiveIndent)
|
||||
out.push(child.value)
|
||||
i = child.next
|
||||
continue
|
||||
}
|
||||
out.push(parseScalar(rest))
|
||||
i++
|
||||
}
|
||||
return { value: out, next: i }
|
||||
}
|
||||
|
||||
function isSequenceItem(text: string): boolean {
|
||||
return text === '-' || text.startsWith('- ') || text.startsWith('-\t')
|
||||
}
|
||||
|
||||
/** 找顶层的「键: 值」分隔冒号;引号内和流式括号内的冒号不算。 */
|
||||
function splitEntry(text: string): { key: string; rest: string } | null {
|
||||
let quote: '"' | "'" | null = null
|
||||
let depth = 0
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i]
|
||||
if (quote === '"') {
|
||||
if (ch === '\\') i++
|
||||
else if (ch === '"') quote = null
|
||||
continue
|
||||
}
|
||||
if (quote === "'") {
|
||||
if (ch === "'") {
|
||||
if (text[i + 1] === "'") i++
|
||||
else quote = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch
|
||||
continue
|
||||
}
|
||||
if (ch === '{' || ch === '[') {
|
||||
depth++
|
||||
continue
|
||||
}
|
||||
if (ch === '}' || ch === ']') {
|
||||
depth--
|
||||
continue
|
||||
}
|
||||
if (ch === ':' && depth === 0) {
|
||||
const after = text[i + 1]
|
||||
if (after === undefined || after === ' ' || after === '\t') {
|
||||
return {
|
||||
key: unquote(text.slice(0, i).trim()),
|
||||
rest: text.slice(i + 1).trim(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isBlockScalarHeader(text: string): boolean {
|
||||
return /^[|>][+-]?\d*$/.test(text)
|
||||
}
|
||||
|
||||
function readBlockScalar(
|
||||
lines: Line[],
|
||||
index: number,
|
||||
parentIndent: number,
|
||||
header: string,
|
||||
): { value: string; next: number } {
|
||||
const collected: string[] = []
|
||||
let i = index
|
||||
while (i < lines.length && lines[i].indent > parentIndent) {
|
||||
collected.push(lines[i].text)
|
||||
i++
|
||||
}
|
||||
if (collected.length === 0) return { value: '', next: i }
|
||||
const folded = header.startsWith('>')
|
||||
const keep = header.includes('+')
|
||||
const joined = folded ? collected.join(' ') : collected.join('\n')
|
||||
return { value: keep ? `${joined}\n` : joined, next: i }
|
||||
}
|
||||
|
||||
function parseScalar(text: string): unknown {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') return null
|
||||
if (trimmed.startsWith('"')) return parseDoubleQuoted(trimmed)
|
||||
if (trimmed.startsWith("'")) return parseSingleQuoted(trimmed)
|
||||
if (trimmed.startsWith('[') || trimmed.startsWith('{')) return parseFlow(trimmed)
|
||||
return parsePlain(trimmed)
|
||||
}
|
||||
|
||||
function parseDoubleQuoted(text: string): string {
|
||||
let out = ''
|
||||
let i = 1
|
||||
while (i < text.length) {
|
||||
const ch = text[i]
|
||||
if (ch === '\\') {
|
||||
const esc = text[i + 1]
|
||||
i += 2
|
||||
if (esc === 'n') out += '\n'
|
||||
else if (esc === 't') out += '\t'
|
||||
else if (esc === 'r') out += '\r'
|
||||
else if (esc === 'u') {
|
||||
const hex = text.slice(i, i + 4)
|
||||
if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw new YamlParseError(`非法 \\u 转义:${hex}`)
|
||||
out += String.fromCharCode(parseInt(hex, 16))
|
||||
i += 4
|
||||
} else out += esc ?? ''
|
||||
continue
|
||||
}
|
||||
if (ch === '"') {
|
||||
if (text.slice(i + 1).trim() !== '') throw new YamlParseError(`双引号后有多余内容:${text}`)
|
||||
return out
|
||||
}
|
||||
out += ch
|
||||
i++
|
||||
}
|
||||
throw new YamlParseError(`双引号未闭合:${text}`)
|
||||
}
|
||||
|
||||
function parseSingleQuoted(text: string): string {
|
||||
let out = ''
|
||||
let i = 1
|
||||
while (i < text.length) {
|
||||
const ch = text[i]
|
||||
if (ch === "'") {
|
||||
if (text[i + 1] === "'") {
|
||||
out += "'"
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if (text.slice(i + 1).trim() !== '') throw new YamlParseError(`单引号后有多余内容:${text}`)
|
||||
return out
|
||||
}
|
||||
out += ch
|
||||
i++
|
||||
}
|
||||
throw new YamlParseError(`单引号未闭合:${text}`)
|
||||
}
|
||||
|
||||
function parseFlow(text: string): unknown {
|
||||
let pos = 0
|
||||
|
||||
function skip(): void {
|
||||
while (pos < text.length && /\s/.test(text[pos])) pos++
|
||||
}
|
||||
|
||||
function value(): unknown {
|
||||
skip()
|
||||
const ch = text[pos]
|
||||
if (ch === '[') return sequence()
|
||||
if (ch === '{') return mapping()
|
||||
if (ch === '"' || ch === "'") return quoted()
|
||||
return plain()
|
||||
}
|
||||
|
||||
function quoted(): string {
|
||||
const start = pos
|
||||
const quote = text[pos]
|
||||
pos++
|
||||
while (pos < text.length) {
|
||||
const ch = text[pos]
|
||||
if (quote === '"' && ch === '\\') {
|
||||
pos += 2
|
||||
continue
|
||||
}
|
||||
if (ch === quote) {
|
||||
if (quote === "'" && text[pos + 1] === "'") {
|
||||
pos += 2
|
||||
continue
|
||||
}
|
||||
pos++
|
||||
const parsed = parseScalar(text.slice(start, pos))
|
||||
return typeof parsed === 'string' ? parsed : String(parsed ?? '')
|
||||
}
|
||||
pos++
|
||||
}
|
||||
throw new YamlParseError(`流式引号未闭合:${text}`)
|
||||
}
|
||||
|
||||
function plain(): unknown {
|
||||
const start = pos
|
||||
while (pos < text.length && text[pos] !== ',' && text[pos] !== ']' && text[pos] !== '}') pos++
|
||||
return parsePlain(text.slice(start, pos).trim())
|
||||
}
|
||||
|
||||
function sequence(): unknown[] {
|
||||
pos++
|
||||
const out: unknown[] = []
|
||||
skip()
|
||||
if (text[pos] === ']') {
|
||||
pos++
|
||||
return out
|
||||
}
|
||||
while (pos < text.length) {
|
||||
out.push(value())
|
||||
skip()
|
||||
if (text[pos] === ',') {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
if (text[pos] === ']') {
|
||||
pos++
|
||||
return out
|
||||
}
|
||||
throw new YamlParseError(`流式序列缺少逗号或右括号:${text}`)
|
||||
}
|
||||
throw new YamlParseError(`流式序列未闭合:${text}`)
|
||||
}
|
||||
|
||||
function mapping(): Record<string, unknown> {
|
||||
pos++
|
||||
const out: Record<string, unknown> = {}
|
||||
skip()
|
||||
if (text[pos] === '}') {
|
||||
pos++
|
||||
return out
|
||||
}
|
||||
while (pos < text.length) {
|
||||
skip()
|
||||
const keyStart = pos
|
||||
while (pos < text.length && text[pos] !== ':') pos++
|
||||
if (pos >= text.length) throw new YamlParseError(`流式映射缺少冒号:${text}`)
|
||||
const key = unquote(text.slice(keyStart, pos).trim())
|
||||
pos++
|
||||
out[key] = value()
|
||||
skip()
|
||||
if (text[pos] === ',') {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
if (text[pos] === '}') {
|
||||
pos++
|
||||
return out
|
||||
}
|
||||
throw new YamlParseError(`流式映射缺少逗号或右括号:${text}`)
|
||||
}
|
||||
throw new YamlParseError(`流式映射未闭合:${text}`)
|
||||
}
|
||||
|
||||
const result = value()
|
||||
skip()
|
||||
if (pos !== text.length) throw new YamlParseError(`流式值后有多余内容:${text}`)
|
||||
return result
|
||||
}
|
||||
|
||||
function parsePlain(text: string): unknown {
|
||||
if (text === '' || text === '~' || text === 'null' || text === 'Null' || text === 'NULL') return null
|
||||
if (/^(true|false)$/i.test(text)) return text.toLowerCase() === 'true'
|
||||
if (/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(text)) return Number(text)
|
||||
return text
|
||||
}
|
||||
|
||||
function unquote(text: string): string {
|
||||
if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) {
|
||||
try {
|
||||
return parseDoubleQuoted(text)
|
||||
} catch {
|
||||
return text.slice(1, -1)
|
||||
}
|
||||
}
|
||||
if (text.length >= 2 && text.startsWith("'") && text.endsWith("'")) {
|
||||
return text.slice(1, -1).replace(/''/g, "'")
|
||||
}
|
||||
return text
|
||||
}
|
||||
Reference in new issue
Block a user