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", "
before
no code tagafter
", + "--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
\nmissing code
",
+ wantWarn: 1,
+ },
+ {
+ name: "multiple pre blocks, all missing code",
+ content: "first
\nsecond
",
+ wantWarn: 2,
+ },
+ {
+ name: "multiple pre blocks, all have code",
+ content: "a
\nb
",
+ 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)
+ }
+ })
+ }
+}