diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go
index ffa487456..96a9ed3a2 100644
--- a/shortcuts/doc/docs_create_v2.go
+++ b/shortcuts/doc/docs_create_v2.go
@@ -18,6 +18,7 @@ func v2CreateFlags() []common.Flag {
return []common.Flag{
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as
... so the title wins over later content titles"},
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
+ {Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"},
{Name: "parent-position", Desc: "parent position such as my_library; mutually exclusive with --parent-token"},
@@ -32,8 +33,8 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
if runtime.Changed("title") && title == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title must not be empty").WithParam("--title")
}
- if runtime.Str("content") == "" && title == "" {
- return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
+ if err := validateDocsV2ReferenceMapFlags(runtime); err != nil {
+ return err
}
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive").WithParams(
@@ -41,11 +42,21 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
errs.InvalidParam{Name: "--parent-position", Reason: "mutually exclusive with --parent-token"},
)
}
+ if runtime.Str("content") == "" && title == "" {
+ return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
+ }
+ if runtime.Str("content") != "" {
+ _, err := resolveDocsV2ContentReferenceMap(runtime)
+ return err
+ }
return nil
}
func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
- body := buildCreateBody(runtime)
+ body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
+ if err != nil {
+ return common.NewDryRunAPI().Set("error", err.Error())
+ }
desc := "OpenAPI: create document"
if runtime.IsBot() {
desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document."
@@ -57,7 +68,10 @@ func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.D
}
func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
- body := buildCreateBody(runtime)
+ body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
+ if err != nil {
+ return err
+ }
data, err := doDocAPI(runtime, "POST", "/open-apis/docs_ai/v1/documents", body)
if err != nil {
@@ -86,7 +100,10 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
}
func buildCreateContent(runtime *common.RuntimeContext) string {
- content := runtime.Str("content")
+ return buildCreateContentWithBody(runtime, runtime.Str("content"))
+}
+
+func buildCreateContentWithBody(runtime *common.RuntimeContext, content string) string {
title := strings.TrimSpace(runtime.Str("title"))
if title == "" {
return content
diff --git a/shortcuts/doc/docs_fetch_v2.go b/shortcuts/doc/docs_fetch_v2.go
index 936b193d4..51d96b225 100644
--- a/shortcuts/doc/docs_fetch_v2.go
+++ b/shortcuts/doc/docs_fetch_v2.go
@@ -14,7 +14,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
-const docsFetchExtraParam = `{"enable_user_cite_reference_map":true}`
+const docsFetchExtraParam = `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}`
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
func v2FetchFlags() []common.Flag {
@@ -71,6 +71,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
if err != nil {
return err
}
+ if err := processHTML5BlockReferenceMapForFetch(runtime, effectiveFetchFormat(runtime), ref.Token, data); err != nil {
+ return err
+ }
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
}
diff --git a/shortcuts/doc/docs_fetch_v2_test.go b/shortcuts/doc/docs_fetch_v2_test.go
index 2ba7c0b0c..933ea2ffc 100644
--- a/shortcuts/doc/docs_fetch_v2_test.go
+++ b/shortcuts/doc/docs_fetch_v2_test.go
@@ -505,14 +505,14 @@ func TestBuildFetchBodyIncludesFetchExtraParamByDefault(t *testing.T) {
if got["enable_user_cite_reference_map"] != true {
t.Fatalf("enable_user_cite_reference_map = %#v, want true in %#v", got["enable_user_cite_reference_map"], got)
}
- if _, ok := got["return_html5_block_data"]; ok {
- t.Fatalf("extra_param should not request html5 block data: %#v", got)
+ if got["return_html5_block_data"] != true {
+ t.Fatalf("return_html5_block_data = %#v, want true in %#v", got["return_html5_block_data"], got)
}
if _, ok := got["reference_map_mode"]; ok {
t.Fatalf("extra_param should not use legacy reference_map_mode: %#v", got)
}
- if len(got) != 1 {
- t.Fatalf("extra_param should only contain fetch reference_map toggle: %#v", got)
+ if len(got) != 2 {
+ t.Fatalf("extra_param should only contain fetch reference_map and html5 data toggles: %#v", got)
}
}
@@ -579,6 +579,34 @@ func TestDocsFetchIMMarkdownRequestsMarkdownFromAPI(t *testing.T) {
}
}
+func TestDocsFetchIMMarkdownIgnoresHTML5BlockInsideCodeFence(t *testing.T) {
+ t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-im-markdown-code-fence"))
+ registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcnFetchIMMarkdownFence/fetch", map[string]interface{}{
+ "document": map[string]interface{}{
+ "document_id": "doxcnFetchIMMarkdownFence",
+ "revision_id": float64(1),
+ "content": "```xml\n\n```\n",
+ },
+ })
+
+ err := mountAndRunDocs(t, DocsFetch, []string{
+ "+fetch",
+ "--doc", "doxcnFetchIMMarkdownFence",
+ "--doc-format", "im-markdown",
+ "--format", "json",
+ "--as", "bot",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ var envelope map[string]interface{}
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
+ }
+}
+
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
t.Parallel()
diff --git a/shortcuts/doc/docs_update_test.go b/shortcuts/doc/docs_update_test.go
index 6d8dcfd47..4d67ca321 100644
--- a/shortcuts/doc/docs_update_test.go
+++ b/shortcuts/doc/docs_update_test.go
@@ -4,6 +4,7 @@ package doc
import (
"context"
+ "encoding/json"
"errors"
"strings"
"testing"
@@ -63,6 +64,28 @@ func TestDocsUpdateDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
}
}
+func TestDocsUpdateDryRunReportsReferenceMapBuildError(t *testing.T) {
+ t.Parallel()
+
+ runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
+ "content": ``,
+ })
+ raw, err := json.Marshal(dryRunUpdateV2(context.Background(), runtime))
+ if err != nil {
+ t.Fatalf("marshal dry-run output: %v", err)
+ }
+ var dry map[string]interface{}
+ if err := json.Unmarshal(raw, &dry); err != nil {
+ t.Fatalf("decode dry-run output: %v\n%s", err, raw)
+ }
+ if got := common.GetString(dry, "error"); !strings.Contains(got, `html5-block path "missing.html" cannot be read`) {
+ t.Fatalf("dry-run error = %q, want reference_map build error; raw=%s", got, raw)
+ }
+ if api, _ := dry["api"].([]interface{}); len(api) > 0 {
+ t.Fatalf("dry-run should not fall back to a request body after build error: %s", raw)
+ }
+}
+
func TestDocsUpdateV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
t.Parallel()
diff --git a/shortcuts/doc/docs_update_v2.go b/shortcuts/doc/docs_update_v2.go
index d9c12b47b..f610168c8 100644
--- a/shortcuts/doc/docs_update_v2.go
+++ b/shortcuts/doc/docs_update_v2.go
@@ -24,7 +24,9 @@ var validCommandsV2 = map[string]bool{
"append": true,
}
-const docsUpdateReferenceMapFlagDesc = "结构化 `reference_map` JSON object;当 `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。"
+const docsReferenceMapFlagDesc = "结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;`--reference-map` 主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。"
+
+const docsUpdateReferenceMapFlagDesc = docsReferenceMapFlagDesc
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
func v2UpdateFlags() []common.Flag {
@@ -115,13 +117,20 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command append requires --content").WithParam("--content")
}
}
+ if content != "" {
+ _, err := resolveDocsV2ContentReferenceMap(runtime)
+ return err
+ }
return nil
}
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
ref, _ := parseDocumentRef(runtime.Str("doc"))
- body, _ := buildUpdateBodyWithReferenceMap(runtime)
+ body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
+ if err != nil {
+ return common.NewDryRunAPI().Set("error", err.Error())
+ }
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
return common.NewDryRunAPI().
PUT(apiPath).
@@ -134,7 +143,7 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocumentRef(runtime.Str("doc"))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
- body, err := buildUpdateBodyWithReferenceMap(runtime)
+ body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
if err != nil {
return err
}
diff --git a/shortcuts/doc/html5_block_resources.go b/shortcuts/doc/html5_block_resources.go
new file mode 100644
index 000000000..80955ba58
--- /dev/null
+++ b/shortcuts/doc/html5_block_resources.go
@@ -0,0 +1,696 @@
+// 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
+)
+
+var (
+ html5BlockStartTagPattern = regexp.MustCompile(`(?is)]*>`)
+ html5BlockElementPattern = regexp.MustCompile(`(?is)]*>(.*?)`)
+ html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
+)
+
+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
+}
+
+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, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.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, " 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 (t html5BlockStartTag) 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 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("")
+ b.WriteString(html5BlockTag)
+ 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()
+}
diff --git a/shortcuts/doc/html5_block_resources_test.go b/shortcuts/doc/html5_block_resources_test.go
new file mode 100644
index 000000000..70b747a3c
--- /dev/null
+++ b/shortcuts/doc/html5_block_resources_test.go
@@ -0,0 +1,563 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package doc
+
+import (
+ "bytes"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/larksuite/cli/internal/cmdutil"
+ "github.com/larksuite/cli/internal/httpmock"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
+ for name, flags := range map[string][]common.Flag{
+ "create": v2CreateFlags(),
+ "update": v2UpdateFlags(),
+ } {
+ t.Run(name, func(t *testing.T) {
+ flag := findDocsTestFlag(flags, "reference-map")
+ if flag.Name == "" {
+ t.Fatal("reference-map flag not found")
+ }
+ if flag.Hidden {
+ t.Fatal("reference-map flag should be public")
+ }
+ if !hasDocsTestInput(flag, common.File) || !hasDocsTestInput(flag, common.Stdin) {
+ t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input)
+ }
+ if !strings.Contains(flag.Desc, "@reference-map.json") {
+ t.Fatalf("reference-map help should mention @file support, got %q", flag.Desc)
+ }
+ })
+ }
+}
+
+func TestDocsV2InputFlagIsNotAvailable(t *testing.T) {
+ for name, flags := range map[string][]common.Flag{
+ "create": v2CreateFlags(),
+ "update": v2UpdateFlags(),
+ } {
+ t.Run(name, func(t *testing.T) {
+ for _, flag := range flags {
+ if flag.Name == "input" {
+ t.Fatalf("%s should not expose input flag", name)
+ }
+ }
+ })
+ }
+}
+
+func TestDocsUpdateV2ReferenceMapPreservesGenericGroups(t *testing.T) {
+ t.Parallel()
+
+ runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
+ "command": "append",
+ "content": `
`,
+ "reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
+ })
+ body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
+ if err != nil {
+ t.Fatalf("buildUpdateBodyWithHTML5ReferenceMap: %v", err)
+ }
+
+ refMap, ok := body["reference_map"].(map[string]interface{})
+ if !ok {
+ t.Fatalf("reference_map = %#v, want object", body["reference_map"])
+ }
+ widget, _ := refMap["widget"].(map[string]interface{})
+ r1, _ := widget["r1"].(map[string]interface{})
+ if got := r1["label"]; got != "widget-ref-value" {
+ t.Fatalf("reference_map.widget.r1.label = %#v, want widget-ref-value; body=%#v", got, body)
+ }
+}
+
+func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
+ dir := t.TempDir()
+ cmdutil.TestChdir(t, dir)
+ if err := os.WriteFile("widget.html", []byte("hello"), 0o600); err != nil {
+ t.Fatalf("WriteFile() error: %v", err)
+ }
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
+ stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
+ "document": map[string]interface{}{
+ "document_id": "doxcn_new_doc",
+ "revision_id": float64(1),
+ },
+ })
+
+ err := runDocsCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--api-version", "v2",
+ "--content", `demo`,
+ "--as", "user",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ body := decodeRequestBody(t, stub.CapturedBody)
+ if got := body["content"].(string); !strings.Contains(got, ``) {
+ t.Fatalf("content was not rewritten with data-ref: %s", got)
+ }
+ refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
+ if got := refMap[html5BlockTag]["html5_1"].Data; got != "hello" {
+ t.Fatalf("reference_map html data = %q", got)
+ }
+ if _, ok := body["resources"]; ok {
+ t.Fatalf("request body must not use resources: %#v", body)
+ }
+}
+
+func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
+ for _, flag := range flags {
+ if flag.Name == name {
+ return flag
+ }
+ }
+ return common.Flag{}
+}
+
+func hasDocsTestInput(flag common.Flag, input string) bool {
+ for _, item := range flag.Input {
+ if item == input {
+ return true
+ }
+ }
+ return false
+}
+
+func TestDocsUpdateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
+ dir := t.TempDir()
+ cmdutil.TestChdir(t, dir)
+ if err := os.WriteFile("widget.html", []byte(""), 0o600); err != nil {
+ t.Fatalf("WriteFile() error: %v", err)
+ }
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-update"))
+ stub := registerDocsAIStub(reg, "PUT", "/open-apis/docs_ai/v1/documents/doxcn_doc", map[string]interface{}{
+ "document": map[string]interface{}{
+ "revision_id": float64(2),
+ "new_blocks": []interface{}{
+ map[string]interface{}{
+ "block_type": "html5-block",
+ "block_id": "blk_html5",
+ "block_token": "blk_html5",
+ },
+ },
+ },
+ "result": "success",
+ })
+
+ err := mountAndRunDocs(t, DocsUpdate, []string{
+ "+update",
+ "--api-version", "v2",
+ "--doc", "doxcn_doc",
+ "--command", "append",
+ "--content", ``,
+ "--as", "user",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ body := decodeRequestBody(t, stub.CapturedBody)
+ if got := body["content"].(string); got != `` {
+ t.Fatalf("content = %q", got)
+ }
+ refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
+ if got := refMap[html5BlockTag]["html5_1"].Data; got != "" {
+ t.Fatalf("reference_map html data = %q", got)
+ }
+
+ var envelope map[string]interface{}
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
+ }
+ data, _ := envelope["data"].(map[string]interface{})
+ doc, _ := data["document"].(map[string]interface{})
+ if blocks, _ := doc["new_blocks"].([]interface{}); len(blocks) != 1 {
+ t.Fatalf("new_blocks not preserved in stdout: %#v", doc)
+ }
+}
+
+func TestDocsFetchV2HTML5BlockKeepsSmallReferenceMapInline(t *testing.T) {
+ dir := t.TempDir()
+ cmdutil.TestChdir(t, dir)
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch"))
+ registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
+ "document": map[string]interface{}{
+ "document_id": "doxcn_fetch",
+ "revision_id": float64(3),
+ "content": ``,
+ "reference_map": map[string]interface{}{
+ "html5-block": map[string]interface{}{
+ "html5_1": map[string]interface{}{"data": "fetched"},
+ },
+ },
+ },
+ "tips": "must_read_html_code",
+ })
+
+ err := mountAndRunDocs(t, DocsFetch, []string{
+ "+fetch",
+ "--api-version", "v2",
+ "--doc", "doxcn_fetch",
+ "--format", "json",
+ "--as", "user",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html")
+ if _, err := os.Stat(written); err == nil {
+ t.Fatalf("small html should stay inline, got file %s", written)
+ }
+
+ var envelope map[string]interface{}
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
+ }
+ data, _ := envelope["data"].(map[string]interface{})
+ doc, _ := data["document"].(map[string]interface{})
+ if got := doc["content"].(string); !strings.Contains(got, ``) {
+ t.Fatalf("content should keep data-ref: %s", got)
+ }
+ refMap := decodeHTML5ReferenceMap(t, doc["reference_map"])
+ if got := refMap[html5BlockTag]["html5_1"].Data; got != "fetched" {
+ t.Fatalf("reference_map html data = %q", got)
+ }
+ if _, ok := doc["resources"]; ok {
+ t.Fatalf("fetch output must not use resources: %#v", doc)
+ }
+ if _, ok := data["suggestions"]; ok {
+ t.Fatalf("CLI must not add suggestions; service tips is enough: %#v", data["suggestions"])
+ }
+ if got := data["tips"]; got != "must_read_html_code" {
+ t.Fatalf("tips should be preserved from service response, got %#v", got)
+ }
+}
+
+func TestDocsFetchV2HTML5BlockLargeReferenceMapUsesPath(t *testing.T) {
+ dir := t.TempDir()
+ cmdutil.TestChdir(t, dir)
+
+ largeHTML := "" + strings.Repeat("x", html5BlockReferenceMaxRaw+1) + ""
+ f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-large"))
+ registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
+ "document": map[string]interface{}{
+ "document_id": "doxcn_fetch",
+ "revision_id": float64(3),
+ "content": ``,
+ "reference_map": map[string]interface{}{
+ "html5-block": map[string]interface{}{
+ "html5_1": map[string]interface{}{"data": largeHTML},
+ },
+ },
+ },
+ })
+
+ err := mountAndRunDocs(t, DocsFetch, []string{
+ "+fetch",
+ "--api-version", "v2",
+ "--doc", "doxcn_fetch",
+ "--format", "json",
+ "--as", "user",
+ }, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html")
+ raw, err := os.ReadFile(written)
+ if err != nil {
+ t.Fatalf("ReadFile(%s) error: %v", written, err)
+ }
+ if string(raw) != largeHTML {
+ t.Fatalf("materialized html = %q", raw)
+ }
+
+ var envelope map[string]interface{}
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
+ }
+ data, _ := envelope["data"].(map[string]interface{})
+ doc, _ := data["document"].(map[string]interface{})
+ if got := doc["content"].(string); strings.Contains(got, `path="@`) || !strings.Contains(got, `data-ref="html5_1"`) {
+ t.Fatalf("content should keep data-ref and not path: %s", got)
+ }
+ refMap := decodeHTML5ReferenceMap(t, doc["reference_map"])
+ entry := refMap[html5BlockTag]["html5_1"]
+ if entry.Data != "" || entry.Path != "@doc-fetch-resources/doxcn_fetch/html5_1.html" {
+ t.Fatalf("large html should be represented as path, got %#v", entry)
+ }
+}
+
+func TestDocsCreateV2HTML5BlockReferenceMapAdvancedInput(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
+ stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
+ "document": map[string]interface{}{
+ "document_id": "doxcn_new_doc",
+ "revision_id": float64(1),
+ },
+ })
+
+ err := runDocsCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--api-version", "v2",
+ "--content", ``,
+ "--reference-map", `{"html5-block":{"html5_1":{"data":""}}}`,
+ "--as", "user",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ body := decodeRequestBody(t, stub.CapturedBody)
+ if got := body["content"].(string); got != `` {
+ t.Fatalf("content = %q", got)
+ }
+ refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
+ if got := refMap[html5BlockTag]["html5_1"].Data; got != "" {
+ t.Fatalf("reference_map html data = %q", got)
+ }
+}
+
+func TestDocsCreateV2HTML5BlockReferenceMapFromFile(t *testing.T) {
+ dir := t.TempDir()
+ cmdutil.TestChdir(t, dir)
+ if err := os.WriteFile("reference-map.json", []byte(`{"html5-block":{"html5_1":{"data":"from file"}}}`), 0o600); err != nil {
+ t.Fatalf("WriteFile(reference-map.json) error: %v", err)
+ }
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
+ stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
+ "document": map[string]interface{}{
+ "document_id": "doxcn_new_doc",
+ "revision_id": float64(1),
+ },
+ })
+
+ err := runDocsCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--api-version", "v2",
+ "--content", ``,
+ "--reference-map", "@reference-map.json",
+ "--as", "user",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ body := decodeRequestBody(t, stub.CapturedBody)
+ refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
+ if got := refMap[html5BlockTag]["html5_1"].Data; got != "from file" {
+ t.Fatalf("reference_map html data = %q", got)
+ }
+}
+
+func TestDocsCreateV2HTML5BlockRejectsMissingReferenceMap(t *testing.T) {
+ f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
+
+ err := runDocsCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--api-version", "v2",
+ "--content", ``,
+ "--as", "user",
+ })
+ if err == nil || !strings.Contains(err.Error(), `reference_map.html5-block.html5_1 is required`) {
+ t.Fatalf("expected missing reference_map error, got: %v", err)
+ }
+}
+
+func TestDocsCreateV2HTML5BlockRejectsInternalDataAttr(t *testing.T) {
+ f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
+
+ err := runDocsCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--api-version", "v2",
+ "--content", ``,
+ "--as", "user",
+ })
+ if err == nil || !strings.Contains(err.Error(), `html5-block data is reserved for SDK internals`) {
+ t.Fatalf("expected internal data attr error, got: %v", err)
+ }
+}
+
+func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) {
+ dir := t.TempDir()
+ cmdutil.TestChdir(t, dir)
+ f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
+
+ err := runDocsCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--api-version", "v2",
+ "--content", ``,
+ "--as", "user",
+ })
+ if err == nil || !strings.Contains(err.Error(), `html5-block path "missing.html" cannot be read from the current working directory`) {
+ t.Fatalf("expected path read error, got: %v", err)
+ }
+}
+
+func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
+ dir := t.TempDir()
+ cmdutil.TestChdir(t, dir)
+ if err := os.WriteFile("widget.html", []byte(""), 0o600); err != nil {
+ t.Fatalf("WriteFile() error: %v", err)
+ }
+
+ f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
+ err := runDocsCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--api-version", "v2",
+ "--content", ``,
+ "--as", "user",
+ })
+ if err == nil || !strings.Contains(err.Error(), `html5-block content must be loaded from path="@relative.html"`) {
+ t.Fatalf("expected inline content error, got: %v", err)
+ }
+}
+
+func TestDocsFetchV2MissingHTML5BlockReferenceFails(t *testing.T) {
+ dir := t.TempDir()
+ cmdutil.TestChdir(t, dir)
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-missing"))
+ registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
+ "document": map[string]interface{}{
+ "document_id": "doxcn_fetch",
+ "revision_id": float64(3),
+ "content": ``,
+ "reference_map": map[string]interface{}{
+ "html5-block": map[string]interface{}{
+ "html5_1": map[string]interface{}{"data": ""},
+ },
+ },
+ },
+ })
+
+ err := mountAndRunDocs(t, DocsFetch, []string{
+ "+fetch",
+ "--api-version", "v2",
+ "--doc", "doxcn_fetch",
+ "--format", "json",
+ "--as", "user",
+ }, f, stdout)
+ if err == nil || !strings.Contains(err.Error(), "Re-run fetch or check that the upstream document.reference_map field includes this ref") {
+ t.Fatalf("expected missing reference_map error, got: %v", err)
+ }
+}
+
+func TestHTML5BlockMarkdownCodeFenceIsIgnored(t *testing.T) {
+ for _, fence := range []string{"```", "~~~"} {
+ t.Run(fence, func(t *testing.T) {
+ content := fence + "xml\n\n" + fence + "\n"
+ if hasProcessableHTML5Block("markdown", content) {
+ t.Fatalf("html5-block inside markdown code fence should be ignored")
+ }
+ })
+ }
+}
+
+func TestWriteHTML5BlockReferenceFileRejectsDotNames(t *testing.T) {
+ runtime := newFetchShortcutTestRuntime(t, "", nil)
+ tests := []struct {
+ name string
+ docToken string
+ ref string
+ want string
+ }{
+ {name: "dot doc token", docToken: ".", ref: "html5_1", want: "document_id"},
+ {name: "dotdot doc token", docToken: "..", ref: "html5_1", want: "document_id"},
+ {name: "dot ref", docToken: "doxcn_fetch", ref: ".", want: "data-ref"},
+ {name: "dotdot ref", docToken: "doxcn_fetch", ref: "..", want: "data-ref"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := writeHTML5BlockReferenceFile(runtime, tt.docToken, tt.ref, "")
+ if err == nil || !strings.Contains(err.Error(), tt.want) {
+ t.Fatalf("writeHTML5BlockReferenceFile() error = %v, want %q", err, tt.want)
+ }
+ })
+ }
+}
+
+func TestPrepareHTML5BlockWriteContentMarkdownRaw(t *testing.T) {
+ dir := t.TempDir()
+ cmdutil.TestChdir(t, dir)
+ if err := os.WriteFile("widget.html", []byte("markdown"), 0o600); err != nil {
+ t.Fatalf("WriteFile() error: %v", err)
+ }
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
+ stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
+ "document": map[string]interface{}{
+ "document_id": "doxcn_new_doc",
+ "revision_id": float64(1),
+ },
+ })
+
+ err := runDocsCreateShortcut(t, f, stdout, []string{
+ "+create",
+ "--api-version", "v2",
+ "--doc-format", "markdown",
+ "--content", "before\n\nafter",
+ "--as", "user",
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ body := decodeRequestBody(t, stub.CapturedBody)
+ if got := body["content"].(string); !strings.Contains(got, ``) {
+ t.Fatalf("content was not rewritten: %s", got)
+ }
+ refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
+ if got := refMap[html5BlockTag]["html5_1"].Data; got != "markdown" {
+ t.Fatalf("reference_map html data = %q", got)
+ }
+}
+
+func registerDocsAIStub(reg *httpmock.Registry, method string, url string, data map[string]interface{}) *httpmock.Stub {
+ stub := &httpmock.Stub{
+ Method: method,
+ URL: url,
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": data,
+ },
+ }
+ reg.Register(stub)
+ return stub
+}
+
+func decodeRequestBody(t *testing.T, raw []byte) map[string]interface{} {
+ t.Helper()
+ var body map[string]interface{}
+ if err := json.Unmarshal(bytes.TrimSpace(raw), &body); err != nil {
+ t.Fatalf("decode request body: %v\n%s", err, raw)
+ }
+ return body
+}
+
+func decodeHTML5ReferenceMap(t *testing.T, raw interface{}) html5BlockReferenceMap {
+ t.Helper()
+ data, err := json.Marshal(raw)
+ if err != nil {
+ t.Fatalf("marshal reference_map: %v\n%#v", err, raw)
+ }
+ var refMap html5BlockReferenceMap
+ if err := json.Unmarshal(data, &refMap); err != nil {
+ t.Fatalf("decode reference_map: %v\n%s", err, data)
+ }
+ return refMap
+}
diff --git a/skills/lark-doc/references/lark-doc-create.md b/skills/lark-doc/references/lark-doc-create.md
index 17fa6bc2a..ce2860136 100644
--- a/skills/lark-doc/references/lark-doc-create.md
+++ b/skills/lark-doc/references/lark-doc-create.md
@@ -60,6 +60,7 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
| ------------------- | -- |---------------------------------------------|
| `--title` | 否 | 文档标题,Markdown 导入时使用;XML 创建推荐在 `--content` 开头写 `...`;多个标题仅保留第一个并在 `warnings` / `degrade_details` 提示 |
| `--content` | 视情况 | 文档内容(XML 或 Markdown 格式);不传 `--content` 时必须传 `--title` |
+| `--reference-map` | 否 | 结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 |
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
| `--parent-token` | 否 | 父文件夹或知识库节点 token(与 `--parent-position` 互斥) |
| `--parent-position` | 否 | 父节点位置,如 `my_library`(与 `--parent-token` 互斥) |
diff --git a/skills/lark-doc/references/lark-doc-update.md b/skills/lark-doc/references/lark-doc-update.md
index 918408fa6..43beb7f56 100644
--- a/skills/lark-doc/references/lark-doc-update.md
+++ b/skills/lark-doc/references/lark-doc-update.md
@@ -24,7 +24,7 @@
| `--command` | 是 | 操作指令(见下方指令速查表) |
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
| `--content` | 视指令 | 写入内容(`str_replace` 传空字符串可实现删除) |
-| `--reference-map` | 否 | 结构化 `reference_map` JSON object;当 `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。 |
+| `--reference-map` | 否 | 结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 |
| `--pattern` | 视指令 | 匹配文本(str_replace) |
| `--block-id` | 视指令 | 目标 block ID(block_* 操作),逗号分隔可批量删除,-1 表示末尾 |
| `--src-block-ids` | 视指令 | 源 block ID(逗号分隔),用于 block_copy_insert_after / block_move_after |
diff --git a/tests/cli_e2e/docs/docs_update_dryrun_test.go b/tests/cli_e2e/docs/docs_update_dryrun_test.go
index 7f47490e0..23dbd1db7 100644
--- a/tests/cli_e2e/docs/docs_update_dryrun_test.go
+++ b/tests/cli_e2e/docs/docs_update_dryrun_test.go
@@ -57,7 +57,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch",
- wantExtraParam: `{"enable_user_cite_reference_map":true}`,
+ wantExtraParam: `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}`,
},
{
name: "update",