Compare commits

..

4 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
liangshuo-1
f0b6f35fee fix: resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
* fix(schema): fall back to runtime catalog when no embedded metadata

Binaries built from the bare Go module (plugin builds) embed only the
empty meta_data_default.json stub because meta_data.json is gitignored
and fetched at build time. The schema command, its completion, and the
affordance command-form resolver read the embedded-only catalog, so
every schema lookup failed with "Unknown service" even though the
runtime registry had already sync-fetched full metadata.

Add registry.SchemaCatalog(): embedded when compiled in (official
builds unchanged, still deterministic), otherwise the merged runtime
catalog seeded from cache or remote fetch. When neither source has
data (offline plugin build with a cold cache), schema now returns a
failed_precondition error with an actionable hint instead of
"Unknown service" with an empty candidate list.

* fix(registry): gate cached meta overlay on version newer than embedded

The cached remote meta was overlaid onto the embedded meta_data.json
unconditionally, so after a CLI upgrade an equal- or older-version
cache kept shadowing the freshly shipped embedded definitions until a
later refresh happened to rewrite it.

Only overlay when the cache version is strictly newer than the
embedded baseline. The bare-module stub baseline is "0.0.0", so plugin
builds without compiled metadata still take any real cached version
(TestOverlayGate_StubEmbedded_OverlaysRealCache) and the schema
runtime fallback keeps working offline from a warm cache.

Ports #1376 onto the typed meta model.

---------

Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
2026-07-06 21:24:24 +08:00
12 changed files with 606 additions and 42 deletions

View File

@@ -2,27 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.66] - 2026-07-06
### Features
- support semantic recurring calendar operations (#1723)
### Bug Fixes
- guide drive import concurrency conflicts (#1751)
- **calendar**: guide approval room booking fallback (#1637)
- support pnpm global installs in self-update (#1705)
### Documentation
- tighten doc creation validation workflow (#1759)
- clarify success envelope contract — judge success by ok, not code (#1730)
### Refactoring
- **envvars**: consolidate agent env value access (#1757)
## [v1.0.65] - 2026-07-03
### Features
@@ -1392,7 +1371,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62

View File

@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
return cmd
}
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
// It uses the embedded source so completion candidates match what `schema`
// execution can resolve (both overlay-free).
// completeSchemaPath is a thin adapter over the schema catalog's Complete.
// It uses the same source as schema execution so completion candidates match
// what `schema` can resolve.
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
mode := f.ResolveStrictMode(cmd.Context())
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
directive := cobra.ShellCompDirectiveNoFileComp
if noSpace {
directive |= cobra.ShellCompDirectiveNoSpace
@@ -86,13 +86,19 @@ func schemaRun(opts *SchemaOptions) error {
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
}
// runSchema resolves the path through the embedded catalog and renders the
// runSchema resolves the path through the schema catalog and renders the
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
// output shape — a single resolved method renders as one envelope object,
// anything broader as an array — and maps resolve failures to hints.
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
catalog := registry.EmbeddedCatalog()
catalog := registry.SchemaCatalog()
if len(catalog.Services()) == 0 {
// No embedded metadata and the runtime fallback is empty too: offline
// with a cold cache, remote meta off, or an unwritable cache dir.
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
}
target, err := catalog.Resolve(parts)
if err != nil {
return resolveError(err)

View File

@@ -77,14 +77,10 @@ func loadService(service string) map[string]json.RawMessage {
// space→dot fallback covers domains where the two already coincide.
func commandFormResolver(service string) func(string) string {
byForm := map[string]string{}
for _, svc := range registry.EmbeddedServicesTyped() {
if svc.Name != service {
continue
}
if svc, ok := registry.SchemaCatalog().Service(service); ok {
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
}
break
}
return func(h string) string {
h = strings.TrimSpace(h)

View File

@@ -6,8 +6,7 @@ package registry
import "github.com/larksuite/cli/internal/apicatalog"
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
// and schema lint.
// metadata — deterministic across machines, for golden tests and schema lint.
func EmbeddedCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
}
@@ -18,3 +17,14 @@ func EmbeddedCatalog() apicatalog.Catalog {
func RuntimeCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
}
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
// otherwise the merged runtime catalog. Binaries built from the bare Go module
// embed only the empty meta_data_default.json stub, so the embedded view has
// nothing to resolve; the merged view is the only data such binaries have.
func SchemaCatalog() apicatalog.Catalog {
if len(EmbeddedServicesTyped()) > 0 {
return EmbeddedCatalog()
}
return RuntimeCatalog()
}

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/larksuite/cli/internal/apicatalog"
)
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
// restores them (with a full state reset) on cleanup.
func swapEmbeddedMeta(t *testing.T, data []byte) {
t.Helper()
resetInit()
orig := embeddedMetaJSON
embeddedMetaJSON = data
t.Cleanup(func() {
waitBackgroundRefresh()
embeddedMetaJSON = orig
resetInit()
})
}
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
c := SchemaCatalog()
if c.Source() != apicatalog.SourceEmbedded {
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
}
if _, ok := c.Service("embedded_svc"); !ok {
t.Fatal("expected embedded_svc from embedded metadata")
}
}
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
// from the bare Go module (plugin builds): only the empty meta_data_default.json
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
// Init seeds via sync fetch.
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write(testEnvelopeJSON("remote_svc"))
}))
defer ts.Close()
testMetaURL = ts.URL
c := SchemaCatalog()
if c.Source() != apicatalog.SourceRuntime {
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
}
if _, ok := c.Service("remote_svc"); !ok {
t.Fatal("expected remote_svc from runtime fallback")
}
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/update"
)
//go:embed scope_priorities.json scope_overrides.json
@@ -85,7 +86,9 @@ func InitWithBrand(brand core.LarkBrand) {
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
if !brandChanged {
if cached, err := loadCachedMerged(); err == nil {
// After a CLI upgrade the embedded data can be fresher than an old
// cache; an equal/older cache must not shadow it.
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
overlayMergedServices(cached)
}
}

View File

@@ -0,0 +1,102 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
)
// seedCache writes a cache file + cache meta for one service whose Title is
// marker, tagged with the given top-level data version and brand.
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
t.Helper()
cDir := filepath.Join(dir, "cache")
if err := os.MkdirAll(cDir, 0700); err != nil {
t.Fatal(err)
}
reg := MergedRegistry{
Version: version,
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
}
data, _ := json.Marshal(reg)
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
t.Fatal(err)
}
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
mData, _ := json.Marshal(cm)
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
t.Fatal(err)
}
}
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
t.Helper()
embedded, _ := json.Marshal(MergedRegistry{
Version: embeddedVer,
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
})
swapEmbeddedMeta(t, embedded)
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
InitWithBrand(core.BrandFeishu)
}
func titleOf(t *testing.T, name string) string {
t.Helper()
svc, ok := ServiceTyped(name)
if !ok {
t.Fatalf("service %q not loaded", name)
}
return svc.Title
}
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
initWithCache(t, "1.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
}
}
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
initWithCache(t, "2.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("older cache: got %q, want EMBEDDED", got)
}
}
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
initWithCache(t, "1.0.0", "2.0.0")
if got := titleOf(t, "svc"); got != "CACHE" {
t.Errorf("newer cache: got %q, want CACHE", got)
}
}
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
initWithCache(t, "1.0.0", "not-a-semver")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
}
}
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
// plugin builds without compiled meta_data.json still get remote data.
initWithCache(t, "0.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "CACHE" {
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
}
}

View File

@@ -72,9 +72,11 @@ func hasEmbeddedServices() bool {
}
// testRegistry returns a minimal MergedRegistry with one service.
// The version is a real semver newer than the embedded stub baseline ("0.0.0")
// so cache overlay passes the version gate in InitWithBrand.
func testRegistry(name string) MergedRegistry {
return MergedRegistry{
Version: "test-1.0",
Version: "1.0.0",
Services: []meta.Service{
{
Name: name,
@@ -160,7 +162,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
}
func TestCacheHit_WithinTTL(t *testing.T) {
resetInit()
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
@@ -197,7 +199,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
}
func TestNetworkError_SilentDegradation(t *testing.T) {
resetInit()
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
@@ -371,8 +373,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
if data == nil {
t.Fatal("expected non-nil data")
}
if reg.Version != "test-1.0" {
t.Errorf("expected version test-1.0, got %s", reg.Version)
if reg.Version != "1.0.0" {
t.Errorf("expected version 1.0.0, got %s", reg.Version)
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.66",
"version": "1.0.65",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

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")
}
}