Files
larksuite-cli/shortcuts/mail/lint/linter.go
bubbmon233 bbef3cbfb1 feat(mail): HTML lint library + Larksuite-native autofix + lark-mail … (#1019)
* feat(mail): HTML lint library + Larksuite-native autofix + lark-mail skill

为 lark-cli mail 域写信链路引入 HTML lint 能力,提升邮件 HTML 的兼容性、
安全性与 Larksuite-native 格式适配。

lint 库(shortcuts/mail/lint/):
- 四档分类:pass / native-autofix / warn-autofix / error-strip
- 安全规则覆盖 script / iframe / on* 事件处理器 / javascript: 及其它
  危险 URL scheme 等 XSS 向量,未知 scheme 一律删除并归 error
- Larksuite-native 格式自动修复:双层 div 段落、原生多级列表结构、
  灰边引用、Larksuite 蓝链接
- cleaned_html 输出确定性稳定(位置索引派生 data-ol-id),便于
  golden-file 测试与缓存

+lint-html 独立预检 shortcut:
- 只读、不调 API、不建草稿,供 AI / 用户 / CI 在写信前预览 lint 结果

写入路径内置 lint(6 个 compose shortcut):
- +send / +draft-create / +draft-edit / +reply / +reply-all / +forward
  在 emlbuilder 之前强制 lint 净化 HTML
- 默认 envelope 对 lint 改动透明(无 lint 字段),保持小巧供 AI 消费;
  --show-lint-details 显式取证返回 lint_applied[] / original_blocked[]
- --body-file 支持从文件读取 body(32MB 上限),与 --body 互斥

预制 HTML 邮件模板(skills/lark-mail/assets/templates/):
- 资讯周报 / 个人周报 / 团队周报 / 调研报告 / 求职简历 5 套
- 按 Larksuite mail-editor 原生格式编写,含正确的多级列表嵌套结构

lark-mail skill 文档:
- references/lark-mail-html.md:邮件 HTML 写法指南(24 个格式 section
  + 颜色调色盘 + URL scheme + 官方模板套用流程)
- references/lark-mail-lint-html.md:+lint-html 用法
- SKILL.md 顶部 CRITICAL 引导

* fix(mail): remove unused readAttr func and apply gofmt

Drop the unused `readAttr` helper in shortcuts/mail/lint/linter.go
that was flagged by golangci-lint (unused linter). Apply gofmt to
linter.go and rules.go which had minor formatting issues.

* fix(mail): address compose lint and guidance
2026-05-27 22:23:32 +08:00

1157 lines
39 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package lint
import (
"bytes"
"fmt"
"hash/fnv"
"strings"
xhtml "golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// MaxExcerptBytes caps the raw-HTML excerpt embedded in a Finding.Excerpt so
// a single offending tag with megabyte content can't bloat the envelope JSON.
// Lint operates on bytes only, but the excerpt representation must not be
// size-amplifying.
const MaxExcerptBytes = 200
// Run lints the given HTML body and returns a structured Report.
// Report.CleanedHTML contains the rewritten HTML (warnings rewritten + errors
// deleted) — the autofix is unconditional.
//
// IMPORTANT: when the input is empty or plain-text (no HTML markup detected
// by the cli's existing `bodyIsHTML` heuristic), callers should short-circuit
// with EmptyReport(html) instead of paying the parse cost. Run still handles
// this gracefully — html.Parse on plain text wraps the input in
// <html><head></head><body>...</body></html>, and the lib's pass-through
// rendering will reproduce the original text — but the round-trip is wasteful
// and produces no findings.
func Run(html string, opts Options) Report {
if html == "" {
return EmptyReport("")
}
rep := Report{
Applied: []Finding{},
Blocked: []Finding{},
}
// We use html.ParseFragment so users authoring fragment-style snippets
// (the canonical compose-5 input shape — `<div>...</div>` rather than a
// full document) don't get implicit <html><head><body> wrappers
// re-rendered. The "body" insertion mode matches what html.Parse would
// have done internally for a fragment but skips the structural wrappers
// at render time.
bodyContext := &xhtml.Node{Type: xhtml.ElementNode, DataAtom: atom.Body, Data: "body"}
nodes, err := xhtml.ParseFragment(strings.NewReader(html), bodyContext)
if err != nil {
// Parser failure is exceptional (the parser is permissive by design);
// fall back to the original input so we don't lose user content.
return EmptyReport(html)
}
// Wrap fragment nodes in a synthetic root so the recursive walker has a
// uniform parent pointer to mutate.
root := &xhtml.Node{Type: xhtml.DocumentNode}
for _, n := range nodes {
root.AppendChild(n)
}
walk(root, &rep)
// nativeCtx tracks per-Run() state so positional ids (e.g. data-ol-id)
// are deterministic across multiple Run() calls on the same input —
// keying off the document-traversal order rather than heap pointers,
// so cleaned_html is byte-stable and amenable to golden-file tests / CI
// diff / cache-key reuse.
nctx := &nativeCtx{olIDs: map[*xhtml.Node]string{}}
applyFeishuNativeStyles(root, &rep, nctx)
rep.HasErrorFindings = len(rep.Blocked) > 0
rep.HasWarningFindings = len(rep.Applied) > 0
rep.CleanedHTML = renderFragment(root)
return rep
}
// walk visits every element node under parent, applying tag/attr/style
// classification. Children are iterated via the next-sibling pointer because
// we mutate the tree in place (replace / remove nodes).
//
// The walker is iterative-style via explicit recursion because the html
// parser's typical nesting depth (≤ 256 by default) is well below Go's
// goroutine stack limit; the existing draft package's plainTextFromHTML
// (mail/draft/htmltext.go) similarly recurses for the same reason.
func walk(parent *xhtml.Node, rep *Report) {
child := parent.FirstChild
for child != nil {
next := child.NextSibling
if child.Type == xhtml.ElementNode {
processElement(parent, child, rep)
}
// child may have been removed/replaced by processElement; recurse
// only if it still has the original parent (i.e. wasn't deleted).
// The html parser sets Parent on every node, so a removed-then-
// reattached node still recurses correctly via its new Parent.
if child.Parent != nil {
walk(child, rep)
}
child = next
}
}
// processElement applies the element-level classification cascade:
// 1. tag → allow / warn-rewrite / error-delete
// 2. attributes → on*-handlers, URL-bearing attrs (scheme allow-list),
// style attribute (CSS property allow-list)
func processElement(parent, n *xhtml.Node, rep *Report) {
tagName := strings.ToLower(n.Data)
kind, ruleID := classifyTag(tagName)
switch kind {
case "error":
rep.Blocked = append(rep.Blocked, Finding{
RuleID: ruleID,
Severity: SeverityError,
TagOrAttr: tagName,
Excerpt: excerptOf(n),
Hint: hintForBlockedTag(tagName),
})
// Always remove blocked tags — the writing-path safety floor has no
// opt-out; `--no-lint` is not provided.
parent.RemoveChild(n)
return
case "warn":
// Always rewrite (e.g. <font>→<span style>) and surface the finding.
rep.Applied = append(rep.Applied, Finding{
RuleID: ruleID,
Severity: SeverityWarning,
TagOrAttr: tagName,
Excerpt: excerptOf(n),
Hint: hintForWarnTag(tagName),
})
rewriteWarnTag(n, tagName)
// Recurse into the rewritten node by falling through; the rewrite
// preserved children as-is.
// fall through to attribute scan
case "allow":
// no-op
}
// Attribute scan: build a new attribute slice, dropping/sanitising as we
// go and surfacing findings.
if len(n.Attr) > 0 {
processAttributes(n, rep)
}
}
// processAttributes walks the attribute list and:
// - drops on*-handlers (always; surfaced as error)
// - drops URL-bearing attrs whose value uses a forbidden scheme
// - filters the `style` attribute property-by-property against the allow-list
//
// Other attributes pass through unchanged. The cli's existing
// `validateInlineCIDs` (helpers.go:2226) handles `cid:`-specific checks;
// the lint must not duplicate that responsibility.
func processAttributes(n *xhtml.Node, rep *Report) {
keep := n.Attr[:0]
for _, attr := range n.Attr {
name := strings.ToLower(attr.Key)
// 1. on*-handlers → always drop, error-tier.
if isEventHandlerAttr(name) {
rep.Blocked = append(rep.Blocked, Finding{
RuleID: RuleAttrEventHandlerBlocked,
Severity: SeverityError,
TagOrAttr: name,
Excerpt: truncateExcerpt(attr.Key + "=\"" + attr.Val + "\""),
Hint: "Removed event handler attribute (on*)",
})
continue
}
// 2. URL-bearing attrs → check scheme allow-list.
if urlAttributes[name] {
kind, ruleID := classifyURLValue(attr.Val)
switch kind {
case "error":
rep.Blocked = append(rep.Blocked, Finding{
RuleID: ruleID,
Severity: SeverityError,
TagOrAttr: name,
Excerpt: truncateExcerpt(attr.Key + "=\"" + attr.Val + "\""),
Hint: "Removed dangerous URL scheme (allowed: http/https/mailto/cid/data:image/*)",
})
continue
case "warn":
rep.Blocked = append(rep.Blocked, Finding{
RuleID: ruleID,
Severity: SeverityError,
TagOrAttr: name,
Excerpt: truncateExcerpt(attr.Key + "=\"" + attr.Val + "\""),
Hint: "Removed URL with unrecognised scheme (allowed: http/https/mailto/cid/data:image/*)",
})
// Always drop the attribute — writing-path safety floor (the
// URL would not render correctly anyway).
continue
}
}
// 3. `style` attribute → property-by-property allow-list.
if name == "style" {
cleaned, dropped := sanitiseStyleAttr(attr.Val)
for _, prop := range dropped {
rep.Applied = append(rep.Applied, Finding{
RuleID: RuleStylePropertyDropped,
Severity: SeverityWarning,
TagOrAttr: "style." + prop,
Excerpt: truncateExcerpt(prop),
Hint: "Removed CSS property not in allowlist (see references/lark-mail-html.md)",
})
}
if len(dropped) == 0 {
// Byte-stable when no property was dropped: leave the
// attribute exactly as authored so lint round-trips are
// idempotent on clean input.
keep = append(keep, attr)
continue
}
if cleaned == "" {
// All properties dropped — remove the attribute entirely.
continue
}
attr.Val = cleaned
keep = append(keep, attr)
continue
}
// 4. Pass-through.
keep = append(keep, attr)
}
n.Attr = keep
}
// rewriteWarnTag replaces a warning-tier tag with its Feishu-native
// equivalent in place: <font> → <span style="..."> with color/face/size
// distilled into inline style; <center> → <div style="text-align:center">;
// <marquee>/<blink> → <span> (text-only, animation discarded — collapsing
// to a span keeps the children but drops the deprecated animation effect).
func rewriteWarnTag(n *xhtml.Node, tagName string) {
switch tagName {
case "font":
// Distill <font color="..." face="..." size="...">.
var styles []string
var keepAttrs []xhtml.Attribute
for _, attr := range n.Attr {
switch strings.ToLower(attr.Key) {
case "color":
if v := strings.TrimSpace(attr.Val); v != "" {
styles = append(styles, "color:"+v)
}
case "face":
if v := strings.TrimSpace(attr.Val); v != "" {
styles = append(styles, "font-family:"+v)
}
case "size":
if v := mapFontSize(attr.Val); v != "" {
styles = append(styles, "font-size:"+v)
}
default:
keepAttrs = append(keepAttrs, attr)
}
}
// Merge any existing style attribute already present on the <font>
// (rare but possible).
if len(styles) > 0 {
merged := strings.Join(styles, ";")
styleIdx := -1
for i, attr := range keepAttrs {
if strings.ToLower(attr.Key) == "style" {
styleIdx = i
break
}
}
if styleIdx >= 0 {
existing := strings.TrimRight(keepAttrs[styleIdx].Val, "; ")
if existing != "" {
merged = existing + ";" + merged
}
keepAttrs[styleIdx].Val = merged
} else {
keepAttrs = append(keepAttrs, xhtml.Attribute{Key: "style", Val: merged})
}
}
n.Data = "span"
n.DataAtom = atom.Span
n.Attr = keepAttrs
case "center":
// <center> → <div style="text-align:center">. Existing style attr
// (if any) is merged with text-align prepended.
styleIdx := -1
for i, attr := range n.Attr {
if strings.ToLower(attr.Key) == "style" {
styleIdx = i
break
}
}
newStyle := "text-align:center"
if styleIdx >= 0 {
existing := strings.TrimRight(n.Attr[styleIdx].Val, "; ")
if existing != "" {
newStyle = newStyle + ";" + existing
}
n.Attr[styleIdx].Val = newStyle
} else {
n.Attr = append(n.Attr, xhtml.Attribute{Key: "style", Val: newStyle})
}
n.Data = "div"
n.DataAtom = atom.Div
case "marquee", "blink":
// Both deprecated; collapse to <span> so children survive.
n.Data = "span"
n.DataAtom = atom.Span
// Strip marquee-specific attributes (direction, scrollamount, ...)
// so the rewritten span is plain.
var keepAttrs []xhtml.Attribute
for _, attr := range n.Attr {
if strings.ToLower(attr.Key) == "style" || strings.ToLower(attr.Key) == "class" || strings.ToLower(attr.Key) == "id" {
keepAttrs = append(keepAttrs, attr)
}
}
n.Attr = keepAttrs
}
}
// mapFontSize maps the legacy <font size="N"> values (1..7) to a CSS px
// equivalent, matching the mapping used by Feishu mail-editor's renderer.
// Out-of-range values fall through to the empty string so the property is
// dropped (better than emitting an arbitrary value).
func mapFontSize(raw string) string {
switch strings.TrimSpace(raw) {
case "1":
return "10px"
case "2":
return "13px"
case "3":
return "16px"
case "4":
return "18px"
case "5":
return "24px"
case "6":
return "32px"
case "7":
return "48px"
default:
return ""
}
}
// sanitiseStyleAttr filters a `style="prop1:val; prop2:val"` declaration
// against the property allow-list. Returns the cleaned style text (joined
// with "; " separators) and a slice of dropped property names (lower-case)
// so the caller can surface STYLE_PROPERTY_DROPPED findings.
//
// NOTE: We do NOT validate property values — only property names. The style
// attribute is filtered by CSS property allow-list; value-level validation
// (e.g. URL safety inside `background-image: url(...)`) is delegated to the
// urlAttributes path because such values typically appear in `src` / `href`
// attrs in compose-5 templates. Users authoring `background-image: url(http:...)`
// in inline style will see the property pass — the URL inside is not a
// security concern at the inline-style level since URL fetching from style
// is restricted by the rendering layer's CSP regardless.
func sanitiseStyleAttr(raw string) (cleaned string, dropped []string) {
if strings.TrimSpace(raw) == "" {
return "", nil
}
parts := strings.Split(raw, ";")
keep := make([]string, 0, len(parts))
for _, part := range parts {
decl := strings.TrimSpace(part)
if decl == "" {
continue
}
colon := strings.IndexByte(decl, ':')
if colon < 0 {
// Malformed declaration; drop and surface as a finding so the
// user notices.
dropped = append(dropped, decl)
continue
}
name := strings.ToLower(strings.TrimSpace(decl[:colon]))
if !classifyStyleProperty(name) {
dropped = append(dropped, name)
continue
}
keep = append(keep, decl)
}
cleaned = strings.Join(keep, "; ")
return cleaned, dropped
}
// hintForBlockedTag returns a hint for an error-blocked tag (matching
// the `output.ErrWithHint` convention used elsewhere in the cli).
func hintForBlockedTag(tag string) string {
switch tag {
case "script":
return "Removed whole tag (XSS risk)"
case "iframe", "object", "embed":
return "Removed whole tag (external embeds not allowed; use <img> or a body link for rich media)"
case "form", "input", "select", "option", "button":
return "Removed whole tag (forms not allowed in email body)"
case "link":
return "Removed <link> (external CSS / resources not allowed)"
case "meta":
return "Removed <meta> (viewport / refresh declarations not allowed)"
case "base":
return "Removed <base> (URL base rewrites not allowed)"
default:
return "Removed whole tag (tag not allowed)"
}
}
// hintForWarnTag returns a hint for a warning-tier tag.
func hintForWarnTag(tag string) string {
switch tag {
case "font":
return "Rewritten as <span style=\"...\"> (modern HTML expresses size / color via inline style)"
case "center":
return "Rewritten as <div style=\"text-align:center\"> (deprecated <center> tag)"
case "marquee", "blink":
return "Rewritten as <span> (animations not supported; text preserved)"
default:
return "Rewritten in modern HTML shape"
}
}
// excerptOf renders the offending node's open-tag header into a short string
// suitable for surfacing in a Finding.Excerpt. We render only the tag header
// (not the full subtree) so a single offending <script> with megabytes of
// content doesn't bloat the envelope JSON. truncateExcerpt enforces the cap.
func excerptOf(n *xhtml.Node) string {
if n == nil {
return ""
}
var buf bytes.Buffer
buf.WriteByte('<')
buf.WriteString(n.Data)
for _, attr := range n.Attr {
buf.WriteByte(' ')
buf.WriteString(attr.Key)
if attr.Val != "" {
buf.WriteString(`="`)
buf.WriteString(attr.Val)
buf.WriteByte('"')
}
}
buf.WriteString(`...>`)
return truncateExcerpt(buf.String())
}
// truncateExcerpt enforces MaxExcerptBytes; longer excerpts are truncated and
// suffixed with " ...". We measure bytes (not runes) because the cap is about
// envelope size, not character count — multibyte UTF-8 in an excerpt is
// uncommon in HTML markup excerpts.
func truncateExcerpt(s string) string {
if len(s) <= MaxExcerptBytes {
return s
}
return s[:MaxExcerptBytes-4] + " ..."
}
// renderFragment serialises a fragment-rooted html tree to a string. We use
// the html package's Render which always emits the document-style markup;
// for fragment input we strip the implicit <html><head></head><body>...</body></html>
// wrapper that html.Parse adds.
func renderFragment(root *xhtml.Node) string {
var buf bytes.Buffer
for child := root.FirstChild; child != nil; child = child.NextSibling {
_ = xhtml.Render(&buf, child)
}
return buf.String()
}
// applyFeishuNativeStyles walks the tree (post-tag-pass) and ensures elements
// that have known Feishu mail-editor native inline styles get them autofilled.
// User-supplied inline styles always take precedence — this pass is purely
// additive (only fills in missing properties). The pass always runs; there
// is no opt-out.
//
// Rules (visual parity with Feishu mail-editor's own native inline styles):
//
// <ol> / <ul> → margin-top:0;margin-bottom:0;margin-left:0;padding-left:0;list-style-position:inside
// <li> → line-height:1.6;margin-top:0px;margin-bottom:0px;padding-left:0;
// margin-left:0;display:list-item;list-style-position:inside;
// list-style-type:decimal (ol parent) | disc (ul parent);
// font-family:inherit;font-size:14px
// <blockquote> → class=lark-mail-doc-quote + padding-left:0;color:rgb(100,106,115);
// border-left:2px solid rgb(187,191,196);margin:0
// direct children wrapped in <div dir=auto style="font-size:14px;padding-left:12px">
// <a> → class="not-doclink" + cursor:pointer;text-decoration:none;
// color:rgb(20,86,240)
// <p> → rewritten to
// <div style="margin-top:4px;margin-bottom:4px;line-height:1.6">
// <div dir="auto" style="font-size:14px">...children...</div>
// </div>
// This is the only rewrite that changes tree shape; the
// others only touch attributes.
//
// nativeCtx accumulates per-Run() state shared across the recursive
// applyFeishuNativeStyles walk. The only field today is the <ol>→id map
// used to seed `data-ol-id` deterministically (see nodeShortID).
type nativeCtx struct {
olIDs map[*xhtml.Node]string
}
// olID returns the deterministic id for the given <ol> node, allocating a
// fresh positional id on first sight. Each call within the same Run() that
// targets the same node returns the same id. Different inputs may collide
// (the map resets per Run()), but `data-ol-id` is per-document so cross-
// document equality is irrelevant.
func (c *nativeCtx) olID(n *xhtml.Node) string {
if id, ok := c.olIDs[n]; ok {
return id
}
id := nodeShortID(len(c.olIDs))
c.olIDs[n] = id
return id
}
func applyFeishuNativeStyles(parent *xhtml.Node, rep *Report, nctx *nativeCtx) {
child := parent.FirstChild
for child != nil {
next := child.NextSibling
if child.Type == xhtml.ElementNode {
switch strings.ToLower(child.Data) {
case "ol", "ul":
wrapNonLIListChildren(child, rep)
ensureFeishuListStyle(child, rep)
case "li":
ensureFeishuListItemStyle(child, parent, rep, nctx)
case "blockquote":
ensureFeishuBlockquoteStyle(child, rep)
case "a":
ensureFeishuLinkStyle(child, rep)
case "p":
rewritePToFeishuDiv(child, rep)
}
}
// child may have been mutated (children moved into a wrapper);
// recurse only if it's still attached.
if child.Parent != nil {
applyFeishuNativeStyles(child, rep, nctx)
}
child = next
}
}
// wrapNonLIListChildren scans the direct children of an <ol>/<ul> and wraps
// any element that is not <li> in a fresh <li>. HTML spec is explicit that
// `<ul>` / `<ol>` may only contain `<li>` (plus optional `<script>` /
// `<template>`); browsers parse a non-<li> child by silently hoisting it out
// of the list, which destroys the intended visual nesting in mail clients.
//
// Whitespace-only text nodes are left in place (they were already going to
// be stripped by stripWhitespaceTextChildren in ensureFeishuListStyle).
// Non-element / non-whitespace text nodes are preserved as-is — the spec
// allows them inside `<li>` only, but most clients render them fine inline
// and we don't want to silently mutate user-authored content beyond the
// minimum needed to satisfy the structural rule.
func wrapNonLIListChildren(list *xhtml.Node, rep *Report) {
child := list.FirstChild
for child != nil {
next := child.NextSibling
if child.Type == xhtml.ElementNode && strings.ToLower(child.Data) != "li" {
// Wrap the offending element in a fresh <li>. The <li> inherits
// no attributes — the wrapped element keeps its own styling so
// nested-list indentation declared on the inner <ul>/<ol>
// survives. The recursive walk re-enters the new <li> later to
// stamp the native list-item style on it.
li := &xhtml.Node{
Type: xhtml.ElementNode,
Data: "li",
DataAtom: atom.Li,
}
rep.Applied = append(rep.Applied, Finding{
RuleID: RuleListDirectChildNonLI,
Severity: SeverityWarning,
TagOrAttr: child.Data,
Excerpt: excerptOf(child),
Hint: "Wrapped non-<li> child of <" + list.Data + "> in a synthetic <li> (HTML spec requires <ul>/<ol> children to be <li>)",
})
list.InsertBefore(li, child)
list.RemoveChild(child)
li.AppendChild(child)
}
child = next
}
}
// hasInlineStyleProp reports whether the given style="..." string already
// declares the named property. Lookup is case-insensitive on the property
// name; whitespace around `:` and `;` is tolerated.
//
// Shorthand expansion: `margin-*` is also considered set when shorthand
// `margin:` is present (same for `padding-*` / `padding:`, and the four
// `border-*-style/color/width` longhands when `border:` shorthand is set).
// Without this, ensureInlineStyleProps would append e.g. `margin-left:0`
// onto a `margin:0 0 0 24px` shorthand and clobber the user-authored
// 24px (mail-editor's native nested-list indent uses this exact shape).
func hasInlineStyleProp(style, prop string) bool {
prop = strings.ToLower(strings.TrimSpace(prop))
if prop == "" {
return false
}
var shorthand string
switch {
case strings.HasPrefix(prop, "margin-"):
shorthand = "margin"
case strings.HasPrefix(prop, "padding-"):
shorthand = "padding"
case strings.HasPrefix(prop, "border-"):
shorthand = "border"
}
for _, decl := range strings.Split(style, ";") {
decl = strings.TrimSpace(decl)
if decl == "" {
continue
}
colon := strings.IndexByte(decl, ':')
if colon < 0 {
continue
}
name := strings.ToLower(strings.TrimSpace(decl[:colon]))
if name == prop {
return true
}
if shorthand != "" && name == shorthand {
return true
}
}
return false
}
// ensureInlineStyleProps appends each (prop, val) pair to the element's style
// attribute *only if* the property is not already declared. Returns true if
// any property was added (so callers can record an Applied finding).
//
// User-authored values take precedence — we never overwrite existing
// declarations even if they differ from our default, because the user may
// have an intentional reason (e.g. wider list-margin for an outline-style
// document).
func ensureInlineStyleProps(n *xhtml.Node, propsInOrder [][2]string) bool {
styleIdx := -1
existing := ""
for i, attr := range n.Attr {
if strings.ToLower(attr.Key) == "style" {
styleIdx = i
existing = attr.Val
break
}
}
additions := make([]string, 0, len(propsInOrder))
for _, kv := range propsInOrder {
if !hasInlineStyleProp(existing, kv[0]) {
additions = append(additions, kv[0]+":"+kv[1])
}
}
if len(additions) == 0 {
return false
}
merged := strings.Join(additions, ";")
if existing != "" {
merged = strings.TrimRight(existing, "; ") + ";" + merged
}
if styleIdx >= 0 {
n.Attr[styleIdx].Val = merged
} else {
n.Attr = append(n.Attr, xhtml.Attribute{Key: "style", Val: merged})
}
return true
}
// stripWhitespaceTextChildren removes direct text-node children of n that
// contain only whitespace (newlines, indentation). On <ol> / <ul> these
// inter-<li> whitespace nodes are rendered by Feishu mail-editor as
// extra inline text — visible as a blank line between list items even
// when every <li> has 0 margin and the full Feishu-native marker set.
// AI-authored HTML almost always contains pretty-printed indentation
// inside lists, so this strip is essential to get gap-free rendering.
func stripWhitespaceTextChildren(n *xhtml.Node) bool {
changed := false
for child := n.FirstChild; child != nil; {
next := child.NextSibling
if child.Type == xhtml.TextNode && strings.TrimSpace(child.Data) == "" {
n.RemoveChild(child)
changed = true
}
child = next
}
return changed
}
// wrapTextChildrenInFeishuSpans wraps each direct text-node child of n in
// the canonical Feishu mail-editor inline shape:
//
// <span style="font-family:inherit"><span style="color:rgb(0,0,0)">text</span></span>
//
// This is what every text leaf produced by the Feishu mail-editor itself
// looks like (Quill-like editor model — every text leaf carries default
// font / color override spans). Without these wrapper spans the renderer
// falls back to its "untracked text" path, which inserts default-line
// spacing between siblings — visually a blank line between list items.
//
// Whitespace-only text nodes are left untouched so we don't bloat the DOM
// with wrapper spans around indentation / line breaks. Only direct text
// children are wrapped; nested element subtrees (e.g. <b>text</b>) are
// left alone (the user has already structured them; deeper traversal here
// risks breaking intentional nesting).
func wrapTextChildrenInFeishuSpans(n *xhtml.Node) bool {
changed := false
for child := n.FirstChild; child != nil; {
next := child.NextSibling
if child.Type == xhtml.TextNode && strings.TrimSpace(child.Data) != "" {
text := child.Data
outer := &xhtml.Node{
Type: xhtml.ElementNode,
Data: "span",
DataAtom: atom.Span,
Attr: []xhtml.Attribute{{Key: "style", Val: "font-family:inherit"}},
}
inner := &xhtml.Node{
Type: xhtml.ElementNode,
Data: "span",
DataAtom: atom.Span,
Attr: []xhtml.Attribute{{Key: "style", Val: "color:rgb(0,0,0)"}},
}
inner.AppendChild(&xhtml.Node{Type: xhtml.TextNode, Data: text})
outer.AppendChild(inner)
n.RemoveChild(child)
if next != nil {
n.InsertBefore(outer, next)
} else {
n.AppendChild(outer)
}
changed = true
}
child = next
}
return changed
}
// reorderAttrs reorders n.Attr so attributes named in `order` come first
// (in that exact sequence); remaining attributes are appended after in
// their original relative order. Lookup is case-insensitive on names.
//
// Feishu mail-editor's renderer is observed to walk attributes in
// declaration order on certain hot paths (notably native list-block /
// paragraph detection); inline-style declaration order matters for the
// same reason. Matching the sequence emitted by Feishu mail-editor
// itself is what flips the renderer onto the native, gap-free path.
func reorderAttrs(n *xhtml.Node, order []string) {
if len(n.Attr) == 0 {
return
}
sorted := make([]xhtml.Attribute, 0, len(n.Attr))
used := make([]bool, len(n.Attr))
// Pass 1: insert attrs whose name is in `order`, in `order` sequence.
for _, k := range order {
lk := strings.ToLower(k)
for i, attr := range n.Attr {
if used[i] {
continue
}
if strings.ToLower(attr.Key) == lk {
sorted = append(sorted, attr)
used[i] = true
break
}
}
}
// Pass 2: append remaining attrs (not in `order`) in original order.
for i, attr := range n.Attr {
if !used[i] {
sorted = append(sorted, attr)
}
}
n.Attr = sorted
}
// ensureAttr sets the given attribute to val if absent, leaving an
// existing attribute (even with a different value) untouched. Returns true
// if the attribute was newly added.
func ensureAttr(n *xhtml.Node, key, val string) bool {
for _, attr := range n.Attr {
if strings.ToLower(attr.Key) == strings.ToLower(key) {
return false
}
}
n.Attr = append(n.Attr, xhtml.Attribute{Key: key, Val: val})
return true
}
// ensureClass appends the given class name to the element's class attribute
// (creating the attribute if absent). Whitespace-separated tokens are
// preserved. Returns true if the class was newly added.
func ensureClass(n *xhtml.Node, cls string) bool {
classIdx := -1
existing := ""
for i, attr := range n.Attr {
if strings.ToLower(attr.Key) == "class" {
classIdx = i
existing = attr.Val
break
}
}
for _, c := range strings.Fields(existing) {
if c == cls {
return false
}
}
if existing == "" {
if classIdx >= 0 {
n.Attr[classIdx].Val = cls
} else {
n.Attr = append(n.Attr, xhtml.Attribute{Key: "class", Val: cls})
}
} else {
n.Attr[classIdx].Val = existing + " " + cls
}
return true
}
// ensureFeishuListStyle adds Feishu-native inline styles + the data-list-*
// marker that the renderer keys off to recognise the list as a native
// list-block (vs. a fallback ad-hoc list with default browser styling and
// the visual "blank line between items" issue).
//
// For <ol>, also seeds `start="1"` (when absent). The `data-ol-id` value
// is allocated lazily from a per-Run() positional counter held on
// nativeCtx and stamped on each <li> by ensureFeishuListItemStyle — so
// identical input HTML always produces identical cleaned_html (byte
// stable across runs).
func ensureFeishuListStyle(n *xhtml.Node, rep *Report) {
styleChanged := ensureInlineStyleProps(n, [][2]string{
{"margin-top", "0px"},
{"margin-bottom", "0px"},
{"margin-left", "0px"},
{"padding-left", "0px"},
{"list-style-position", "inside"},
})
tag := strings.ToLower(n.Data)
dataAttr := "data-list-bullet"
if tag == "ol" {
dataAttr = "data-list-number"
}
markerChanged := ensureAttr(n, dataAttr, "true")
if tag == "ol" {
if ensureAttr(n, "start", "1") {
markerChanged = true
}
// data-ol-id is intentionally NOT set on the <ol> itself — the
// mail-editor's own output puts data-ol-id only on <li>
// children. Putting it on <ol> too triggers a different
// renderer code path that reintroduces inter-item spacing.
}
// Force the canonical attribute order Feishu mail-editor emits.
// Order matters for the renderer's native-list-block fast path.
if tag == "ol" {
reorderAttrs(n, []string{"start", "style", "data-list-number"})
} else {
reorderAttrs(n, []string{"style", "data-list-bullet"})
}
// Strip inter-<li> whitespace text nodes (newlines / indentation
// from pretty-printed source HTML). Feishu mail-editor renders
// these as visible blank lines between list items.
if stripWhitespaceTextChildren(n) {
markerChanged = true
}
if styleChanged || markerChanged {
rep.Applied = append(rep.Applied, Finding{
RuleID: RuleStyleListNative,
Severity: SeverityWarning,
TagOrAttr: n.Data,
Excerpt: excerptOf(n),
Hint: "Added native-list inline style + data-list-* marker + canonical attribute order (recognised as native list-block, avoiding fallback-render blank lines)",
})
}
}
// nodeShortID generates an 8-char deterministic id from the given positional
// index. Identical input HTML produces identical ids on every Run() call,
// so the lib is fully byte-stable — golden-file tests, CI diff, downstream
// caches keyed off cleaned_html all work correctly. The hash is FNV-32a on
// the index's decimal representation; 32 bits of hash space gives ample
// margin for a single document's <ol> count.
func nodeShortID(index int) string {
h := fnv.New32a()
fmt.Fprintf(h, "%d", index)
return fmt.Sprintf("%08x", h.Sum32())
}
// ensureFeishuListItemStyle adds Feishu-native inline styles + class +
// data-* marker to <li>. The list-style-type defaults to decimal/disc
// based on the parent <ol>/<ul> kind; class follows mail-editor
// internal naming (`temp-li number1` for ol-children, `temp-li bullet1`
// for ul-children). data-li-line / data-list mark the node as part of a
// native list-block so the renderer doesn't fall back to default browser
// list styling (which has the "blank line between items" visual).
func ensureFeishuListItemStyle(n, parent *xhtml.Node, rep *Report, nctx *nativeCtx) {
listType := "disc"
listKind := "bullet1"
if parent != nil && strings.ToLower(parent.Data) == "ol" {
listType = "decimal"
listKind = "number1"
}
// CSS declaration order matches Feishu mail-editor's own output
// exactly — the renderer's list-item fast-path is observed to
// require this ordering, in addition to the marker set, for the
// gap-free native list-block render.
styleChanged := ensureInlineStyleProps(n, [][2]string{
{"line-height", "1.6"},
{"margin-top", "0px"},
{"margin-bottom", "0px"},
{"padding-left", "0px"},
{"display", "list-item"},
{"list-style-type", listType},
{"font-family", "inherit"},
{"font-size", "14px"},
{"margin-left", "0px"},
{"list-style-position", "inside"},
})
classChanged := false
if ensureClass(n, "temp-li") {
classChanged = true
}
if ensureClass(n, listKind) {
classChanged = true
}
markerChanged := false
if ensureAttr(n, "data-li-line", "true") {
markerChanged = true
}
if ensureAttr(n, "data-list", listKind) {
markerChanged = true
}
if ensureAttr(n, "dir", "auto") {
markerChanged = true
}
// For ol-children, derive data-ol-id from the parent <ol>'s positional
// index (so all siblings under the same <ol> get the same id, but the
// <ol> itself stays clean — see ensureFeishuListStyle for why). The
// nativeCtx makes this id document-deterministic across multiple
// Run() calls on the same input. data-start is the 1-based position
// among <li> siblings; it renders the visible number prefix.
if parent != nil && strings.ToLower(parent.Data) == "ol" {
if ensureAttr(n, "data-ol-id", nctx.olID(parent)) {
markerChanged = true
}
pos := 1
for c := parent.FirstChild; c != nil && c != n; c = c.NextSibling {
if c.Type == xhtml.ElementNode && strings.ToLower(c.Data) == "li" {
pos++
}
}
if ensureAttr(n, "data-start", fmt.Sprintf("%d", pos)) {
markerChanged = true
}
}
contentChanged := wrapTextChildrenInFeishuSpans(n)
// Canonical li attribute order from Feishu mail-editor output:
// class, data-li-line, data-list, data-ol-id, data-start, style, dir
reorderAttrs(n, []string{"class", "data-li-line", "data-list", "data-ol-id", "data-start", "style", "dir"})
if styleChanged || classChanged || markerChanged || contentChanged {
rep.Applied = append(rep.Applied, Finding{
RuleID: RuleStyleListItemNative,
Severity: SeverityWarning,
TagOrAttr: "li",
Excerpt: excerptOf(n),
Hint: "Added native-list-item inline style + class (temp-li " + listKind + ") + data marker + double-span text wrap + canonical attribute order",
})
}
}
// ensureFeishuBlockquoteStyle adds Feishu-native inline styles to
// <blockquote>: the iconic 2px left bar in subtle grey + an inner 12px
// content indent matching the Feishu mail editor output.
func ensureFeishuBlockquoteStyle(n *xhtml.Node, rep *Report) {
styleChanged := ensureInlineStyleProps(n, [][2]string{
{"padding-left", "0px"},
{"color", "rgb(100,106,115)"},
{"border-left", "2px solid rgb(187,191,196)"},
{"margin", "0px"},
})
classChanged := ensureClass(n, "lark-mail-doc-quote")
contentChanged := ensureBlockquoteContentWrapper(n)
if styleChanged || classChanged || contentChanged {
rep.Applied = append(rep.Applied, Finding{
RuleID: RuleStyleBlockquoteNative,
Severity: SeverityWarning,
TagOrAttr: "blockquote",
Excerpt: excerptOf(n),
Hint: "Added blockquote native style (2px left grey bar + 12px content indent)",
})
}
}
// ensureBlockquoteContentWrapper wraps direct blockquote children in the same
// content div shape emitted by the Feishu mail editor. Existing native-shaped
// blockquotes are left unchanged.
func ensureBlockquoteContentWrapper(n *xhtml.Node) bool {
first := firstNonWhitespaceChild(n)
if first == nil {
return false
}
if first.Type == xhtml.ElementNode && strings.EqualFold(first.Data, "div") && hasAttrValue(first, "dir", "auto") && hasInlineStyleProp(getStyleAttr(first), "padding-left") {
return false
}
var children []*xhtml.Node
for c := n.FirstChild; c != nil; {
next := c.NextSibling
n.RemoveChild(c)
children = append(children, c)
c = next
}
inner := &xhtml.Node{
Type: xhtml.ElementNode,
Data: "div",
DataAtom: atom.Div,
Attr: []xhtml.Attribute{
{Key: "dir", Val: "auto"},
{Key: "style", Val: "font-size:14px;padding-left:12px"},
},
}
for _, c := range children {
inner.AppendChild(c)
}
n.AppendChild(inner)
return true
}
func firstNonWhitespaceChild(n *xhtml.Node) *xhtml.Node {
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type == xhtml.TextNode && strings.TrimSpace(c.Data) == "" {
continue
}
return c
}
return nil
}
func hasAttrValue(n *xhtml.Node, key, val string) bool {
for _, attr := range n.Attr {
if strings.EqualFold(attr.Key, key) && attr.Val == val {
return true
}
}
return false
}
func getStyleAttr(n *xhtml.Node) string {
for _, attr := range n.Attr {
if strings.EqualFold(attr.Key, "style") {
return attr.Val
}
}
return ""
}
// ensureFeishuLinkStyle adds Feishu-native class + inline styles to <a>:
// `class="not-doclink"` is the marker the Feishu mail renderer keys off to
// avoid treating the link as an internal doc-share, and the inline style
// matches the editor's own colour and decoration.
func ensureFeishuLinkStyle(n *xhtml.Node, rep *Report) {
classChanged := ensureClass(n, "not-doclink")
styleChanged := ensureInlineStyleProps(n, [][2]string{
{"cursor", "pointer"},
{"text-decoration", "none"},
{"color", "rgb(20,86,240)"},
})
if classChanged || styleChanged {
rep.Applied = append(rep.Applied, Finding{
RuleID: RuleStyleLinkNative,
Severity: SeverityWarning,
TagOrAttr: "a",
Excerpt: excerptOf(n),
Hint: "Added link style (class=not-doclink + brand blue + no underline)",
})
}
}
// rewritePToFeishuDiv changes <p>...</p> in place to
//
// <div style="margin-top:4px;margin-bottom:4px;line-height:1.6">
// <div dir="auto" style="font-size:14px">...children...</div>
// </div>
//
// User-supplied attributes on the original <p> are preserved on the outer
// div. The inner div uses native defaults only when the author did not
// already provide equivalent paragraph direction / font size.
//
// This is the only Feishu-native pass that mutates the tree shape (vs. just
// adding inline styles). Children are re-parented to the inner div so all
// their existing styles / classes survive untouched.
func rewritePToFeishuDiv(n *xhtml.Node, rep *Report) {
// Detach existing children (kept in order).
var children []*xhtml.Node
for c := n.FirstChild; c != nil; {
nx := c.NextSibling
n.RemoveChild(c)
children = append(children, c)
c = nx
}
// Mutate <p> → <div>.
n.Data = "div"
n.DataAtom = atom.Div
// Add outer wrapper inline styles (only fills missing properties).
ensureInlineStyleProps(n, [][2]string{
{"margin-top", "4px"},
{"margin-bottom", "4px"},
{"line-height", "1.6"},
})
// Build inner <div>. Native defaults are additive: an author-supplied
// dir or font-size on the original <p> wins via the outer div.
innerDir := attrValueOrDefault(n, "dir", "auto")
innerAttrs := []xhtml.Attribute{{Key: "dir", Val: innerDir}}
if !hasInlineStyleProp(getStyleAttr(n), "font-size") {
innerAttrs = append(innerAttrs, xhtml.Attribute{Key: "style", Val: "font-size:14px"})
}
inner := &xhtml.Node{
Type: xhtml.ElementNode,
Data: "div",
DataAtom: atom.Div,
Attr: innerAttrs,
}
for _, c := range children {
inner.AppendChild(c)
}
n.AppendChild(inner)
rep.Applied = append(rep.Applied, Finding{
RuleID: RuleStyleParaWrapper,
Severity: SeverityWarning,
TagOrAttr: "p",
Excerpt: excerptOf(n),
Hint: "Rewritten as double-wrapped div paragraph (outer margin/line-height + additive inner dir/font-size)",
})
}
func attrValueOrDefault(n *xhtml.Node, key, fallback string) string {
for _, attr := range n.Attr {
if strings.EqualFold(attr.Key, key) && strings.TrimSpace(attr.Val) != "" {
return attr.Val
}
}
return fallback
}