diff --git a/shortcuts/doc/docs_create_test.go b/shortcuts/doc/docs_create_test.go index a701aa6a8a..58f21de155 100644 --- a/shortcuts/doc/docs_create_test.go +++ b/shortcuts/doc/docs_create_test.go @@ -308,6 +308,62 @@ func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) { } } +func TestDocsCreateV2WarnsOnPreWithoutCode(t *testing.T) { + t.Parallel() + + f, stdout, stderr, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + registerDocsCreateAPIStub(reg, map[string]interface{}{ + "document": map[string]interface{}{ + "document_id": "doxcn_new_doc", + "revision_id": float64(1), + "url": "https://example.feishu.cn/docx/doxcn_new_doc", + }, + }) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--api-version", "v2", + "--content", "test

before

no code tag

after

", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + stderrStr := stderr.String() + if !strings.Contains(stderrStr, "missing a child element") { + t.Fatalf("expected stderr warning about missing , got:\n%s", stderrStr) + } +} + +func TestDocsCreateV2NoWarningWhenPreHasCode(t *testing.T) { + t.Parallel() + + f, stdout, stderr, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + registerDocsCreateAPIStub(reg, map[string]interface{}{ + "document": map[string]interface{}{ + "document_id": "doxcn_new_doc", + "revision_id": float64(1), + "url": "https://example.feishu.cn/docx/doxcn_new_doc", + }, + }) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--api-version", "v2", + "--content", "test
func main() {}
", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + stderrStr := stderr.String() + if strings.Contains(stderrStr, "missing a child element") { + t.Fatalf("did not expect warning for valid pre/code, got:\n%s", stderrStr) + } +} + // ── Helpers ── func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig { diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go index 70d07ddcbd..ca2e016d74 100644 --- a/shortcuts/doc/docs_create_v2.go +++ b/shortcuts/doc/docs_create_v2.go @@ -30,6 +30,14 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error { if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" { return common.FlagErrorf("--parent-token and --parent-position are mutually exclusive") } + + // Warn about
 blocks missing  children (silently dropped by API).
+	if runtime.Str("doc-format") == "xml" && runtime.Factory != nil {
+		if warnings := validatePreTags(runtime.Str("content")); len(warnings) > 0 {
+			emitPreWarnings(runtime.IO().ErrOut, warnings)
+		}
+	}
+
 	return nil
 }
 
diff --git a/shortcuts/doc/docs_update_test.go b/shortcuts/doc/docs_update_test.go
index dd8561880e..d852b72c8a 100644
--- a/shortcuts/doc/docs_update_test.go
+++ b/shortcuts/doc/docs_update_test.go
@@ -3,10 +3,15 @@
 package doc
 
 import (
+	"bytes"
 	"context"
+	"encoding/json"
 	"strings"
 	"testing"
 
+	"github.com/larksuite/cli/internal/cmdutil"
+	"github.com/larksuite/cli/internal/core"
+	"github.com/larksuite/cli/internal/httpmock"
 	"github.com/larksuite/cli/shortcuts/common"
 	"github.com/spf13/cobra"
 )
@@ -133,3 +138,148 @@ func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[
 	}
 	return common.TestNewRuntimeContext(cmd, nil)
 }
+
+// ── Integration tests (full pipeline with mocked HTTP) ──
+
+func TestDocsUpdateV2WarnsOnPreWithoutCode(t *testing.T) {
+	t.Parallel()
+
+	f, stdout, stderr, reg := cmdutil.TestFactory(t, docsUpdateTestConfig(t))
+	registerDocsUpdateAPIStub(reg, map[string]interface{}{
+		"document": map[string]interface{}{
+			"revision_id": float64(2),
+			"url":         "https://example.feishu.cn/docx/doxcnUpdateDryRun",
+		},
+		"result": "success",
+	})
+
+	err := runDocsUpdateShortcut(t, f, stdout, []string{
+		"+update",
+		"--api-version", "v2",
+		"--doc", "doxcnUpdateDryRun",
+		"--command", "block_insert_after",
+		"--block-id", "doxcnAnchor",
+		"--content", "
no code tag
", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + stderrStr := stderr.String() + if !strings.Contains(stderrStr, "missing a child element") { + t.Fatalf("expected stderr warning about missing , got:\n%s", stderrStr) + } +} + +func TestDocsUpdateV2BlockInsertAfterHint(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, docsUpdateTestConfig(t)) + registerDocsUpdateAPIStub(reg, map[string]interface{}{ + "document": map[string]interface{}{ + "revision_id": float64(2), + "url": "https://example.feishu.cn/docx/doxcnUpdateDryRun", + }, + "result": "success", + }) + + err := runDocsUpdateShortcut(t, f, stdout, []string{ + "+update", + "--api-version", "v2", + "--doc", "doxcnUpdateDryRun", + "--command", "block_insert_after", + "--block-id", "doxcnAnchor", + "--content", "

hello

", + "--as", "user", + }) + 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("failed to decode output: %v\nraw=%s", err, stdout.String()) + } + data, _ := envelope["data"].(map[string]interface{}) + if data == nil { + t.Fatalf("missing data in output envelope: %#v", envelope) + } + hint, _ := data["_hint"].(string) + if hint == "" { + t.Fatalf("expected _hint in block_insert_after response, got: %#v", data) + } + if !strings.Contains(hint, "docs +fetch") { + t.Fatalf("_hint should mention docs +fetch, got: %s", hint) + } +} + +func TestDocsUpdateV2AppendHint(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, docsUpdateTestConfig(t)) + registerDocsUpdateAPIStub(reg, map[string]interface{}{ + "document": map[string]interface{}{ + "revision_id": float64(2), + "url": "https://example.feishu.cn/docx/doxcnUpdateDryRun", + }, + "result": "success", + }) + + err := runDocsUpdateShortcut(t, f, stdout, []string{ + "+update", + "--api-version", "v2", + "--doc", "doxcnUpdateDryRun", + "--command", "append", + "--content", "

hello

", + "--as", "user", + }) + 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("failed to decode output: %v\nraw=%s", err, stdout.String()) + } + data, _ := envelope["data"].(map[string]interface{}) + if data == nil { + t.Fatalf("missing data in output envelope: %#v", envelope) + } + hint, _ := data["_hint"].(string) + if hint == "" { + t.Fatalf("expected _hint in append response, got: %#v", data) + } +} + +// ── Helpers ── + +func docsUpdateTestConfig(t *testing.T) *core.CliConfig { + t.Helper() + + replacer := strings.NewReplacer("/", "-", " ", "-") + suffix := replacer.Replace(strings.ToLower(t.Name())) + return &core.CliConfig{ + AppID: "test-docs-update-" + suffix, + AppSecret: "secret-docs-update-" + suffix, + Brand: core.BrandFeishu, + } +} + +func registerDocsUpdateAPIStub(reg *httpmock.Registry, data map[string]interface{}) { + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/docs_ai/v1/documents/doxcnUpdateDryRun", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": data, + }, + }) +} + +func runDocsUpdateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error { + t.Helper() + + return mountAndRunDocs(t, DocsUpdate, args, f, stdout) +} diff --git a/shortcuts/doc/docs_update_v2.go b/shortcuts/doc/docs_update_v2.go index bd18e9492a..78c42e6b69 100644 --- a/shortcuts/doc/docs_update_v2.go +++ b/shortcuts/doc/docs_update_v2.go @@ -106,6 +106,14 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { return common.FlagErrorf("--command append requires --content") } } + + // Warn about
 blocks missing  children (silently dropped by API).
+	if runtime.Str("doc-format") == "xml" && content != "" && runtime.Factory != nil {
+		if warnings := validatePreTags(content); len(warnings) > 0 {
+			emitPreWarnings(runtime.IO().ErrOut, warnings)
+		}
+	}
+
 	return nil
 }
 
@@ -132,6 +140,17 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
 		return err
 	}
 
+	// For block_insert_after and block_copy_insert_after, hint that users
+	// should fetch the document to discover newly created block IDs.
+	cmd := runtime.Str("command")
+	if (cmd == "block_insert_after" || cmd == "block_copy_insert_after" || cmd == "append") &&
+		common.GetString(data, "result") == "success" {
+		data["_hint"] = fmt.Sprintf(
+			"To discover newly inserted block IDs, fetch the document with: lark-cli docs +fetch --api-version v2 --doc %s --detail with-ids",
+			ref.Token,
+		)
+	}
+
 	runtime.OutRaw(data, nil)
 	return nil
 }
diff --git a/shortcuts/doc/xml_validate.go b/shortcuts/doc/xml_validate.go
new file mode 100644
index 0000000000..266546d0e9
--- /dev/null
+++ b/shortcuts/doc/xml_validate.go
@@ -0,0 +1,49 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package doc
+
+import (
+	"fmt"
+	"io"
+	"regexp"
+)
+
+// preTagRegex matches 
...
blocks including those spanning multiple lines. +var preTagRegex = regexp.MustCompile(`(?s)]*>(.*?)
`) + +// codeTagRegex matches a or opening tag. +var codeTagRegex = regexp.MustCompile(` blocks that lack a child +// element. The Lark Docs API silently drops such blocks, so we warn the user. +// Returns a list of warning messages, one per offending
 block.
+func validatePreTags(content string) []string {
+	if content == "" {
+		return nil
+	}
+
+	matches := preTagRegex.FindAllStringSubmatch(content, -1)
+	if len(matches) == 0 {
+		return nil
+	}
+
+	var warnings []string
+	for i, m := range matches {
+		inner := m[1]
+		if !codeTagRegex.MatchString(inner) {
+			warnings = append(warnings, fmt.Sprintf(
+				"
 block #%d is missing a  child element; the Lark Docs API will silently drop this block. Wrap the content in ... inside the 
 tag.",
+				i+1,
+			))
+		}
+	}
+	return warnings
+}
+
+// emitPreWarnings writes pre-tag validation warnings to the given writer (typically stderr).
+func emitPreWarnings(w io.Writer, warnings []string) {
+	for _, wm := range warnings {
+		fmt.Fprintf(w, "warning: %s\n", wm)
+	}
+}
diff --git a/shortcuts/doc/xml_validate_test.go b/shortcuts/doc/xml_validate_test.go
new file mode 100644
index 0000000000..ce638cf48f
--- /dev/null
+++ b/shortcuts/doc/xml_validate_test.go
@@ -0,0 +1,87 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package doc
+
+import (
+	"testing"
+)
+
+func TestValidatePreTags(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		content  string
+		wantWarn int
+	}{
+		{
+			name:     "empty content",
+			content:  "",
+			wantWarn: 0,
+		},
+		{
+			name:     "no pre tags",
+			content:  "test

hello

", + wantWarn: 0, + }, + { + name: "pre with code child", + content: `
fmt.Println("hello")
`, + wantWarn: 0, + }, + { + name: "pre with code child and attributes", + content: `
func main() {}
`, + wantWarn: 0, + }, + { + name: "pre without code child", + content: "
line1\nline2\nline3
", + wantWarn: 1, + }, + { + name: "pre without code child, self-closing pre", + content: "
",
+			wantWarn: 0,
+		},
+		{
+			name:     "multiple pre blocks, one missing code",
+			content:  "
ok
\n
missing code
", + wantWarn: 1, + }, + { + name: "multiple pre blocks, all missing code", + content: "
first
\n
second
", + wantWarn: 2, + }, + { + name: "multiple pre blocks, all have code", + content: "
a
\n
b
", + wantWarn: 0, + }, + { + name: "pre with multiline content missing code", + content: "
\n
...content...
\n
", + wantWarn: 0, + }, + { + name: "pre with code tag in attributes only", + content: "
example\">plain text
", + wantWarn: 1, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + warnings := validatePreTags(tt.content) + if len(warnings) != tt.wantWarn { + t.Fatalf("validatePreTags() returned %d warnings, want %d\nwarnings: %v\ncontent: %s", + len(warnings), tt.wantWarn, warnings, tt.content) + } + }) + } +}