Compare commits

...

3 Commits

Author SHA1 Message Date
sunpeiyang.996
622a34c5c1 fix: add type attribute when inferred from file extension
When the whiteboard type is inferred from file extension (e.g. .puml →
plantuml), the type attribute must be explicitly added to the tag attrs
so the server receives a valid <whiteboard type="plantuml"> tag.

Change-Id: Icfb4ec349ba47992be02ff90587e281fda143c73
2026-07-07 16:22:53 +08:00
sunpeiyang.996
c3c2b39a22 fix: use element-level regex to replace entire whiteboard tag
The previous approach matched only the start tag and appended a new closing
tag, leaving the original </whiteboard>. Use whiteboardElementReplacer that
matches the full element to avoid doubled closing tags.

Change-Id: Ib32fd487918e464fa875455690c165cc7d1dca6c
2026-07-07 16:16:58 +08:00
sunpeiyang.996
764419ec7b feat: support whiteboard inline file import in docs create/update
Allow `<whiteboard type="plantuml" path="@./diagram.puml">` in --content
XML for docs +create and docs +update. CLI reads the file and replaces
the tag body with the file content, consistent with the existing
html5-block path resolution pattern.

- New whiteboard_inline.go: parse, validate, and rewrite whiteboard tags
  with @path attributes; infer type from file extension (.puml→plantuml,
  .mmd→mermaid, .svg→svg, other→raw)
- Wire into prepareDocsV2WriteInput (html5_block_resources.go) after
  html5-block processing
- Validate that path+inner content are mutually exclusive
- Add unit tests for type validation, XML parsing, attribute ops, rendering

Change-Id: Icf7f9efbec1385b956dd6d2e797895aaaeab141a
2026-07-07 16:07:29 +08:00
3 changed files with 400 additions and 0 deletions

View File

@@ -119,6 +119,13 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn
if err != nil {
return docsV2WriteInput{}, err
}
if err := validateWhiteboardWriteElementBodies(runtime.Str("doc-format"), content); err != nil {
return docsV2WriteInput{}, err
}
content, err = prepareWhiteboardInlineContent(runtime, runtime.Str("doc-format"), content)
if err != nil {
return docsV2WriteInput{}, err
}
if err := resolveReferenceMapPaths(runtime, html5RefMap); err != nil {
return docsV2WriteInput{}, err
}

View File

@@ -0,0 +1,275 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"encoding/xml"
"errors"
"fmt"
"io"
"path/filepath"
"regexp"
"strings"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
const (
whiteboardTag = "whiteboard"
)
var (
whiteboardStartTagPattern = regexp.MustCompile(`(?is)<whiteboard\b[^>]*>`)
whiteboardElementPattern = regexp.MustCompile(`(?is)<whiteboard\b[^>]*>(.*?)</whiteboard>`)
whiteboardElementReplacer = regexp.MustCompile(`(?is)<whiteboard\b[^>]*>.*?</whiteboard>`)
)
type whiteboardAttr struct {
Name string
Value string
}
type whiteboardStartTag struct {
Attrs []whiteboardAttr
SelfClosing bool
}
func prepareWhiteboardInlineContent(runtime *common.RuntimeContext, format string, content string) (string, error) {
if !strings.Contains(content, "<"+whiteboardTag) {
return content, nil
}
if strings.TrimSpace(format) == "markdown" {
// whiteboard tags are only used in XML format
return content, nil
}
var rewriteErr error
out := whiteboardElementReplacer.ReplaceAllStringFunc(content, func(raw string) string {
if rewriteErr != nil {
return raw
}
// Extract the opening tag part
openTagMatch := whiteboardStartTagPattern.FindString(raw)
if openTagMatch == "" {
return raw
}
tag, err := parseWhiteboardStartTag(openTagMatch)
if err != nil {
rewriteErr = common.ValidationErrorf("invalid whiteboard tag: %v", err).WithParam("whiteboard")
return raw
}
pathValue, hasPath := tag.attr("path")
if !hasPath {
// no path attribute, leave as-is
return raw
}
data, err := readWhiteboardPath(runtime, pathValue, "whiteboard path")
if err != nil {
rewriteErr = err
return raw
}
// Infer type from extension if not present
var docType string
if docType, hasType := tag.attr("type"); hasType {
docType = strings.TrimSpace(docType)
if !isValidWhiteboardType(docType) {
rewriteErr = common.ValidationErrorf("invalid whiteboard type %q; valid types: raw | plantuml | mermaid | svg", docType).WithParam("type")
return raw
}
} else {
cleanPath := filepath.Clean(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(pathValue), "@")))
ext := strings.ToLower(filepath.Ext(cleanPath))
switch ext {
case ".puml", ".plantuml":
docType = "plantuml"
case ".mmd", ".mermaid":
docType = "mermaid"
case ".svg":
docType = "svg"
default:
docType = "raw"
}
}
tag.removeAttrs("path")
if docType != "" {
if !tag.hasAttr("type") {
tag.Attrs = append(tag.Attrs, whiteboardAttr{Name: "type", Value: docType})
}
} else {
tag.removeAttrs("type")
}
var result strings.Builder
result.WriteString(tag.render(false))
result.WriteString(data)
result.WriteString("</whiteboard>")
return result.String()
})
if rewriteErr != nil {
return "", rewriteErr
}
return out, nil
}
// validateWhiteboardWriteElementBodies ensures that whiteboard tags with path attribute
// don't contain inner content (like html5-block). This prevents ambiguity: you must either
// have the path attribute resolved by CLI OR have content inline, not both.
func validateWhiteboardWriteElementBodies(format string, content string) error {
validateSegment := func(segment string) error {
matches := whiteboardElementPattern.FindAllStringSubmatchIndex(segment, -1)
for _, match := range matches {
if len(match) < 4 || match[2] < 0 || match[3] < 0 {
continue
}
inner := strings.TrimSpace(segment[match[2]:match[3]])
if inner != "" {
// inner content is non-empty — check if there's a path attribute in the opening tag
raw := segment[match[0]:match[1]]
tag, err := parseWhiteboardStartTag(raw)
if err != nil {
continue // already validated during rewrite; ignore here
}
if _, hasPath := tag.attr("path"); hasPath {
return common.ValidationErrorf("whiteboard with path=\"@...\" cannot contain inner content; remove the content between <whiteboard> and </whiteboard>").WithParam("whiteboard")
}
}
}
return nil
}
if strings.TrimSpace(format) != "markdown" {
return validateSegment(content)
}
var validateErr error
_ = applyOutsideCodeFences(content, func(segment string) string {
if validateErr != nil {
return segment
}
if err := validateSegment(segment); err != nil {
validateErr = err
}
return segment
})
return validateErr
}
func isValidWhiteboardType(typ string) bool {
switch typ {
case "raw", "plantuml", "mermaid", "svg":
return true
default:
return false
}
}
func readWhiteboardPath(runtime *common.RuntimeContext, pathValue, label string) (string, error) {
pathRaw := strings.TrimSpace(pathValue)
if !strings.HasPrefix(pathRaw, "@") {
return "", common.ValidationErrorf("%s %q must start with @, for example @diagram.puml", 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")
}
data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean)
if err != nil {
return "", fmt.Errorf("%s %q cannot be read from the current working directory; check that the file exists: %w", label, clean, err)
}
return string(data), nil
}
func parseWhiteboardStartTag(raw string) (whiteboardStartTag, error) {
trimmed := strings.TrimSpace(raw)
selfClosing := strings.HasSuffix(trimmed, "/>")
decoder := xml.NewDecoder(strings.NewReader(raw))
for {
tok, err := decoder.Token()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return whiteboardStartTag{}, err
}
start, ok := tok.(xml.StartElement)
if !ok {
continue
}
if start.Name.Local != whiteboardTag {
return whiteboardStartTag{}, fmt.Errorf("expected <%s>, got <%s>", whiteboardTag, start.Name.Local)
}
attrs := make([]whiteboardAttr, 0, len(start.Attr))
for _, attr := range start.Attr {
attrs = append(attrs, whiteboardAttr{Name: attr.Name.Local, Value: attr.Value})
}
return whiteboardStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil
}
return whiteboardStartTag{}, fmt.Errorf("missing start element")
}
func (t *whiteboardStartTag) attr(name string) (string, bool) {
for _, attr := range t.Attrs {
if attr.Name == name {
return attr.Value, true
}
}
return "", false
}
func (t *whiteboardStartTag) hasAttr(name string) bool {
_, ok := t.attr(name)
return ok
}
func (t *whiteboardStartTag) removeAttrs(names ...string) {
newAttrs := make([]whiteboardAttr, 0, len(t.Attrs))
for _, attr := range t.Attrs {
keep := true
for _, name := range names {
if attr.Name == name {
keep = false
break
}
}
if keep {
newAttrs = append(newAttrs, attr)
}
}
t.Attrs = newAttrs
}
func (t whiteboardStartTag) render(selfClosing bool) string {
var b strings.Builder
b.WriteByte('<')
b.WriteString(whiteboardTag)
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(whiteboardTag)
b.WriteByte('>')
}
return b.String()
}

View File

@@ -0,0 +1,118 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"testing"
)
func TestIsValidWhiteboardType(t *testing.T) {
tests := []struct {
typ string
want bool
}{
{"raw", true},
{"plantuml", true},
{"mermaid", true},
{"svg", true},
{"", false},
{"unknown", false},
{"RAW", false},
{"PlantUML", false},
}
for _, tt := range tests {
got := isValidWhiteboardType(tt.typ)
if got != tt.want {
t.Errorf("isValidWhiteboardType(%q) = %v, want %v", tt.typ, got, tt.want)
}
}
}
func TestParseWhiteboardStartTag(t *testing.T) {
tests := []struct {
name string
raw string
wantErr bool
attrs map[string]string
}{
{
name: "path attribute",
raw: `<whiteboard type="plantuml" path="@./diagram.puml">`,
wantErr: false,
attrs: map[string]string{"type": "plantuml", "path": "@./diagram.puml"},
},
{
name: "self-closing",
raw: `<whiteboard token="abc"/>`,
wantErr: false,
attrs: map[string]string{"token": "abc"},
},
{
name: "no attributes",
raw: `<whiteboard>`,
wantErr: false,
attrs: map[string]string{},
},
}
for _, tt := range tests {
got, err := parseWhiteboardStartTag(tt.raw)
if (err != nil) != tt.wantErr {
t.Errorf("%s: parseWhiteboardStartTag(%q) error = %v, wantErr = %v", tt.name, tt.raw, err, tt.wantErr)
continue
}
if err != nil {
continue
}
for k, want := range tt.attrs {
gotVal, ok := got.attr(k)
if !ok {
t.Errorf("%s: expected attr %q not found", tt.name, k)
} else if gotVal != want {
t.Errorf("%s: attr %q = %q, want %q", tt.name, k, gotVal, want)
}
}
}
}
func TestRemoveAttrs(t *testing.T) {
tag := whiteboardStartTag{
Attrs: []whiteboardAttr{
{Name: "type", Value: "plantuml"},
{Name: "path", Value: "@./diagram.puml"},
},
}
tag.removeAttrs("path")
if _, ok := tag.attr("path"); ok {
t.Error("path attribute should have been removed")
}
if _, ok := tag.attr("type"); !ok {
t.Error("type attribute should still exist")
}
}
func TestRenderWhiteboardStartTag(t *testing.T) {
tag := whiteboardStartTag{
Attrs: []whiteboardAttr{
{Name: "type", Value: "plantuml"},
},
}
result := tag.render(false)
if result != `<whiteboard type="plantuml">` {
t.Errorf("render() = %q, want %q", result, `<whiteboard type="plantuml">`)
}
}
func TestWhiteboardStartTagHasAttr(t *testing.T) {
tag := whiteboardStartTag{
Attrs: []whiteboardAttr{
{Name: "type", Value: "plantuml"},
},
}
if !tag.hasAttr("type") {
t.Error("should have type attr")
}
if tag.hasAttr("path") {
t.Error("should not have path attr")
}
}