feat: 备份恢复 / 图片清理 / 单位恢复 / 审计过滤 / 模板引擎 M1 + 两个导入缺陷修复

用户点名的三件事(模板功能、删死代码、剩余缺口)这一批全部落地,另修掉两个刚暴露的导入缺陷:

**新增能力**
- 备份恢复:`POST /api/backup/restore`(admin)。先把校验做在拍快照之前(只读打开 + integrity_check +
  关键表校验 + 异类库标志表互斥),再用 VACUUM INTO 拍「恢复前安全副本」,然后关连接、整文件替换、
  触发优雅重启;`-wal` 还在时直接拒绝(宁可失败也不让旧库 WAL 套到新库上);恢复后审计直写"恢复之后
  还在的那份库"(不走通用 Auditor,否则记录只会落在被替换掉的旧文件上)。顺带修掉:裸服务端连续恢复
  第二次会在拍副本处崩出一句看不懂的 500,现在回 503「上次恢复后尚未重启,请先重启服务端」。
- 安全副本保留策略:每类库保留最近 5 份,独立于 .db 的 20 份滚动清理。
- 图片管理:`DELETE /api/uploads/{name}`(被引用 409 并列出引用点)、`GET /api/uploads/orphans`、
  `POST /api/uploads/orphans/delete`(只删列出的孤儿,宁漏勿误),全部 admin(与字体删除同档);
  `deploy/backup.bat` 补上 uploads/ 归档(logs 不归档:可再生的诊断且会无上限增长)。
- 已彻底删除单位的恢复:`POST /api/teams/{id}/restore`(与 hideTeam 同档)。幂等;恢复不会把「停用」
  变成「启用」;真机确认恢复前后榜单逐字节一致、change_log 最大值不变(数据一行没动,不需要重算)。
  界面加「显示已彻底删除的单位」(仅 admin)与恢复按钮;非 admin 的删除确认框里写明"本账号无法自行恢复"。
- 审计日志按 `action` 过滤(子串模糊,输入里的 %/_/\ 转义成字面量)。
- 删除死表 recent_projects(新增 system 迁移 0006,未动已应用的迁移)。
- points_override 不再"设了没效果":写入侧 400 + 引擎侧守卫,提示改用「积分调整」。
- 联队份额空壳清理(UnionAttribution/UnionSplit/Entry.TeamShares/resolvedShares 等;TeamShare 类型保留,
  因为 export/realtime/奖牌流水三处在生产路径上构造它)。
- 自定义大屏模板 M1:插值引擎(parseTemplate/renderTemplate),任何情况都不抛异常、未定义→空串+警告、
  警告按 (code,path) 合并计数、无 eval/innerHTML,57 个新用例。

**两个真缺陷(前两个已随 0.1.6 出包)**
- 基础数据包导入「排序」列口径不一致:小项/组别留空保持原值,单位/大项留空被写成 0(plan 没从库里
  现值起手)→ 四张表统一为「留空保持原值」。
- 同类第二处:布尔列「整列被删掉」被当成"取默认值",会把库里所有停用的单位/大项静默改回启用 →
  改为「缺列 = 不动」(写侧与解析侧都收口,补了"缺列保持/空格子=是/显式否=停用"三组对照用例)。

**删死代码**
- createEntry / updateEntry / deleteEntry / entryEventID / checkCategoryInEvent / getEntry /
  normalizeEntryStatus / ExportBaseDataXLSX(全仓 grep 零引用)。⚠️ 随之记入文档:「组别必须属于该小项」
  这道校验现在只由导入路径"按小项+组别名解析"天然保证,将来若新增从请求体接收 categoryId 的接口,
  必须自己补校验(库层没有复合外键)。

**文档**
- devdocs 全面同步(01/02/03/04/05/06/07/09/11/12/13/README;含身份口径、延迟结算、大屏单栏新版式、
  新增路由与设置键、R18/R19 风险);docs 复核 + 第二轮(身份口径三个易写反的分支、图片删除、备份覆盖、
  审计过滤、单位恢复、去掉硬编码版本号、上线检查清单 +5 项);debug/使用说明 6 份逐份 diff 一致。

测试:服务端 13 包(连跑两遍)、前端 406 例、Console 28 例 + 18 文件语法检查 —— 全绿。
版本号 dev_0.1.6 → dev_0.1.7。
This commit is contained in:
AskaEth committed 2026-09-11 19:26:36 +08:00
1 parent 631c8dd8e1
commit 53a0cb225c
61 files changed
+6386 -699

No files matched your search

+562
View File
@@ -0,0 +1,562 @@
/**
* 自定义大屏模板的插值引擎(设计稿 devdocs/16 §3):解析 → 渲染 → 收集警告。
*
* 纯函数:不碰 DOM、不执行模板里的任何字节(模板是数据不是代码,这里没有 eval / new Function),
* 所以 `node --test` 能直接跑,M4 的桥只负责把 html 结果写进 iframe。
*
* 失败口径按设计稿 §3.1 / §9:取不到值 → 空串 + 一条警告,不打断渲染;
* 块结构错误 → 返回结构化 error(带行列号),**不抛异常**,由调用方决定要不要降级。
*/
/** 一条警告的成因。渲染期只产这些码,解析期不产警告。 */
export type TemplateWarningCode =
/** 路径取不到值(含中间段缺失、对数字取属性) */
| 'missing'
/** 路径末段取到 null */
| 'null'
/** 不是设计稿支持的路径写法(运算符、过滤器、多余的 /、@ 关键字拼错…) */
| 'invalid-path'
/** ../ 超过一层,或已到最外层再往上 */
| 'parent-depth'
/** {{#each}} 的数据源不是数组 */
| 'not-array'
/** 插值目标是对象/数组/函数,渲染不出文本 */
| 'not-scalar'
export interface TemplateWarning {
code: TemplateWarningCode
/** 触发警告的原始路径文本(不含花括号) */
path: string
/** 中文可读描述,供预览直接显示 */
message: string
/** 1 起 */
line: number
/** 1 起 */
column: number
/** 同一 (code, path) 在本次渲染里出现的次数,最小 1:循环里同一处笔误只报一条 */
count: number
}
/** 解析期的结构错误(设计稿 §3.1 的「硬失败」,但不抛异常,见文件头)。 */
export type TemplateErrorCode =
| 'unclosed-tag'
| 'unclosed-block'
| 'unexpected-close'
| 'block-mismatch'
| 'unexpected-else'
| 'duplicate-else'
| 'unknown-block'
| 'empty-block-path'
export interface TemplateError {
code: TemplateErrorCode
message: string
/** 1 起 */
line: number
/** 1 起,字符(UTF-16 码元)计 */
column: number
/** 0 起的源码偏移,便于预览精确跳转 */
offset: number
}
export interface TextNode {
type: 'text'
value: string
}
export interface ValueNode {
type: 'value'
path: PathRef
/** true = {{{ }}} / {{& }} 原样输出 */
raw: boolean
line: number
column: number
}
export interface SectionNode {
type: 'each' | 'if'
path: PathRef
body: TemplateNode[]
/** {{else}} 分支;没有 else 时为 null */
alternative: TemplateNode[] | null
line: number
column: number
offset: number
}
export type TemplateNode = TextNode | ValueNode | SectionNode
/** 解析后的路径引用:parents 是 ../ 层数,segments 是点号分段。 */
export interface PathRef {
raw: string
parents: number
segments: string[]
/** false = 不是支持的写法,渲染时按空串处理并给 invalid-path 警告 */
valid: boolean
}
export interface ParseResult {
/** error 非 null 时恒为空数组:半截语法树没有可用信息,不如只给错误位置 */
ast: TemplateNode[]
error: TemplateError | null
}
export interface RenderResult {
html: string
warnings: TemplateWarning[]
/** 结构错误;非 null 时 html 恒为空串 */
error: TemplateError | null
}
/** 只有数组下标与 length 能在数组上取,其它键一律算取不到(设计稿 §3.5 的空串口径)。 */
const ARRAY_INDEX_RE = /^[0-9]+$/
/** 路径分段允许的字符:数据键都是 ASCII,表达式(空格、+、|)会在这里被判为非法。 */
const SEGMENT_RE = /^[A-Za-z0-9_$@]+$/
const LOOP_KEYS = new Set(['@index', '@first', '@last'])
const BLOCK_KEYWORDS = new Set(['each', 'if'])
const ESCAPES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}
function escapeHtml(input: string): string {
return input.replace(/[&<>"']/g, (ch) => ESCAPES[ch] ?? ch)
}
/**
* 设计稿 §3.3 写死的真值表,不要在这里加 JS 的隐式转换:
* ''、0、NaN、[]、{} 一律为假;' '、'0' 这类非空串为真。
*/
function isTruthy(value: unknown): boolean {
if (value === undefined || value === null || value === false) return false
if (typeof value === 'number') return value !== 0 && !Number.isNaN(value)
if (typeof value === 'string') return value !== ''
if (Array.isArray(value)) return value.length > 0
if (typeof value === 'object') return Object.keys(value as object).length > 0
return true
}
/**
* 解析点号路径。非法路径不抛错(设计稿 §3.1:未知 token → 空串 + 警告),
* 但要在这里先判死,渲染时才不用为每个分段重复兜底。
*/
function parsePath(raw: string): PathRef {
let rest = raw
let parents = 0
while (rest.startsWith('../')) {
parents += 1
rest = rest.slice(3)
}
// 设计稿 §3.1:../ 最多一层,多层按空串处理;这里把带 / 的剩余写法一并判非法
if (rest.includes('/')) return { raw, parents, segments: [], valid: false }
const segments = rest === '' ? [] : rest.split('.')
if (segments.length === 0) return { raw, parents, segments, valid: false }
for (const segment of segments) {
if (!SEGMENT_RE.test(segment)) return { raw, parents, segments, valid: false }
if (segment.startsWith('@') && !LOOP_KEYS.has(segment)) {
return { raw, parents, segments, valid: false }
}
}
return { raw, parents, segments, valid: true }
}
interface TextToken {
kind: 'text'
value: string
start: number
end: number
}
interface TagToken {
kind: 'value' | 'open' | 'close' | 'else'
/** open/close 上的块名 */
block: 'each' | 'if' | null
rawPath: string
raw: boolean
start: number
end: number
line: number
column: number
}
type Token = TextToken | TagToken
interface Frame {
value: unknown
/** 非循环帧(根)为 -1 */
index: number
total: number
}
const WARNING_TEXT: Record<TemplateWarningCode, (path: string) => string> = {
missing: (p) => `路径「${p}」取不到值,已按空串渲染`,
null: (p) => `路径「${p}」的值为 null,已按空串渲染`,
'invalid-path': (p) => `「${p}」不是支持的写法(只支持点号路径、this、@index/@first/@last 与一层 ../),已按空串处理`,
'parent-depth': (p) => `「${p}」的 ../ 超过一层或已到最外层,已按空串处理`,
'not-array': (p) => `「${p}」不是数组,{{#each}} 未输出任何内容`,
'not-scalar': (p) => `「${p}」是对象/数组,插值只能渲染文本,已按空串处理`,
}
const ERROR_TEXT: Record<TemplateErrorCode, string> = {
'unclosed-tag': '标签没有闭合的 }}',
'unclosed-block': '块没有闭合的结束标记',
'unexpected-close': '多出来的结束标记',
'block-mismatch': '结束标记与最近的块不配对',
'unexpected-else': '{{else}} 只能出现在 {{#if}} 块内',
'duplicate-else': '同一个 {{#if}} 块里出现了多个 {{else}}',
'unknown-block': '不支持的块标记',
'empty-block-path': '块标记缺少路径',
}
/** 同一 (code, path) 只留第一条,重复出现只累加 count:循环里的笔误不该刷满警告清单。 */
class WarningCollector {
private readonly seen = new Map<string, TemplateWarning>()
private readonly list: TemplateWarning[] = []
warn(code: TemplateWarningCode, path: string, line: number, column: number): void {
const key = `${code}|${path}`
const hit = this.seen.get(key)
if (hit) {
hit.count += 1
return
}
const warning: TemplateWarning = {
code,
path,
message: WARNING_TEXT[code](path),
line,
column,
count: 1,
}
this.seen.set(key, warning)
this.list.push(warning)
}
get warnings(): TemplateWarning[] {
return this.list
}
}
function buildLineStarts(source: string): number[] {
const starts = [0]
for (let i = 0; i < source.length; i += 1) {
if (source.charCodeAt(i) === 10) starts.push(i + 1)
}
return starts
}
function positionAt(starts: number[], offset: number): { line: number; column: number } {
let lo = 0
let hi = starts.length - 1
while (lo < hi) {
const mid = (lo + hi + 1) >> 1
if (starts[mid] <= offset) lo = mid
else hi = mid - 1
}
return { line: lo + 1, column: offset - starts[lo] + 1 }
}
function makeError(
code: TemplateErrorCode,
starts: number[],
offset: number,
detail = '',
): TemplateError {
const { line, column } = positionAt(starts, offset)
return {
code,
message: detail === '' ? ERROR_TEXT[code] : `${ERROR_TEXT[code]}:${detail}`,
line,
column,
offset,
}
}
function tokenize(
source: string,
starts: number[],
): { tokens: Token[]; error: TemplateError | null } {
const tokens: Token[] = []
let cursor = 0
let textStart = 0
while (cursor < source.length) {
const open = source.indexOf('{{', cursor)
if (open < 0) break
if (open > textStart) {
tokens.push({ kind: 'text', value: source.slice(textStart, open), start: textStart, end: open })
}
const triple = source.startsWith('{{{', open)
const contentStart = open + (triple ? 3 : 2)
const close = source.indexOf(triple ? '}}}' : '}}', contentStart)
if (close < 0) {
return { tokens, error: makeError('unclosed-tag', starts, open) }
}
const end = close + (triple ? 3 : 2)
const inner = source.slice(contentStart, close).trim()
const { line, column } = positionAt(starts, open)
if (inner.startsWith('#')) {
const rest = inner.slice(1).trim()
const keyword = rest.split(/\s/)[0] ?? ''
if (!BLOCK_KEYWORDS.has(keyword)) {
return { tokens, error: makeError('unknown-block', starts, open, `{{#${rest}}} 不是 {{#each}} 或 {{#if}}`) }
}
// 操作数取「关键字之后的整段」:{{#if rank > 3}} 要整段判为非法路径 → 假(设计稿 §3.3),
// 不能只取第一个词,否则会解析成一个能取到值的 rank 而变成真。
const rawPath = rest.slice(keyword.length).trim()
if (rawPath === '') {
return { tokens, error: makeError('empty-block-path', starts, open, `{{#${keyword}}}`) }
}
tokens.push({ kind: 'open', block: keyword as 'each' | 'if', rawPath, raw: false, start: open, end, line, column })
} else if (inner.startsWith('/')) {
const keyword = inner.slice(1).trim()
if (!BLOCK_KEYWORDS.has(keyword)) {
return { tokens, error: makeError('unknown-block', starts, open, `{{/${keyword}}} 不是 {{/each}} 或 {{/if}}`) }
}
tokens.push({ kind: 'close', block: keyword as 'each' | 'if', rawPath: '', raw: false, start: open, end, line, column })
} else if (inner === 'else') {
tokens.push({ kind: 'else', block: null, rawPath: '', raw: false, start: open, end, line, column })
} else if (!triple && inner.startsWith('&')) {
// {{& x}} 与 {{{ x }}} 同义(设计稿 §3.4)
tokens.push({ kind: 'value', block: null, rawPath: inner.slice(1).trim(), raw: true, start: open, end, line, column })
} else {
tokens.push({ kind: 'value', block: null, rawPath: inner, raw: triple, start: open, end, line, column })
}
cursor = end
textStart = end
}
if (textStart < source.length) {
tokens.push({ kind: 'text', value: source.slice(textStart), start: textStart, end: source.length })
}
return { tokens, error: null }
}
/**
* Mustache 的 standalone line 规则(设计稿 §3.2):一行里只有空白 + 一个块标记时,
* 整行(含缩进与换行)吞掉。只对块标记生效,插值行照旧保留——缩进写模板不该在输出里留空行。
*/
function stripStandaloneLines(source: string, tokens: Token[]): void {
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i]
if (token.kind === 'text' || token.kind === 'value') continue
const lineStart = source.lastIndexOf('\n', token.start - 1) + 1
if (/[^\t ]/.test(source.slice(lineStart, token.start))) continue
const newline = source.indexOf('\n', token.end)
const lineEnd = newline < 0 ? source.length : newline
// \r 归前一行(CRLF 模板),所以行尾只允许空白与 \r
if (/[^\t\r ]/.test(source.slice(token.end, lineEnd))) continue
const prev = tokens[i - 1]
if (prev && prev.kind === 'text') prev.value = prev.value.replace(/[\t ]*$/, '')
const next = tokens[i + 1]
// 只吃掉「标记尾随空白 + 换行」,下一行自己的缩进要留着
if (next && next.kind === 'text') next.value = next.value.replace(/^[\t ]*\r?\n/, '')
}
}
function buildAst(
tokens: Token[],
starts: number[],
): { ast: TemplateNode[]; error: TemplateError | null } {
const root: TemplateNode[] = []
interface Entry {
node: SectionNode | null
list: TemplateNode[]
part: 'body' | 'alternative'
}
const stack: Entry[] = [{ node: null, list: root, part: 'body' }]
const top = (): Entry => stack[stack.length - 1]
for (const token of tokens) {
if (token.kind === 'text') {
if (token.value !== '') top().list.push({ type: 'text', value: token.value })
continue
}
if (token.kind === 'value') {
top().list.push({
type: 'value',
path: parsePath(token.rawPath),
raw: token.raw,
line: token.line,
column: token.column,
})
continue
}
if (token.kind === 'open') {
const node: SectionNode = {
type: token.block as 'each' | 'if',
path: parsePath(token.rawPath),
body: [],
alternative: null,
line: token.line,
column: token.column,
offset: token.start,
}
top().list.push(node)
stack.push({ node, list: node.body, part: 'body' })
continue
}
if (token.kind === 'else') {
const entry = top()
if (!entry.node || entry.node.type !== 'if') {
return { ast: [], error: makeError('unexpected-else', starts, token.start) }
}
if (entry.part === 'alternative') {
return { ast: [], error: makeError('duplicate-else', starts, token.start) }
}
entry.node.alternative = []
entry.list = entry.node.alternative
entry.part = 'alternative'
continue
}
// close
if (stack.length === 1) {
return { ast: [], error: makeError('unexpected-close', starts, token.start, `{{/${token.block}}}`) }
}
const entry = top()
if (!entry.node || entry.node.type !== token.block) {
return {
ast: [],
error: makeError('block-mismatch', starts, token.start, `{{/${token.block}}} 与最近的 {{#${entry.node?.type ?? '?'}}} 不配对`),
}
}
stack.pop()
}
if (stack.length > 1) {
const unclosed = stack[1].node
return { ast: [], error: makeError('unclosed-block', starts, unclosed?.offset ?? 0, `{{#${unclosed?.type ?? '?'}}}`) }
}
return { ast: root, error: null }
}
function resolvePath(ref: PathRef, stack: Frame[]): { value: unknown; code: TemplateWarningCode | null } {
if (!ref.valid) return { value: undefined, code: 'invalid-path' }
if (ref.parents > 1) return { value: undefined, code: 'parent-depth' }
const baseIndex = stack.length - 1 - ref.parents
if (baseIndex < 0) return { value: undefined, code: 'parent-depth' }
const frame = stack[baseIndex]
let segments = ref.segments
if (segments[0]?.startsWith('@')) {
if (segments.length > 1) return { value: undefined, code: 'invalid-path' }
// 循环外没有 @index:按取不到处理(设计稿 §3.1 未定义 → 空串)
if (baseIndex === 0) return { value: undefined, code: 'missing' }
if (segments[0] === '@index') return { value: frame.index, code: null }
if (segments[0] === '@first') return { value: frame.index === 0, code: null }
return { value: frame.index === frame.total - 1, code: null }
}
// 设计稿 §3.2:循环体内 this.xxx 与直接 xxx 等价
if (segments[0] === 'this') segments = segments.slice(1)
let current = frame.value
for (const segment of segments) {
if (current === undefined) return { value: undefined, code: 'missing' }
if (current === null) return { value: undefined, code: 'null' }
if (Array.isArray(current)) {
if (segment === 'length') {
current = current.length
continue
}
if (!ARRAY_INDEX_RE.test(segment)) return { value: undefined, code: 'missing' }
const index = Number(segment)
if (index >= current.length) return { value: undefined, code: 'missing' }
current = current[index]
continue
}
if (typeof current === 'object') {
if (!Object.prototype.hasOwnProperty.call(current, segment)) {
return { value: undefined, code: 'missing' }
}
current = (current as Record<string, unknown>)[segment]
continue
}
// 对数字/布尔/字符串取属性 → 空串(设计稿 §3.5)
return { value: undefined, code: 'missing' }
}
if (current === undefined) return { value: undefined, code: 'missing' }
if (current === null) return { value: undefined, code: 'null' }
return { value: current, code: null }
}
function renderNodes(nodes: TemplateNode[], stack: Frame[], out: string[], sink: WarningCollector): void {
for (const node of nodes) {
if (node.type === 'text') {
out.push(node.value)
continue
}
if (node.type === 'value') {
const resolved = resolvePath(node.path, stack)
if (resolved.code) sink.warn(resolved.code, node.path.raw, node.line, node.column)
const { text, code } = valueToText(resolved.value)
if (code) sink.warn(code, node.path.raw, node.line, node.column)
out.push(node.raw ? text : escapeHtml(text))
continue
}
const resolved = resolvePath(node.path, stack)
if (resolved.code) sink.warn(resolved.code, node.path.raw, node.line, node.column)
if (node.type === 'if') {
if (isTruthy(resolved.value)) renderNodes(node.body, stack, out, sink)
else if (node.alternative) renderNodes(node.alternative, stack, out, sink)
continue
}
// 设计稿 §3.2:只支持数组;null/undefined 前面已按空值告警,非数组另告一条
if (Array.isArray(resolved.value)) {
const total = resolved.value.length
for (let i = 0; i < total; i += 1) {
renderNodes(node.body, stack.concat({ value: resolved.value[i], index: i, total }), out, sink)
}
} else if (resolved.value !== undefined && resolved.value !== null) {
sink.warn('not-array', node.path.raw, node.line, node.column)
}
}
}
/**
* 对象/数组不落成 [object Object]:屏上要的是文本,渲染不出来就给空串 + 警告。
* undefined/null 不在这里告警——取值阶段已经报过 missing/null,重复报会刷屏。
*/
function valueToText(value: unknown): { text: string; code: TemplateWarningCode | null } {
if (value === undefined || value === null) return { text: '', code: null }
if (typeof value === 'string') return { text: value, code: null }
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
return { text: String(value), code: null }
}
return { text: '', code: 'not-scalar' }
}
export function parseTemplate(source: string): ParseResult {
const starts = buildLineStarts(source)
const { tokens, error } = tokenize(source, starts)
if (error) return { ast: [], error }
stripStandaloneLines(source, tokens)
return buildAst(tokens, starts)
}
/** 渲染。任何情况下都不抛异常:结构错误走返回值里的 error,取不到值走 html 空串 + warnings。 */
export function renderTemplate(source: string, data: unknown = {}): RenderResult {
const { ast, error } = parseTemplate(source)
if (error) return { html: '', warnings: [], error }
const sink = new WarningCollector()
const out: string[] = []
renderNodes(ast, [{ value: data, index: -1, total: -1 }], out, sink)
return { html: out.join(''), warnings: sink.warnings, error: null }
}
@@ -0,0 +1,141 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { renderTemplate } from '../template'
// 块语法口径:设计稿 §3.2(#each)、§3.3(#if/else 真值表)、§3.2 的 standalone line 吞行规则。
test('#each:空数组不输出循环体,也不产生警告', () => {
const result = renderTemplate('<ul>{{#each rows}}<li>{{ this }}</li>{{/each}}</ul>', { rows: [] })
assert.equal(result.html, '<ul></ul>')
assert.deepEqual(result.warnings, [])
})
test('#each:null/undefined 当空数组,给一条警告', () => {
const missing = renderTemplate('[{{#each rows}}x{{/each}}]', {})
assert.equal(missing.html, '[]')
assert.equal(missing.warnings.length, 1)
assert.equal(missing.warnings[0].code, 'missing')
const nullCase = renderTemplate('[{{#each rows}}x{{/each}}]', { rows: null })
assert.equal(nullCase.html, '[]')
assert.equal(nullCase.warnings[0].code, 'null')
})
test('#each:非数组只告警不渲染(设计稿 §3.2 只支持数组)', () => {
const result = renderTemplate('[{{#each rows}}x{{/each}}]', { rows: 'abc' })
assert.equal(result.html, '[]')
assert.equal(result.warnings.length, 1)
assert.equal(result.warnings[0].code, 'not-array')
})
test('#each:多条 + @index/@first/@last', () => {
const source = '{{#each rows}}{{ @index }}:{{ this }}:{{ @first }}:{{ @last }};{{/each}}'
const result = renderTemplate(source, { rows: ['a', 'b'] })
assert.equal(result.html, '0:a:true:false;1:b:false:true;')
assert.deepEqual(result.warnings, [])
})
test('#each:this.xxx 与直接 xxx 等价(设计稿 §3.2)', () => {
const data = { rows: [{ n: 1 }, { n: 2 }] }
assert.equal(renderTemplate('{{#each rows}}{{ this.n }}/{{ n }},{{/each}}', data).html, '1/1,2/2,')
})
test('#each:嵌套循环里 this 逐层生效、../ 弹一层', () => {
const data = { groups: [{ name: 'A', items: ['x', 'y'] }, { name: 'B', items: ['z'] }] }
const source = '{{#each groups}}{{#each this.items}}{{ ../this.name }}:{{ this }};{{/each}}{{/each}}'
const result = renderTemplate(source, data)
assert.equal(result.html, 'A:x;A:y;B:z;')
assert.deepEqual(result.warnings, [])
})
test('未定义路径作 #each 数据源:嵌套时仍按空数组处理', () => {
const result = renderTemplate('{{#each groups}}{{#each this.items}}{{ this }}{{/each}}{{#each this.absent}}x{{/each}}{{/each}}', {
groups: [{ items: ['a'] }],
})
assert.equal(result.html, 'a')
assert.equal(result.warnings[0].code, 'missing')
assert.equal(result.warnings[0].path, 'this.absent')
})
test('../ 超过一层:空串 + parent-depth 警告(设计稿 §3.1)', () => {
const result = renderTemplate('{{#each rows}}{{ ../../a }}{{/each}}', { a: 1, rows: [1] })
assert.equal(result.html, '')
assert.equal(result.warnings[0].code, 'parent-depth')
})
test('../ 在根作用域:空串 + parent-depth 警告', () => {
const result = renderTemplate('{{ ../a }}', { a: 1 })
assert.equal(result.html, '')
assert.equal(result.warnings[0].code, 'parent-depth')
})
test('循环外取 @index:空串 + 警告', () => {
const result = renderTemplate('{{ @index }}', {})
assert.equal(result.html, '')
assert.equal(result.warnings[0].code, 'missing')
})
test('#if:真值表按设计稿 §3.3 写死', () => {
const cases: Array<[unknown, boolean]> = [
[undefined, false],
[null, false],
[false, false],
['', false],
[0, false],
[Number.NaN, false],
[[], false],
[{}, false],
[' ', true],
['0', true],
[1, true],
[-1, true],
[[0], true],
[{ a: 1 }, true],
['a', true],
[true, true],
]
for (const [value, expected] of cases) {
const result = renderTemplate('{{#if v}}T{{else}}F{{/if}}', { v: value })
assert.equal(result.html, expected ? 'T' : 'F', `v=${String(value)}`)
}
})
test('#if:走不到的分支不求值(分支里的缺失路径不产生警告)', () => {
const result = renderTemplate('{{#if ok}}{{ a.b.c }}{{else}}{{ d.e }}{{/if}}', { ok: false, d: { e: 'hit' } })
assert.equal(result.html, 'hit')
assert.deepEqual(result.warnings, [])
})
test('#if/else:跨行写法的两条分支', () => {
const source = ['{{#if rows.length}}', ' 有 {{ rows.length }} 条', '{{else}}', ' 暂无数据', '{{/if}}'].join('\n')
assert.equal(renderTemplate(source, { rows: [1, 2] }).html, ' 有 2 条\n')
assert.equal(renderTemplate(source, { rows: [] }).html, ' 暂无数据\n')
})
test('#if 里的比较运算按非法路径处理 → 假(设计稿 §3.3)', () => {
const result = renderTemplate('{{#if rank > 3}}T{{else}}F{{/if}}', { rank: 9 })
assert.equal(result.html, 'F')
assert.equal(result.warnings[0].code, 'invalid-path')
})
test('standalone line:只有空白 + 块标记的行整行吞掉,缩进模板不留空行', () => {
const source = '<div>\n {{#each rows}}\n <span>{{ this }}</span>\n {{/each}}\n</div>'
const result = renderTemplate(source, { rows: ['a', 'b'] })
// {{#each}} / {{/each}} 那两行连缩进与换行一起消失;循环体自己那行的缩进保留
assert.equal(result.html, '<div>\n <span>a</span>\n <span>b</span>\n</div>')
})
test('standalone line:空数组时不留下循环那两行的空白', () => {
const source = '<div>\n {{#each rows}}\n <span>{{ this }}</span>\n {{/each}}\n</div>'
assert.equal(renderTemplate(source, { rows: [] }).html, '<div>\n</div>')
})
test('standalone line:行内块与插值行都不吞行', () => {
assert.equal(renderTemplate('x{{#if v}}y{{/if}}z', { v: true }).html, 'xyz')
assert.equal(renderTemplate('<div>\n {{ v }}\n</div>', { v: 'a' }).html, '<div>\n a\n</div>')
})
test('standalone line:块标记周围有其它字符就不吞行', () => {
const result = renderTemplate('<div>\n {{#if v}}x{{/if}}\n</div>', { v: true })
assert.equal(result.html, '<div>\n x\n</div>')
})
@@ -0,0 +1,104 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { parseTemplate, renderTemplate } from '../template'
// 结构错误口径:设计稿 §3.1 说「硬失败」,§9 说要降级、不能白屏,§10.1 要「结构化 ParseError(含位置)」。
// 这里的落法是:引擎不抛异常,把结构化错误(码 + 行列号 + 偏移)放在返回值里,由调用方决定降级。
test('未闭合的 {{#each}}:返回 unclosed-block,html 为空,不抛异常', () => {
const source = 'line1\n{{#each rows}}\n <span>{{ this }}</span>\n'
const result = renderTemplate(source, { rows: ['a'] })
assert.equal(result.error?.code, 'unclosed-block')
assert.equal(result.error?.line, 2)
assert.equal(result.error?.column, 1)
assert.equal(result.error?.offset, 6)
assert.equal(result.html, '')
assert.deepEqual(result.warnings, [])
})
test('未闭合的 {{#if}}:返回 unclosed-block,指向最外层没闭合的块', () => {
const source = '{{#if a}}\n{{#if b}}\nx\n{{/if}}\n'
const result = renderTemplate(source, { a: true, b: true })
assert.equal(result.error?.code, 'unclosed-block')
assert.equal(result.error?.line, 1)
assert.equal(result.html, '')
})
test('多出来的 {{/each}}:返回 unexpected-close', () => {
const result = renderTemplate('x\n{{/each}}', {})
assert.equal(result.error?.code, 'unexpected-close')
assert.equal(result.error?.line, 2)
assert.equal(result.error?.column, 1)
})
test('错配的 {{/if}}:返回 block-mismatch,消息里点出两个块名', () => {
const result = renderTemplate('{{#each rows}}x{{/if}}', { rows: [] })
assert.equal(result.error?.code, 'block-mismatch')
assert.ok(result.error?.message.includes('each'))
assert.ok(result.error?.message.includes('if'))
})
test('{{else}} 出现在 {{#each}} 内:返回 unexpected-else', () => {
const result = renderTemplate('{{#each rows}}a{{else}}b{{/each}}', { rows: [] })
assert.equal(result.error?.code, 'unexpected-else')
})
test('{{else}} 出现在块外:返回 unexpected-else', () => {
const result = renderTemplate('{{else}}', {})
assert.equal(result.error?.code, 'unexpected-else')
})
test('同一个 {{#if}} 里两个 {{else}}:返回 duplicate-else', () => {
const result = renderTemplate('{{#if a}}1{{else}}2{{else}}3{{/if}}', { a: true })
assert.equal(result.error?.code, 'duplicate-else')
})
test('{{ 不配对:返回 unclosed-tag 并给出精确位置', () => {
const result = renderTemplate('a\n b {{ x', {})
assert.equal(result.error?.code, 'unclosed-tag')
assert.equal(result.error?.line, 2)
assert.equal(result.error?.column, 4)
assert.equal(result.error?.offset, 5)
})
test('{{{ 不配对:返回 unclosed-tag', () => {
const result = renderTemplate('{{{ x }}', { x: 1 })
assert.equal(result.error?.code, 'unclosed-tag')
assert.equal(result.html, '')
})
test('不支持的块标记:返回 unknown-block', () => {
assert.equal(renderTemplate('{{#unless a}}x{{/unless}}', {}).error?.code, 'unknown-block')
assert.equal(renderTemplate('{{#each rows}}x{{/with}}', {}).error?.code, 'unknown-block')
})
test('块标记缺路径:返回 empty-block-path', () => {
assert.equal(renderTemplate('{{#if}}x{{/if}}', {}).error?.code, 'empty-block-path')
assert.equal(renderTemplate('{{#each}}x{{/each}}', {}).error?.code, 'empty-block-path')
})
test('结构错误一律不抛异常(设计稿 §9:模板问题不得打挂大屏)', () => {
const broken = ['{{#each rows}}', '{{/if}}', '{{', '{{{', '{{#unless a}}', '{{else}}', '{{#each rows}}{{/if}}']
for (const source of broken) {
assert.doesNotThrow(() => renderTemplate(source, { rows: [] }), source)
assert.ok(renderTemplate(source, { rows: [] }).error !== null, source)
}
})
test('解析通过的模板 error 为 null,ast 非空', () => {
const parsed = parseTemplate('{{ a }}')
assert.equal(parsed.error, null)
assert.equal(parsed.ast.length, 1)
assert.equal(parsed.ast[0].type, 'value')
const broken = parseTemplate('{{#each rows}}')
assert.equal(broken.error?.code, 'unclosed-block')
assert.deepEqual(broken.ast, [])
})
test('行号按实际换行统计(\\r\\n 模板也不偏)', () => {
const result = renderTemplate('a\r\nb\r\n{{#each rows}}\r\nx\r\n', { rows: [] })
assert.equal(result.error?.line, 3)
})
@@ -0,0 +1,132 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { renderTemplate } from '../template'
// M1 的验收目标(设计稿 §3.7、§11.1):把「社区积分榜」示例模板用一份构造数据渲染出来。
// 这里保留示例的缩进与块标记位置,顺带验证 standalone line 吞行不会在输出里留下空行。
const SOURCE = `<div class="wrap">
<h1>{{ settings.display.title }}</h1>
<div class="sub">{{ settings.event.name }} · 共 {{ standings.length }} 个单位</div>
{{#if standings.length}}
<div class="row head"><span class="rk">名次</span><span class="nm">单位</span><span></span><span class="pts">总分</span></div>
{{#each standings}}
<div class="row">
<span class="rk top{{ this.rank }}">{{ this.rank }}</span>
<span class="nm">
{{{ this.logoImg }}}
<span>{{ this.teamName }}</span>
</span>
<span>
{{#if this.rankChange}}<b class="{{ this.rankUpDownClass }}">{{ this.rankChangeText }}</b>{{/if}}
</span>
<span class="pts">{{ this.totalPoints }}</span>
</div>
{{/each}}
{{else}}
<div class="empty">暂无积分数据</div>
{{/if}}
</div>`
// logoImg / rankChangeText / rankUpDownClass 是设计稿 §4.3 说的「桥补的便利字段」,
// M1 只消费它们,不由引擎生成。
function createData() {
return {
settings: {
display: { title: '社区积分榜' },
event: { name: '第十二届社区运动会' },
},
standings: [
{
rank: 1,
teamName: '阳光社区',
totalPoints: 128,
rankChange: 2,
rankChangeText: '▲2',
rankUpDownClass: 'up',
logoImg: '<img src="/uploads/sun.png" alt="">',
},
{
rank: 2,
teamName: '晨曦社区',
totalPoints: 96,
rankChange: 0,
rankChangeText: '',
rankUpDownClass: '',
logoImg: '',
},
{
rank: 3,
teamName: 'A&B <社区>',
totalPoints: 88,
rankChange: null,
rankChangeText: '',
rankUpDownClass: '',
logoImg: '',
},
],
}
}
test('示例一:纯插值「社区积分榜」的关键片段', () => {
const result = renderTemplate(SOURCE, createData())
assert.equal(result.error, null)
assert.equal(result.html.includes('<h1>社区积分榜</h1>'), true, '标题插值')
assert.equal(result.html.includes('第十二届社区运动会 · 共 3 个单位'), true, '赛事名与数组长度')
assert.equal(result.html.includes('<span class="rk">名次</span>'), true, '有数据时走 #if 真分支的表头')
assert.equal(result.html.includes('<span class="rk top1">1</span>'), true, '名次参与 class 拼接')
assert.equal(result.html.includes('<b class="up">▲2</b>'), true, '上升箭头')
assert.equal(result.html.includes('<span class="pts">128</span>'), true, '总分')
assert.equal(result.html.includes('暂无积分数据'), false, '有数据时不输出空态')
})
test('示例一:三花括号的 logoImg 原样落成标签,文本字段仍然转义', () => {
const result = renderTemplate(SOURCE, createData())
assert.equal(result.html.includes('<img src="/uploads/sun.png" alt="">'), true, 'logoImg 不该被实体化')
assert.equal(result.html.includes('A&amp;B &lt;社区&gt;'), true, '单位名里的 & 与尖括号被转义')
assert.equal(result.html.includes('A&B <社区>'), false)
})
test('示例一:空值处渲染成空串,输出里不出现 undefined / null / [object Object]', () => {
const result = renderTemplate(SOURCE, createData())
assert.equal(result.html.includes('undefined'), false)
assert.equal(result.html.includes('[object Object]'), false)
assert.equal(result.html.includes('>null<'), false)
// 第三行 rankChange 为 null:不输出箭头 <b>,那一格是空串
const thirdRow = result.html.slice(result.html.indexOf('A&amp;B &lt;社区&gt;'))
assert.equal(thirdRow.includes('<b class'), false)
})
test('示例一:standings 为空时走 {{else}} 空态分支', () => {
const data = { ...createData(), standings: [] }
const result = renderTemplate(SOURCE, data)
assert.equal(result.html.includes('暂无积分数据'), true)
assert.equal(result.html.includes('名次'), false, '空态不该输出表头')
assert.equal(result.html.includes('共 0 个单位'), true)
assert.equal(result.error, null)
})
test('示例一:整体渲染不产生结构错误,警告只来自按设计缺失的字段', () => {
const result = renderTemplate(SOURCE, createData())
assert.equal(result.error, null)
for (const warning of result.warnings) {
assert.equal(warning.code, 'null')
assert.equal(warning.path, 'this.rankChange')
}
assert.equal(result.warnings.length, 1)
})
test('示例一:空结果里循环那两行不留下空白行', () => {
const data = { ...createData(), standings: [] }
const result = renderTemplate(SOURCE, data)
assert.equal(result.html.includes('\n \n'), false, 'standalone 块标记的行应当整行消失')
// 循环体整段没输出,接在空态分支前面的只有模板里那个空行
assert.equal(result.html.includes('\n <div class="empty">暂无积分数据</div>\n</div>'), true)
})
@@ -0,0 +1,49 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { renderTemplate } from '../template'
// 设计稿 §10.1 的「边界/性能」组:单文件上限是 1 MiB(§6.2),引擎不能在这个量级上炸或卡死。
test('1 MiB 量级的模板能正常渲染', () => {
const filler = 'x'.repeat(1024)
const source = `${filler}\n`.repeat(1024) + '<b>{{ title }}</b>\n'
assert.ok(source.length > 1024 * 1024, '构造的模板要超过 1 MiB')
const result = renderTemplate(source, { title: '超长模板' })
assert.equal(result.error, null)
assert.equal(result.html.endsWith('<b>超长模板</b>\n'), true)
assert.deepEqual(result.warnings, [])
})
test('1000 项循环:结果正确,同一处缺失路径只留一条警告', () => {
const rows = Array.from({ length: 1000 }, (_, i) => ({ n: i }))
const result = renderTemplate('{{#each rows}}{{ this.n }};{{/each}}', { rows })
assert.equal(result.error, null)
assert.equal(result.html.startsWith('0;1;2;'), true)
assert.equal(result.html.endsWith('999;'), true)
const missing = renderTemplate('{{#each rows}}{{ this.absent }}{{/each}}', { rows })
assert.equal(missing.html, '')
assert.equal(missing.warnings.length, 1)
assert.equal(missing.warnings[0].count, 1000)
})
test('深嵌套 #each/#if(30 层)不炸,作用域逐层正确', () => {
const depth = 30
let source = ''
const root: Record<string, unknown> = { name: 'root' }
let cursor = root
for (let i = 0; i < depth; i += 1) {
source += `{{#each level${i}}}`
const inner: Record<string, unknown> = { name: `n${i}` }
cursor[`level${i}`] = [inner]
cursor = inner
}
source += '{{ this.name }}'
for (let i = 0; i < depth; i += 1) source += '{{/each}}'
const result = renderTemplate(source, root)
assert.equal(result.error, null)
assert.equal(result.html, `n${depth - 1}`)
})
@@ -0,0 +1,138 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { renderTemplate } from '../template'
// 插值通道的取值与转义口径:设计稿 §3.1(路径)、§3.4(转义)、§3.5(未定义=空串 + 警告)。
// 引擎是纯函数,这里全部直接调 renderTemplate,不依赖 Vue / DOM。
test('插值:多级路径与数组下标', () => {
const data = { settings: { display: { title: '春季运动会' } }, standings: [{ teamName: '甲' }, { teamName: '乙' }] }
const result = renderTemplate('{{ settings.display.title }}|{{ standings.0.teamName }}|{{standings.1.teamName}}', data)
assert.equal(result.html, '春季运动会|甲|乙')
assert.equal(result.error, null)
assert.deepEqual(result.warnings, [])
})
test('插值:路径外的空白不参与取值', () => {
const result = renderTemplate('{{ v }}', { v: 'ok' })
assert.equal(result.html, 'ok')
})
test('未定义变量:渲染成空串,只报一条警告,不抛异常', () => {
const result = renderTemplate('<td>{{ settings.display.missing }}</td>', { settings: { display: {} } })
assert.equal(result.html, '<td></td>')
assert.equal(result.error, null)
assert.equal(result.warnings.length, 1)
assert.equal(result.warnings[0].code, 'missing')
assert.equal(result.warnings[0].path, 'settings.display.missing')
assert.equal(result.warnings[0].count, 1)
assert.equal(result.warnings[0].line, 1)
assert.ok(result.warnings[0].message.includes('settings.display.missing'))
assert.ok(!result.html.includes('undefined'))
})
test('深层缺失:中间节点不存在也是空串', () => {
const result = renderTemplate('{{ a.b.c }}', {})
assert.equal(result.html, '')
assert.equal(result.warnings[0].code, 'missing')
})
test('null 中间节点:空串 + null 警告', () => {
const result = renderTemplate('{{ a.b.c }}', { a: { b: null } })
assert.equal(result.html, '')
assert.equal(result.warnings[0].code, 'null')
})
test('null 叶子:空串 + null 警告', () => {
const result = renderTemplate('[{{ a }}]', { a: null })
assert.equal(result.html, '[]')
assert.equal(result.warnings[0].code, 'null')
})
test('对数字/字符串取属性:空串 + 警告(设计稿 §3.5 的例子 version.x)', () => {
const numberCase = renderTemplate('{{ version.x }}', { version: 3 })
assert.equal(numberCase.html, '')
assert.equal(numberCase.warnings[0].code, 'missing')
const stringCase = renderTemplate('{{ name.length }}', { name: 'abc' })
assert.equal(stringCase.html, '')
assert.equal(stringCase.warnings[0].code, 'missing')
})
test('数组越界与用非下标取数组:空串 + 警告', () => {
const outOfRange = renderTemplate('{{ rows.9 }}', { rows: ['a'] })
assert.equal(outOfRange.html, '')
assert.equal(outOfRange.warnings[0].code, 'missing')
const nonIndex = renderTemplate('{{ rows.foo }}', { rows: ['a'] })
assert.equal(nonIndex.html, '')
assert.equal(nonIndex.warnings[0].code, 'missing')
})
test('数组的 length 可以取', () => {
const result = renderTemplate('共 {{ rows.length }} 条', { rows: ['a', 'b'] })
assert.equal(result.html, '共 2 条')
assert.deepEqual(result.warnings, [])
})
test('同一个缺失路径出现多次:合并成一条警告并累加 count', () => {
const result = renderTemplate('{{ a }}-{{ a }}-{{ a }}', {})
assert.equal(result.html, '--')
assert.equal(result.warnings.length, 1)
assert.equal(result.warnings[0].count, 3)
})
test('默认转义:& < > " \' 全部实体化,中文原样', () => {
const value = `<script>alert("x&y")</script>'中文`
const result = renderTemplate('{{ v }}', { v: value })
assert.equal(result.html, '&lt;script&gt;alert(&quot;x&amp;y&quot;)&lt;/script&gt;&#39;中文')
assert.ok(!result.html.includes('<'), '转义后不该再出现 <')
})
test('三花括号与 {{& }} 原样输出(设计稿 §3.4)', () => {
const data = { logoImg: '<img src="/uploads/a.png" alt="">' }
assert.equal(renderTemplate('{{{ logoImg }}}', data).html, '<img src="/uploads/a.png" alt="">')
assert.equal(renderTemplate('{{& logoImg }}', data).html, '<img src="/uploads/a.png" alt="">')
// 默认通道仍然转义,两者不能混为一谈
assert.equal(renderTemplate('{{ logoImg }}', data).html, '&lt;img src=&quot;/uploads/a.png&quot; alt=&quot;&quot;&gt;')
})
test('数字与布尔照原样渲染', () => {
assert.equal(renderTemplate('{{ n }}', { n: 0 }).html, '0')
assert.equal(renderTemplate('{{ n }}', { n: -1.5 }).html, '-1.5')
assert.equal(renderTemplate('{{ b }}', { b: true }).html, 'true')
assert.equal(renderTemplate('{{ b }}', { b: false }).html, 'false')
})
test('对象/数组插值:空串 + not-scalar 警告,不把 [object Object] 泼到屏上', () => {
const objectCase = renderTemplate('{{ settings }}', { settings: { a: 1 } })
assert.equal(objectCase.html, '')
assert.equal(objectCase.warnings[0].code, 'not-scalar')
const arrayCase = renderTemplate('{{ rows }}', { rows: [1, 2] })
assert.equal(arrayCase.html, '')
assert.equal(arrayCase.warnings[0].code, 'not-scalar')
})
test('不支持的写法(运算符/过滤器/空标签):空串 + invalid-path 警告,不抛异常', () => {
const cases = ['{{ a + 1 }}', '{{ a | b }}', '{{ a.b. }}', '{{ }}', '{{ @foo }}', '{{ a/b }}']
for (const source of cases) {
const result = renderTemplate(source, { a: 1, b: 2 })
assert.equal(result.html, '', source)
assert.equal(result.error, null, source)
assert.equal(result.warnings[0]?.code, 'invalid-path', source)
}
})
test('渲染不抛异常:各种奇怪的数据对象都只是空串 + 警告', () => {
const sources = ['{{ a.b }}', '{{ a }}', '{{ @index }}', '{{ ../a }}', '{{#if a}}{{ a }}{{/if}}']
const datas: unknown[] = [undefined, null, 0, 'str', [], { a: null }, { a: () => 1 }]
for (const source of sources) {
for (const data of datas) {
assert.doesNotThrow(() => renderTemplate(source, data), `${source} / ${String(data)}`)
}
}
})