// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package doc import ( "bytes" "encoding/json" "encoding/xml" "errors" "fmt" "io" "path/filepath" "regexp" "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/shortcuts/common" ) const ( html5BlockTag = "html5-block" html5BlockPathAttr = "path" html5BlockDataRefAttr = "data-ref" html5BlockDataAttr = "data" html5BlockReferenceRoot = "doc-fetch-resources" html5BlockReferenceMaxRaw = 1024 whiteboardTag = "whiteboard" whiteboardTypeAttr = "type" whiteboardPathAttr = "path" ) var ( html5BlockStartTagPattern = regexp.MustCompile(`(?is)]*>`) html5BlockElementPattern = regexp.MustCompile(`(?is)]*>(.*?)`) html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) whiteboardElementPattern = regexp.MustCompile(`(?is)]*(?:/>|>.*?)`) ) type html5BlockReferenceEntry struct { Data string `json:"data,omitempty"` Path string `json:"path,omitempty"` UserID string `json:"user_id,omitempty"` } type html5BlockReferenceMap map[string]map[string]html5BlockReferenceEntry type docsV2WriteInput struct { Content string ReferenceMap map[string]interface{} } type html5BlockAttr struct { Name string Value string } type html5BlockStartTag struct { Attrs []html5BlockAttr SelfClosing bool } type whiteboardStartTag struct { Attrs []html5BlockAttr SelfClosing bool } func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) { body := buildCreateBody(runtime) if runtime.Str("content") == "" && !runtime.Changed("reference-map") { return body, nil } input, err := resolveDocsV2ContentReferenceMap(runtime) if err != nil { return nil, err } body["content"] = buildCreateContentWithBody(runtime, input.Content) if len(input.ReferenceMap) > 0 { body["reference_map"] = input.ReferenceMap } return body, nil } func buildUpdateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) { body := buildUpdateBody(runtime) input, err := resolveDocsV2ContentReferenceMap(runtime) if err != nil { return nil, err } if input.Content != "" { body["content"] = input.Content } if len(input.ReferenceMap) > 0 { body["reference_map"] = input.ReferenceMap } return body, nil } func validateDocsV2ReferenceMapFlags(runtime *common.RuntimeContext) error { if runtime.Changed("reference-map") && runtime.Str("content") == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map requires --content").WithParam("--reference-map") } return nil } func resolveDocsV2ContentReferenceMap(runtime *common.RuntimeContext) (docsV2WriteInput, error) { input := docsV2WriteInput{Content: runtime.Str("content")} if raw := runtime.Str("reference-map"); strings.TrimSpace(raw) != "" { refMap, err := parseReferenceMapObject(raw, "--reference-map") if err != nil { return docsV2WriteInput{}, err } input.ReferenceMap = refMap } return prepareDocsV2WriteInput(runtime, input) } func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteInput) (docsV2WriteInput, error) { refMap := cloneReferenceMapObject(input.ReferenceMap) html5RefMap, err := html5ReferenceMapFromObject(refMap) if err != nil { return docsV2WriteInput{}, err } content, err := prepareWhiteboardWriteContent(runtime, runtime.Str("doc-format"), input.Content) if err != nil { return docsV2WriteInput{}, err } content, html5RefMap, err = prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), content, html5RefMap) if err != nil { return docsV2WriteInput{}, err } if err := resolveReferenceMapPaths(runtime, html5RefMap); err != nil { return docsV2WriteInput{}, err } refMap = mergeHTML5ReferenceMap(refMap, html5RefMap) return docsV2WriteInput{ Content: content, ReferenceMap: refMap, }, nil } func parseReferenceMapObject(raw string, label string) (map[string]interface{}, error) { if len(bytes.TrimSpace([]byte(raw))) == 0 || string(bytes.TrimSpace([]byte(raw))) == "null" { return nil, nil } var refMap map[string]interface{} if err := json.Unmarshal([]byte(raw), &refMap); err != nil { return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam(label).WithCause(err) } return refMap, nil } func parseHTML5BlockReferenceMapBytes(raw []byte, label string) (html5BlockReferenceMap, error) { if len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" { return nil, nil } var refMap html5BlockReferenceMap if err := json.Unmarshal(raw, &refMap); err != nil { return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam(label).WithCause(err) } return compactReferenceMap(refMap), nil } func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string, content string, refMap html5BlockReferenceMap) (string, html5BlockReferenceMap, error) { if !strings.Contains(content, " 0 { return "", aggregateWhiteboardRewriteErrors(rewriteErrs) } return out, nil } func rewriteWhiteboardFileRefs(runtime *common.RuntimeContext, content string) (string, error) { var rewriteErrs []error out := whiteboardElementPattern.ReplaceAllStringFunc(content, func(raw string) string { rewritten, err := rewriteWhiteboardFileRef(runtime, raw) if err != nil { rewriteErrs = append(rewriteErrs, err) return raw } return rewritten }) if len(rewriteErrs) > 0 { return "", aggregateWhiteboardRewriteErrors(rewriteErrs) } return out, nil } func rewriteWhiteboardFileRef(runtime *common.RuntimeContext, raw string) (string, error) { startRaw, body, _, ok := splitWhiteboardElement(raw) if !ok { return raw, nil } tag, err := parseWhiteboardStartTag(startRaw) if err != nil { return "", common.ValidationErrorf("invalid whiteboard tag: %v", err).WithParam("whiteboard") } pathValue, hasPath := tag.attr(whiteboardPathAttr) bodyPath, hasBodyPath := whiteboardBodyPathRef(body) if !hasPath && !hasBodyPath { return raw, nil } if hasPath && strings.TrimSpace(body) != "" { return "", common.ValidationErrorf("whiteboard cannot contain both path and inline content").WithParam("whiteboard") } if hasPath && hasBodyPath { return "", common.ValidationErrorf("whiteboard cannot contain both path and @file body").WithParam("whiteboard") } typRaw, ok := tag.attr(whiteboardTypeAttr) if !ok || strings.TrimSpace(typRaw) == "" { return "", common.ValidationErrorf("whiteboard file input requires type=\"svg\", type=\"mermaid\", or type=\"plantuml\"").WithParam("type") } typ, ok := canonicalWhiteboardFileType(typRaw) if !ok { return "", common.ValidationErrorf("whiteboard file input only supports type=\"svg\", type=\"mermaid\", or type=\"plantuml\", got %q", typRaw).WithParam("type") } if hasBodyPath { pathValue = bodyPath } data, err := readWhiteboardPath(runtime, pathValue, typ) if err != nil { return "", err } tag.setAttr(whiteboardTypeAttr, typ) tag.removeAttrs(whiteboardPathAttr) return tag.render(false) + whiteboardContentForType(typ, data) + "", nil } func splitWhiteboardElement(raw string) (startTag string, body string, selfClosing bool, ok bool) { trimmed := strings.TrimSpace(raw) selfClosing = strings.HasSuffix(trimmed, "/>") if selfClosing { return raw, "", true, true } startEnd := strings.Index(raw, ">") if startEnd < 0 { return "", "", false, false } endStart := strings.LastIndex(strings.ToLower(raw), "") if endStart < 0 || endStart < startEnd { return "", "", false, false } return raw[:startEnd+1], raw[startEnd+1 : endStart], false, true } func whiteboardBodyPathRef(body string) (string, bool) { trimmed := strings.TrimSpace(body) if !strings.HasPrefix(trimmed, "@") || strings.HasPrefix(trimmed, "@@") { return "", false } if strings.ContainsAny(trimmed, "\r\n") { return "", false } return trimmed, true } func canonicalWhiteboardFileType(raw string) (string, bool) { switch strings.ToLower(strings.TrimSpace(raw)) { case "svg": return "svg", true case "mermaid": return "mermaid", true case "plantuml": return "plantuml", true default: return "", false } } func readWhiteboardPath(runtime *common.RuntimeContext, pathValue string, typ string) (string, error) { pathRaw := strings.TrimSpace(pathValue) if !strings.HasPrefix(pathRaw, "@") { return "", common.ValidationErrorf("whiteboard %s path %q must start with @, for example @diagram.%s", typ, pathValue, exampleWhiteboardExt(typ)).WithParam("path") } relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@")) if relPath == "" { return "", common.ValidationErrorf("whiteboard %s path cannot be empty after @", typ).WithParam("path") } clean := filepath.Clean(relPath) if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { return "", common.ValidationErrorf("whiteboard %s path %q must be a relative path within the current working directory", typ, pathValue).WithParam("path") } if !whiteboardExtAllowed(typ, strings.ToLower(filepath.Ext(clean))) { return "", common.ValidationErrorf("whiteboard %s path %q must point to a %s file", typ, pathValue, whiteboardExtList(typ)).WithParam("path") } data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean) if err != nil { return "", common.ValidationErrorf("whiteboard %s path %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", typ, clean, err). WithParam("path"). WithParams(errs.InvalidParam{Name: clean, Reason: fmt.Sprintf("whiteboard %s path cannot be read", typ)}). WithCause(err) } return string(data), nil } func whiteboardExtAllowed(typ string, ext string) bool { for _, allowed := range whiteboardAllowedExts(typ) { if ext == allowed { return true } } return false } func whiteboardAllowedExts(typ string) []string { switch typ { case "svg": return []string{".svg"} case "mermaid": return []string{".mermaid", ".mmd"} case "plantuml": return []string{".plantuml", ".puml", ".pu", ".uml"} default: return nil } } func whiteboardExtList(typ string) string { return strings.Join(whiteboardAllowedExts(typ), ", ") } func exampleWhiteboardExt(typ string) string { exts := whiteboardAllowedExts(typ) if len(exts) == 0 { return "txt" } return strings.TrimPrefix(exts[0], ".") } func whiteboardContentForType(typ string, data string) string { if typ == "svg" { return data } return escapeXMLText(data) } func aggregateWhiteboardRewriteErrors(rewriteErrs []error) error { flatErrs := flattenWhiteboardRewriteErrors(rewriteErrs) messages := make([]string, 0, len(flatErrs)) params := make([]errs.InvalidParam, 0, len(flatErrs)) for _, err := range flatErrs { messages = append(messages, err.Error()) params = append(params, whiteboardInvalidParamsFromError(err)...) } validationErr := common.ValidationErrorf("whiteboard file input failed: %s", strings.Join(messages, "; ")). WithParam("whiteboard"). WithCause(errors.Join(flatErrs...)) if len(params) > 0 { validationErr.WithParams(params...) } return validationErr } func flattenWhiteboardRewriteErrors(rewriteErrs []error) []error { flatErrs := make([]error, 0, len(rewriteErrs)) for _, err := range rewriteErrs { var validationErr *errs.ValidationError if errors.As(err, &validationErr) && validationErr.Param == "whiteboard" && validationErr.Cause != nil { if joined, ok := validationErr.Cause.(interface{ Unwrap() []error }); ok { flatErrs = append(flatErrs, flattenWhiteboardRewriteErrors(joined.Unwrap())...) continue } } flatErrs = append(flatErrs, err) } return flatErrs } func whiteboardInvalidParamsFromError(err error) []errs.InvalidParam { var validationErr *errs.ValidationError if !errors.As(err, &validationErr) { return nil } if len(validationErr.Params) > 0 { return validationErr.Params } if validationErr.Param != "" { return []errs.InvalidParam{{Name: validationErr.Param, Reason: validationErr.Message}} } return nil } func validateHTML5BlockWriteElementBodies(format string, content string) error { validateSegment := func(segment string) error { matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1) for _, match := range matches { if len(match) < 4 || match[2] < 0 || match[3] < 0 { continue } if strings.TrimSpace(segment[match[2]:match[3]]) != "" { return common.ValidationErrorf("html5-block content must be loaded from path=\"@relative.html\" or reference_map; remove content between and ").WithParam("html5-block") } } return nil } if strings.TrimSpace(format) != "markdown" { return validateSegment(content) } var validateErr error _ = applyOutsideCodeFences(content, func(segment string) string { if validateErr != nil { return segment } validateErr = validateSegment(segment) return segment }) return validateErr } func processHTML5BlockReferenceMapForFetch(runtime *common.RuntimeContext, format string, docToken string, data map[string]interface{}) error { doc, _ := data["document"].(map[string]interface{}) if doc == nil { return nil } content, _ := doc["content"].(string) if !hasProcessableHTML5Block(format, content) { return nil } refMap, err := referenceMapFromDocument(doc) if err != nil { return err } group := refMap[html5BlockTag] if group == nil { return common.ValidationErrorf("document.reference_map.%s is required for fetched html5-block content", html5BlockTag).WithParam("reference_map") } if err := validateFetchedHTML5BlockRefs(format, content, refMap); err != nil { return err } changed := false for ref, entry := range group { if entry.Data == "" || len([]byte(entry.Data)) <= html5BlockReferenceMaxRaw { continue } relPath, err := writeHTML5BlockReferenceFile(runtime, docToken, ref, entry.Data) if err != nil { return err } entry.Data = "" entry.Path = "@" + filepath.ToSlash(relPath) group[ref] = entry changed = true } if changed { doc["reference_map"] = refMap } return nil } func referenceMapFromDocument(doc map[string]interface{}) (html5BlockReferenceMap, error) { raw, ok := doc["reference_map"] if !ok || raw == nil { return nil, common.ValidationErrorf("document.reference_map is required for fetched html5-block content").WithParam("reference_map") } refMap, err := referenceMapFromValue(raw, "document.reference_map") if err != nil { return nil, err } if len(refMap) == 0 { return nil, common.ValidationErrorf("document.reference_map is required for fetched html5-block content").WithParam("reference_map") } return refMap, nil } func referenceMapFromValue(value interface{}, label string) (html5BlockReferenceMap, error) { if typed, ok := value.(html5BlockReferenceMap); ok { return compactReferenceMap(typed), nil } raw, err := json.Marshal(value) if err != nil { return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam("reference_map").WithCause(err) } return parseHTML5BlockReferenceMapBytes(raw, label) } func validateFetchedHTML5BlockRefs(format string, content string, refMap html5BlockReferenceMap) error { validateSegment := func(segment string) error { _, err := rewriteHTML5BlockStartTags(segment, func(raw string) (string, error) { tag, parseErr := parseHTML5BlockStartTag(raw) if parseErr != nil { return raw, common.ValidationErrorf("invalid html5-block tag in fetched content: %v", parseErr).WithParam("html5-block") } ref, ok := tag.attr(html5BlockDataRefAttr) if !ok || strings.TrimSpace(ref) == "" { return raw, common.ValidationErrorf("fetched html5-block is missing data-ref; cannot resolve HTML reference").WithParam("html5-block") } ref = strings.TrimSpace(ref) if _, ok := refMap[html5BlockTag][ref]; !ok { return raw, common.ValidationErrorf("document.reference_map.%s.%s is missing; cannot resolve html5-block. Re-run fetch or check that the upstream document.reference_map field includes this ref.", html5BlockTag, ref).WithParam("reference_map") } return raw, nil }) return err } if strings.TrimSpace(format) != "markdown" { return validateSegment(content) } var validateErr error _ = applyOutsideCodeFences(content, func(segment string) string { if validateErr != nil { return segment } validateErr = validateSegment(segment) return segment }) return validateErr } func resolveReferenceMapPaths(runtime *common.RuntimeContext, refMap html5BlockReferenceMap) error { for typ, group := range refMap { for ref, entry := range group { if strings.TrimSpace(entry.Path) == "" { continue } if entry.Data != "" { return common.ValidationErrorf("reference_map.%s.%s must use either data or path, not both", typ, ref).WithParam("reference_map") } data, err := readHTML5BlockPath(runtime, entry.Path, fmt.Sprintf("reference_map.%s.%s.path", typ, ref)) if err != nil { return err } entry.Data = data entry.Path = "" group[ref] = entry } } return nil } func readHTML5BlockPath(runtime *common.RuntimeContext, pathValue string, label string) (string, error) { pathRaw := strings.TrimSpace(pathValue) if !strings.HasPrefix(pathRaw, "@") { return "", common.ValidationErrorf("%s %q must start with @, for example @widget.html", label, pathValue).WithParam("path") } relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@")) if relPath == "" { return "", common.ValidationErrorf("%s cannot be empty after @", label).WithParam("path") } clean := filepath.Clean(relPath) if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { return "", common.ValidationErrorf("%s %q must be a relative path within the current working directory", label, pathValue).WithParam("path") } if strings.ToLower(filepath.Ext(clean)) != ".html" { return "", common.ValidationErrorf("%s %q must point to a .html file", label, pathValue).WithParam("path") } data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean) if err != nil { return "", common.ValidationErrorf("%s %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", label, clean, err).WithParam("path").WithCause(err) } return string(data), nil } func hasProcessableHTML5Block(format string, content string) bool { if !strings.Contains(content, "") decoder := xml.NewDecoder(strings.NewReader(raw)) for { tok, err := decoder.Token() if err != nil { if errors.Is(err, io.EOF) { break } return html5BlockStartTag{}, err } start, ok := tok.(xml.StartElement) if !ok { continue } if start.Name.Local != html5BlockTag { return html5BlockStartTag{}, fmt.Errorf("expected <%s>, got <%s>", html5BlockTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors. } attrs := make([]html5BlockAttr, 0, len(start.Attr)) for _, attr := range start.Attr { attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value}) } return html5BlockStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil } return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors. } func parseWhiteboardStartTag(raw string) (whiteboardStartTag, error) { trimmed := strings.TrimSpace(raw) selfClosing := strings.HasSuffix(trimmed, "/>") decoder := xml.NewDecoder(strings.NewReader(raw)) for { tok, err := decoder.Token() if err != nil { if errors.Is(err, io.EOF) { break } return whiteboardStartTag{}, err } start, ok := tok.(xml.StartElement) if !ok { continue } if start.Name.Local != whiteboardTag { return whiteboardStartTag{}, fmt.Errorf("expected <%s>, got <%s>", whiteboardTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors. } attrs := make([]html5BlockAttr, 0, len(start.Attr)) for _, attr := range start.Attr { attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value}) } return whiteboardStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil } return whiteboardStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors. } func (t html5BlockStartTag) attr(name string) (string, bool) { for _, attr := range t.Attrs { if attr.Name == name { return attr.Value, true } } return "", false } func (t whiteboardStartTag) attr(name string) (string, bool) { for _, attr := range t.Attrs { if attr.Name == name { return attr.Value, true } } return "", false } func (t html5BlockStartTag) hasAttr(name string) bool { _, ok := t.attr(name) return ok } func (t *html5BlockStartTag) removeAttrs(names ...string) { remove := make(map[string]struct{}, len(names)) for _, name := range names { remove[name] = struct{}{} } attrs := t.Attrs[:0] for _, attr := range t.Attrs { if _, ok := remove[attr.Name]; ok { continue } attrs = append(attrs, attr) } t.Attrs = attrs } func (t *whiteboardStartTag) removeAttrs(names ...string) { remove := make(map[string]struct{}, len(names)) for _, name := range names { remove[name] = struct{}{} } attrs := t.Attrs[:0] for _, attr := range t.Attrs { if _, ok := remove[attr.Name]; ok { continue } attrs = append(attrs, attr) } t.Attrs = attrs } func (t *whiteboardStartTag) setAttr(name string, value string) { for i, attr := range t.Attrs { if attr.Name == name { t.Attrs[i].Value = value return } } t.Attrs = append(t.Attrs, html5BlockAttr{Name: name, Value: value}) } func (t html5BlockStartTag) render(selfClosing bool) string { var b strings.Builder b.WriteByte('<') b.WriteString(html5BlockTag) for _, attr := range t.Attrs { b.WriteByte(' ') b.WriteString(attr.Name) b.WriteString(`="`) b.WriteString(escapeXMLAttr(attr.Value)) b.WriteByte('"') } if selfClosing { b.WriteString("/>") } else { b.WriteByte('>') } if t.SelfClosing && !selfClosing { b.WriteString("') } return b.String() } func (t whiteboardStartTag) render(selfClosing bool) string { var b strings.Builder b.WriteByte('<') b.WriteString(whiteboardTag) for _, attr := range t.Attrs { b.WriteByte(' ') b.WriteString(attr.Name) b.WriteString(`="`) b.WriteString(escapeXMLAttr(attr.Value)) b.WriteByte('"') } if selfClosing { b.WriteString("/>") } else { b.WriteByte('>') } return b.String() } func escapeXMLAttr(value string) string { var b strings.Builder for _, r := range value { switch r { case '&': b.WriteString("&") case '<': b.WriteString("<") case '>': b.WriteString(">") case '"': b.WriteString(""") case '\'': b.WriteString("'") default: b.WriteRune(r) } } return b.String() } func escapeXMLText(value string) string { var b strings.Builder for _, r := range value { switch r { case '&': b.WriteString("&") case '<': b.WriteString("<") default: b.WriteRune(r) } } return b.String() }