// 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
//
..., 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 — `
...
` rather than a
// full document) don't get implicit 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. →) 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: → with color/face/size
// distilled into inline style;