This repository has been archived on 2026-06-21. You can view files and clone it, but cannot push or open issues or pull requests.
Files
MetaLab/internal/service/shortcode_service.go
Victor_Jay 6ec12010f3 feat: Tiptap替换为Vditor + MD存储重构 + Shortcode卡片扩展
- 编辑器:Tiptap 替换为 Vditor 3.11.2(wysiwyg 模式,本地托管 5.8MB,去 CDN)
- 存储:Body 字段从 HTML 改为 Markdown,去除 BodyHTML,新增 Excerpt 列表摘要
- 安全:去除 bluemonday 依赖(MD 纯文本无 XSS 风险),go.mod 已清理
- Shortcode:新增 [zone:type:params] 扩展语法,支持活动/游戏/投票/资源卡片
- 图片上传:POST /api/posts/upload-image 直接返回 Vditor 原生响应格式
- 草稿:localStorage 键名改为 draft_post_body_md,与旧 HTML 草稿隔离
- 详情页:Vditor.method.min.js 客户端 MD → HTML 渲染,去 highlight.js CDN
- 样式:去 Tiptap 样式 ~190 行,精简工具栏 CSS,新增卡片样式
- 文档:vditor-migration-plan.md 完整记录迁移决策、架构、用法
2026-05-30 15:56:22 +08:00

144 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"fmt"
"html"
"strings"
"metazone.cc/metalab/internal/model"
)
// =============================================================================
// ShortcodeService — Markdown shortcode 解析与渲染
//
// 职责:
// 1. 从 Markdown body 中提取所有 [zone:type:params] 语法
// 2. 将合法的 shortcode 替换为前端可识别的占位 HTML<div class="zone-card">
// 3. 未注册的 shortcode 保持不变(不报错,避免破坏用户输入)
//
// 扩展新类型:
// 1. 在 model/shortcode.go 中注册新 ShortcodeType 常量
// 2. 在本文件的 renderPlaceholder() 中添加 case 分支
// 3. 在 templates/.../shortcode.js 中添加前端卡片渲染逻辑
// =============================================================================
// ShortcodeService 无状态,纯函数集合
type ShortcodeService struct{}
// NewShortcodeService 构造函数
func NewShortcodeService() *ShortcodeService {
return &ShortcodeService{}
}
// =============================================================================
// 主入口:解析 + 替换
// =============================================================================
// Process 从 MD body 中提取 shortcode 并替换为占位 HTML
// 返回的 ProcessedBody 可直接传给模板渲染Go template 会自动对 HTML 实体转义,
// 因此占位 div 需要用 template.HTML 类型标记为安全)
func (s *ShortcodeService) Process(body string) model.ShortcodeResult {
result := model.ShortcodeResult{
ProcessedBody: body,
}
// 一次遍历替换所有合法的 shortcode
result.ProcessedBody = model.ShortcodePattern.ReplaceAllStringFunc(body, func(match string) string {
sc, ok := parseShortcode(match)
if !ok {
return match // 格式不合法,保留原文
}
if !model.IsValidType(string(sc.Type)) {
return match // 类型未注册,保留原文
}
// 生成占位 HTML
placeholder := renderPlaceholder(sc)
if placeholder == "" {
return match
}
result.Shortcodes = append(result.Shortcodes, sc)
return placeholder
})
return result
}
// =============================================================================
// 解析
// =============================================================================
// parseShortcode 从 "[zone:type:params]" 字符串中解析出 Shortcode
// 返回 false 表示格式不合法
func parseShortcode(raw string) (model.Shortcode, bool) {
matches := model.ShortcodePattern.FindStringSubmatch(raw)
if len(matches) != 3 {
return model.Shortcode{}, false
}
sc := model.Shortcode{
Raw: raw,
Type: model.ShortcodeType(matches[1]),
Params: strings.TrimSpace(matches[2]),
}
if sc.Params == "" {
return model.Shortcode{}, false
}
return sc, true
}
// =============================================================================
// 占位 HTML 渲染器(每个类型一个 case
// =============================================================================
// renderPlaceholder 根据 shortcode 类型生成前端占位 HTML
//
// 占位 div 结构约定:
// <div class="zone-card" data-zone-type="类型" data-zone-id="参数"
// data-zone-extra="附加信息">
// <span class="zone-card-loading">卡片加载中...</span>
// </div>
//
// 前端 shortcode.js 根据 data-zone-* 属性决定如何渲染。
// "加载中..." 文本会被 shortcode.js 在渲染时替换,仅作降级显示。
func renderPlaceholder(sc model.Shortcode) string {
switch sc.Type {
case model.ShortcodeEvent:
// [zone:event:活动ID]
// 前端根据活动ID获取标题、时间、封面等信息
return fmt.Sprintf(
`<div class="zone-card" data-zone-type="event" data-zone-id="%s"><span class="zone-card-loading">活动卡片加载中...</span></div>`,
html.EscapeString(sc.Params),
)
case model.ShortcodeGame:
// [zone:game:游戏slug]
// 前端根据游戏slug获取名称、封面、标签等信息
return fmt.Sprintf(
`<div class="zone-card" data-zone-type="game" data-zone-id="%s"><span class="zone-card-loading">游戏卡片加载中...</span></div>`,
html.EscapeString(sc.Params),
)
case model.ShortcodePoll:
// [zone:poll:投票ID] ※预留
return fmt.Sprintf(
`<div class="zone-card" data-zone-type="poll" data-zone-id="%s"><span class="zone-card-loading">投票组件加载中...</span></div>`,
html.EscapeString(sc.Params),
)
case model.ShortcodeResource:
// [zone:resource:资源ID] ※预留
return fmt.Sprintf(
`<div class="zone-card" data-zone-type="resource" data-zone-id="%s"><span class="zone-card-loading">资源卡片加载中...</span></div>`,
html.EscapeString(sc.Params),
)
default:
return ""
}
}