diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go index 413c4cbff..5a42b3709 100644 --- a/shortcuts/doc/docs_create_v2.go +++ b/shortcuts/doc/docs_create_v2.go @@ -27,6 +27,11 @@ 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") } + if runtime.Str("doc-format") == "markdown" { + if msg := CheckV2MarkdownCustomTags(runtime.Str("content")); msg != "" { + return common.FlagErrorf("%s", msg) + } + } return nil } diff --git a/shortcuts/doc/docs_update_check.go b/shortcuts/doc/docs_update_check.go index cf71c1012..9d41a5b0b 100644 --- a/shortcuts/doc/docs_update_check.go +++ b/shortcuts/doc/docs_update_check.go @@ -34,6 +34,11 @@ import ( // _**text**_ *__text__* // Lark stores only one of the two emphases (usually italic), silently // dropping the other. The user wanted both; they will get one. +// +// 3. Whole-paragraph style markers are not rendered by Lark. A line that +// consists entirely of *…* or **…** (no other content) is treated as a +// literal string rather than an italic or bold paragraph. The markers +// appear verbatim in the document instead of applying style. func docsUpdateWarnings(mode, markdown string) []string { var warnings []string if w := checkDocsUpdateReplaceMultilineMarkdown(mode, markdown); w != "" { @@ -42,6 +47,9 @@ func docsUpdateWarnings(mode, markdown string) []string { if w := checkDocsUpdateBoldItalic(markdown); w != "" { warnings = append(warnings, w) } + if w := checkDocsUpdateWholeParagraphStyle(markdown); w != "" { + warnings = append(warnings, w) + } return warnings } @@ -279,3 +287,75 @@ func leadingRun(s string, c byte) string { } return s[:i] } + +// wholeParagraphStyleRe matches a line whose entire content is a single +// emphasis span: *…* or **…** (at least one non-whitespace char inside). +// CJK and ASCII content both match; we deliberately do NOT match ***…*** +// because that is already covered by checkDocsUpdateBoldItalic. +// wholeParagraphStyleRe matches a line whose entire content is exactly one +// *italic* or **bold** span. Requirements: opener and closer must be the same +// number of asterisks (1 or 2), content has no * or newline, and at least one +// non-whitespace non-asterisk character is present (prevents `* *` matches). +var wholeParagraphStyleRe = regexp.MustCompile( + `^(?:\*[^*\n]*[^\s*\n][^*\n]*\*|\*\*[^*\n]*[^\s*\n][^*\n]*\*\*)$`, +) + +// checkDocsUpdateWholeParagraphStyle warns when a markdown paragraph +// consists entirely of *italic* or **bold** markers. Lark's markdown +// parser treats such lines as literal strings (the markers appear verbatim) +// rather than styled paragraphs. This is distinct from inline emphasis +// embedded in mixed-content lines, which Lark handles correctly. +func checkDocsUpdateWholeParagraphStyle(markdown string) string { + if markdown == "" { + return "" + } + sanitized := stripMarkdownCodeRegions(markdown) + for _, line := range strings.Split(sanitized, "\n") { + trimmed := strings.TrimSpace(line) + if wholeParagraphStyleRe.MatchString(trimmed) { + return "line consisting entirely of *…* or **…** markers will not be rendered as italic/bold by Lark; " + + "the markers appear as literal characters. " + + "Mix the styled text with surrounding prose, or use Lark XML: / ." + } + } + return "" +} + +// v2MarkdownUnsupportedTags lists Lark custom XML tags that the v2 +// markdown parser does not understand. When present in markdown-mode +// content, the parser silently strips or corrupts them — collapsing grid +// columns, discarding lark-table rows, or escaping text-color tags to +// literal characters. Use --doc-format xml to preserve these constructs. +var v2MarkdownUnsupportedTags = []struct { + prefix string + display string +}{ + {""}, + {""}, + {""}, + {""}, + {""}, +} + +// CheckV2MarkdownCustomTags returns a non-empty error message when content +// contains Lark custom tags that the v2 markdown parser silently corrupts. +// Callers in Validate should return this as an error; callers in DryRun +// may choose to surface it as a warning instead. +func CheckV2MarkdownCustomTags(content string) string { + if content == "" { + return "" + } + var found []string + for _, t := range v2MarkdownUnsupportedTags { + if strings.Contains(content, t.prefix) { + found = append(found, t.display) + } + } + if len(found) == 0 { + return "" + } + return "--doc-format markdown does not support Lark custom tags (" + + strings.Join(found, ", ") + "); " + + "the v2 API will silently strip or corrupt them. " + + "Switch to --doc-format xml (the default) to preserve these blocks." +} diff --git a/shortcuts/doc/docs_update_check_test.go b/shortcuts/doc/docs_update_check_test.go index 50905873a..26ecba24b 100644 --- a/shortcuts/doc/docs_update_check_test.go +++ b/shortcuts/doc/docs_update_check_test.go @@ -364,6 +364,19 @@ func TestDocsUpdateWarningsAggregates(t *testing.T) { } } +func TestDocsUpdateWarningsWholeParagraphStyle(t *testing.T) { + t.Parallel() + + // Whole-paragraph italic should surface via docsUpdateWarnings. + warnings := docsUpdateWarnings("append", "*整段斜体段落*") + if len(warnings) != 1 { + t.Fatalf("expected 1 warning, got %d: %v", len(warnings), warnings) + } + if !strings.Contains(warnings[0], "*…*") { + t.Fatalf("unexpected warning text: %q", warnings[0]) + } +} + func TestDocsUpdateWarningsEmpty(t *testing.T) { t.Parallel() @@ -373,3 +386,146 @@ func TestDocsUpdateWarningsEmpty(t *testing.T) { t.Fatalf("expected no warnings, got: %v", warnings) } } + +func TestCheckV2MarkdownCustomTags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + wantHint bool + wantTag string + }{ + { + name: "empty content produces no warning", + content: "", + wantHint: false, + }, + { + name: "plain markdown without custom tags is fine", + content: "# Title\n\nSome **bold** paragraph.", + wantHint: false, + }, + { + name: "callout tag is safe (supported by v2)", + content: `content`, + wantHint: false, + }, + { + name: "grid tag triggers warning", + content: `A`, + wantHint: true, + wantTag: "", + }, + { + name: "column tag triggers warning", + content: `col content`, + wantHint: true, + wantTag: "", + }, + { + name: "lark-table tag triggers warning", + content: "\ncell\n", + wantHint: true, + wantTag: "", + }, + { + name: "text color tag triggers warning", + content: `colored text`, + wantHint: true, + wantTag: "", + }, + { + name: "multiple custom tags all appear in message", + content: ``, + wantHint: true, + wantTag: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := CheckV2MarkdownCustomTags(tt.content) + hasHint := got != "" + if hasHint != tt.wantHint { + t.Fatalf("CheckV2MarkdownCustomTags(%q) = %q, wantHint=%v", tt.content, got, tt.wantHint) + } + if tt.wantTag != "" && !strings.Contains(got, tt.wantTag) { + t.Fatalf("expected message to contain %q, got: %q", tt.wantTag, got) + } + }) + } +} + +func TestCheckDocsUpdateWholeParagraphStyle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + markdown string + wantHint bool + }{ + { + name: "plain paragraph no warning", + markdown: "This is a normal paragraph.", + wantHint: false, + }, + { + name: "inline bold within mixed line is fine", + markdown: "See **important** detail here.", + wantHint: false, + }, + { + name: "whole line italic triggers warning", + markdown: "*为什么大家都选代码?*", + wantHint: true, + }, + { + name: "whole line bold triggers warning", + markdown: "**回到最初的问题:要用长期的耐心去打磨。**", + wantHint: true, + }, + { + name: "whole line italic inside larger doc triggers warning", + markdown: "# Title\n\nSome prose.\n\n*整段斜体行*\n\nMore prose.", + wantHint: true, + }, + { + name: "triple asterisk not matched (covered by bold+italic check)", + markdown: "***text***", + wantHint: false, + }, + { + name: "asymmetric markers not matched (*text**)", + markdown: "*text**", + wantHint: false, + }, + { + name: "whitespace-only inside markers not matched", + markdown: "* *", + wantHint: false, + }, + { + name: "whole paragraph style inside code fence is ignored", + markdown: "```\n*not a paragraph*\n```", + wantHint: false, + }, + { + name: "empty markdown no warning", + markdown: "", + wantHint: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := checkDocsUpdateWholeParagraphStyle(tt.markdown) + hasHint := got != "" + if hasHint != tt.wantHint { + t.Fatalf("checkDocsUpdateWholeParagraphStyle(%q) = %q, wantHint=%v", tt.markdown, got, tt.wantHint) + } + }) + } +} diff --git a/shortcuts/doc/docs_update_v2.go b/shortcuts/doc/docs_update_v2.go index 62b1f0cb6..fd1d0ec73 100644 --- a/shortcuts/doc/docs_update_v2.go +++ b/shortcuts/doc/docs_update_v2.go @@ -103,6 +103,11 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { return common.FlagErrorf("--command append requires --content") } } + if runtime.Str("doc-format") == "markdown" && content != "" { + if msg := CheckV2MarkdownCustomTags(content); msg != "" { + return common.FlagErrorf("%s", msg) + } + } return nil }