From 73fd00e88d3e866b4133fe1221325ca03d0ff0a5 Mon Sep 17 00:00:00 2001 From: "sunpeiyang.996" Date: Tue, 30 Jun 2026 18:04:44 +0800 Subject: [PATCH] chore:lark-cli docs support reference_map --- shortcuts/doc/docs_create_v2.go | 27 +- shortcuts/doc/docs_fetch_v2.go | 5 +- shortcuts/doc/docs_fetch_v2_test.go | 8 +- shortcuts/doc/docs_update_v2.go | 11 +- shortcuts/doc/html5_block_resources.go | 691 ++++++++++++++++++ shortcuts/doc/html5_block_resources_test.go | 536 ++++++++++++++ skills/lark-doc/references/lark-doc-create.md | 1 + tests/cli_e2e/docs/docs_update_dryrun_test.go | 2 +- 8 files changed, 1268 insertions(+), 13 deletions(-) create mode 100644 shortcuts/doc/html5_block_resources.go create mode 100644 shortcuts/doc/html5_block_resources_test.go diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go index ffa487456..e8202f7d3 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: "结构化 `reference_map` JSON object;当 `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。", 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 { + body = buildCreateBody(runtime) + } 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..db4c5658b 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, runtime.Str("doc-format"), 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..46ecb2db6 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) } } diff --git a/shortcuts/doc/docs_update_v2.go b/shortcuts/doc/docs_update_v2.go index d9c12b47b..ad9893d1f 100644 --- a/shortcuts/doc/docs_update_v2.go +++ b/shortcuts/doc/docs_update_v2.go @@ -115,13 +115,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 { + body = buildUpdateBody(runtime) + } apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token) return common.NewDryRunAPI(). PUT(apiPath). @@ -134,7 +141,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..ae9cfebf2 --- /dev/null +++ b/shortcuts/doc/html5_block_resources.go @@ -0,0 +1,691 @@ +// 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("') + } + 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..fbe45236d --- /dev/null +++ b/shortcuts/doc/html5_block_resources_test.go @@ -0,0 +1,536 @@ +// 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("
updated
"), 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 != "
updated
" { + 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("
from file
"), 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", `
inline
`, + "--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) { + content := "```xml\n\n```\n" + if hasProcessableHTML5Block("markdown", content) { + t.Fatalf("html5-block inside markdown code fence should be ignored") + } +} + +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..fb401fba5 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` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。 | | `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) | | `--parent-token` | 否 | 父文件夹或知识库节点 token(与 `--parent-position` 互斥) | | `--parent-position` | 否 | 父节点位置,如 `my_library`(与 `--parent-token` 互斥) | 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",