mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
6 Commits
feat-svgli
...
fix/apps-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d752ab9a20 | ||
|
|
73be1d06ec | ||
|
|
cccf025599 | ||
|
|
7db899db01 | ||
|
|
c2d6038aae | ||
|
|
efa3439e01 |
23
CHANGELOG.md
23
CHANGELOG.md
@@ -2,6 +2,28 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.64] - 2026-07-02
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
|
||||
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
|
||||
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
|
||||
- **cli**: Improve secure label error handling (#1707)
|
||||
- **cli**: Reduce public content token false positives
|
||||
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
|
||||
- **doc**: Align word statistics compound tokens (#1706)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
|
||||
- **doc**: Support `reference_map` in docs (#1690)
|
||||
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
|
||||
|
||||
## [v1.0.62] - 2026-07-01
|
||||
|
||||
### Features
|
||||
@@ -1333,6 +1355,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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
|
||||
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
||||
|
||||
@@ -22,11 +22,6 @@ import (
|
||||
|
||||
// NewCmdAuth creates the auth command with subcommands.
|
||||
func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
return NewCmdAuthWithContext(context.Background(), f)
|
||||
}
|
||||
|
||||
// NewCmdAuthWithContext creates the auth command with subcommands.
|
||||
func NewCmdAuthWithContext(ctx context.Context, f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "OAuth credentials and authorization management",
|
||||
@@ -43,7 +38,7 @@ func NewCmdAuthWithContext(ctx context.Context, f *cmdutil.Factory) *cobra.Comma
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
cmd.AddCommand(NewCmdAuthLoginWithContext(ctx, f, nil))
|
||||
cmd.AddCommand(NewCmdAuthLogin(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthLogout(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthStatus(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthScopes(f, nil))
|
||||
|
||||
@@ -42,11 +42,6 @@ var pollDeviceToken = larkauth.PollDeviceToken
|
||||
|
||||
// NewCmdAuthLogin creates the auth login subcommand.
|
||||
func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
|
||||
return NewCmdAuthLoginWithContext(context.Background(), f, runF)
|
||||
}
|
||||
|
||||
// NewCmdAuthLoginWithContext creates the auth login subcommand.
|
||||
func NewCmdAuthLoginWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
|
||||
opts := &LoginOptions{Factory: f}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -78,7 +73,7 @@ to generate QR codes (supports ASCII and PNG formats).`,
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
var helpBrand core.LarkBrand
|
||||
if !cmdutil.IsCredentialBootstrapDisabled(ctx) && f != nil && f.Config != nil {
|
||||
if f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
helpBrand = cfg.Brand
|
||||
}
|
||||
|
||||
18
cmd/build.go
18
cmd/build.go
@@ -90,9 +90,8 @@ func WithoutPlugins() BuildOption {
|
||||
}
|
||||
|
||||
// WithoutStrictMode builds the complete repository-owned command tree without
|
||||
// applying user/profile strict-mode pruning or credential-backed bootstrap
|
||||
// probes. It is intended for offline inspection tools and pure local commands
|
||||
// that must not require account configuration.
|
||||
// applying user/profile strict-mode pruning. It is intended for offline
|
||||
// inspection tools, not production execution.
|
||||
func WithoutStrictMode() BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.skipStrictMode = true
|
||||
@@ -147,9 +146,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
o(cfg)
|
||||
}
|
||||
}
|
||||
if cfg.skipStrictMode {
|
||||
ctx = cmdutil.ContextWithCredentialBootstrapDisabled(ctx)
|
||||
}
|
||||
// Default streams when WithIO is not supplied so the root command's
|
||||
// SetIn/Out/Err calls below don't deref nil. NewDefault also normalizes
|
||||
// partial streams internally; keep both in sync so cfg.streams reflects
|
||||
@@ -196,10 +192,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuthWithContext(ctx, f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoamiWithContext(ctx, f))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
|
||||
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
@@ -222,10 +218,8 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
// before printing help; non-bare invocations and non-TTY are unaffected.
|
||||
installRootUpgradePrompt(f, rootCmd)
|
||||
|
||||
if !cfg.skipStrictMode {
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
|
||||
if cfg.skipPlugins {
|
||||
|
||||
34
cmd/root.go
34
cmd/root.go
@@ -103,16 +103,10 @@ func Execute() int {
|
||||
configureFlagCompletions(os.Args)
|
||||
|
||||
ctx := context.Background()
|
||||
buildOpts := []BuildOption{
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
HideProfile(isSingleAppMode()),
|
||||
}
|
||||
if isLocalSVGlideInvocation(rawInvocationArgs) {
|
||||
buildOpts = append(buildOpts, WithoutStrictMode())
|
||||
}
|
||||
f, rootCmd, reg := buildInternal(
|
||||
ctx, inv,
|
||||
buildOpts...,
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
HideProfile(isSingleAppMode()),
|
||||
)
|
||||
|
||||
// --- Notices (non-blocking) ---
|
||||
@@ -136,30 +130,6 @@ func Execute() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func isLocalSVGlideInvocation(args []string) bool {
|
||||
positionals := make([]string, 0, 2)
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "--profile":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--profile="):
|
||||
continue
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
continue
|
||||
default:
|
||||
positionals = append(positionals, arg)
|
||||
if len(positionals) == 2 {
|
||||
return positionals[0] == "slides" && positionals[1] == "+create-svglide"
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// setupNotices wires both the binary update notice and the skills
|
||||
// staleness notice into output.PendingNotice as a composed function.
|
||||
// Each provider populates an independent key under _notice; either
|
||||
|
||||
@@ -5,12 +5,9 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -29,27 +26,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
type countingKeychain struct {
|
||||
gets int
|
||||
sets int
|
||||
removes int
|
||||
}
|
||||
|
||||
func (k *countingKeychain) Get(service, account string) (string, error) {
|
||||
k.gets++
|
||||
return "", fmt.Errorf("unexpected keychain Get for %s/%s", service, account)
|
||||
}
|
||||
|
||||
func (k *countingKeychain) Set(service, account, value string) error {
|
||||
k.sets++
|
||||
return fmt.Errorf("unexpected keychain Set for %s/%s", service, account)
|
||||
}
|
||||
|
||||
func (k *countingKeychain) Remove(service, account string) error {
|
||||
k.removes++
|
||||
return fmt.Errorf("unexpected keychain Remove for %s/%s", service, account)
|
||||
}
|
||||
|
||||
// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that
|
||||
// auth, config, and schema commands have auth check disabled,
|
||||
// while api does not.
|
||||
@@ -99,63 +75,6 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLocalSVGlideInvocation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{name: "local svglide", args: []string{"slides", "+create-svglide", "--action", "init"}, want: true},
|
||||
{name: "with profile", args: []string{"--profile", "demo", "slides", "+create-svglide"}, want: true},
|
||||
{name: "with profile equals", args: []string{"--profile=demo", "slides", "+create-svglide"}, want: true},
|
||||
{name: "other slides shortcut", args: []string{"slides", "+create"}, want: false},
|
||||
{name: "root help", args: []string{"--help"}, want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isLocalSVGlideInvocation(tt.args); got != tt.want {
|
||||
t.Fatalf("isLocalSVGlideInvocation(%v) = %v, want %v", tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalSVGlideRootCommandDoesNotTouchKeychain(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var in, out, errOut bytes.Buffer
|
||||
kc := &countingKeychain{}
|
||||
_, rootCmd, _ := buildInternal(
|
||||
context.Background(),
|
||||
cmdutil.InvocationContext{},
|
||||
WithIO(&in, &out, &errOut),
|
||||
WithKeychain(kc),
|
||||
WithoutStrictMode(),
|
||||
WithoutPlugins(),
|
||||
)
|
||||
rootCmd.SetArgs([]string{
|
||||
"slides",
|
||||
"+create-svglide",
|
||||
"--action", "init",
|
||||
"--title", "Demo",
|
||||
"--input", "source.md",
|
||||
"--out", "run-demo",
|
||||
})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\nstdout=%s\nstderr=%s", err, out.String(), errOut.String())
|
||||
}
|
||||
if kc.gets != 0 || kc.sets != 0 || kc.removes != 0 {
|
||||
t.Fatalf("keychain touched: gets=%d sets=%d removes=%d", kc.gets, kc.sets, kc.removes)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "run.json")); err != nil {
|
||||
t.Fatalf("missing run.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
// The human skills-install guidance now lives in the root usage-template
|
||||
// footer (below the command list), not in the agent-facing Long.
|
||||
|
||||
@@ -54,12 +54,6 @@ type Options struct {
|
||||
// local-only; when an external credential provider manages tokens, resolving
|
||||
// the identity may contact that provider.
|
||||
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
return NewCmdWhoamiWithContext(context.Background(), f)
|
||||
}
|
||||
|
||||
// NewCmdWhoamiWithContext creates the whoami command using the build context
|
||||
// for registration-time strict-mode presentation.
|
||||
func NewCmdWhoamiWithContext(ctx context.Context, f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &Options{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "whoami",
|
||||
@@ -69,7 +63,7 @@ func NewCmdWhoamiWithContext(ctx context.Context, f *cmdutil.Factory) *cobra.Com
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &opts.As)
|
||||
cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As)
|
||||
// Output is always JSON. Accept (and ignore) --json so existing
|
||||
// `whoami --json` callers don't break; hide it to avoid implying a non-JSON
|
||||
// mode exists.
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
# `slides +create-svglide` Codex Runtime Design
|
||||
|
||||
Date: 2026-07-02
|
||||
Branch: `feat-svglide-07`
|
||||
Scope: first local-only version of `lark-cli slides +create-svglide`
|
||||
|
||||
## Result
|
||||
|
||||
Build `slides +create-svglide` as a staged local runtime for AnyGen SVG Slides. The command creates and manages a run directory that Codex can fill with generated content, assets, and SVG slides. The CLI owns state, prompts, schemas, validation, preview, receipts, and recovery. Codex owns LLM reasoning, web research, image/search execution, chart design, and SVG authoring.
|
||||
|
||||
The first version does not publish to Feishu Slides. It must produce a local, inspectable SVG deck workbench.
|
||||
|
||||
## Context
|
||||
|
||||
`feat-svglide-07` currently starts from the latest `origin/main` and has only the existing Slides XML shortcut surface. There is no current `+create-svglide` implementation on this branch.
|
||||
|
||||
The AnyGen SVG Slides prompt should be reused as contracts and workflow rules, not pasted as one large prompt. Its value is split across request interpretation, research, design brief, outline, `slide_content.md`, asset planning, SVG authoring, protocol validation, preview, and repair.
|
||||
|
||||
## Goals
|
||||
|
||||
- Add a staged `slides +create-svglide` command group.
|
||||
- Create a local run directory under a user-specified `--out` path, usually `.lark-slides/svglide-runs/<run-id>`.
|
||||
- Generate prompt task files that tell Codex exactly what to produce for each stage.
|
||||
- Generate JSON schemas for stage outputs.
|
||||
- Track stage state in `run.json`.
|
||||
- Validate JSON outputs, SVG protocol basics, asset href existence, slide count, placeholder slides, and preview generation.
|
||||
- Generate `preview.html` for local inspection.
|
||||
- Write receipts and `repair_queue.md` so failed runs can resume from the current stage.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No online Feishu Slides creation.
|
||||
- No `slide_engine` or `slide` server changes.
|
||||
- No SVG-to-SXSD conversion.
|
||||
- No built-in model API provider.
|
||||
- No built-in web search, image generation, or image search client.
|
||||
- No complete 12-agent process runner.
|
||||
- No PPTX import/edit workflow.
|
||||
|
||||
## Command Surface
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svglide init --title "Demo" --input ./source.md --audience "..." --delivery-mode self_read --pages 8 --out ./.lark-slides/svglide-runs/demo
|
||||
lark-cli slides +create-svglide next <run-dir>
|
||||
lark-cli slides +create-svglide status <run-dir>
|
||||
lark-cli slides +create-svglide validate <run-dir>
|
||||
lark-cli slides +create-svglide preview <run-dir>
|
||||
```
|
||||
|
||||
`init` creates the run directory, writes the initial request files, schemas, stage prompts, and `run.json`.
|
||||
|
||||
`next` reads `run.json`, finds the next stage, verifies required inputs, renders or refreshes that stage's Codex task prompt, and reports the exact files Codex must create. It must not pretend LLM work is complete.
|
||||
|
||||
`status` checks declared outputs and receipts for each stage, then prints the current stage, missing files, and next useful command.
|
||||
|
||||
`validate` runs deterministic checks and writes validation receipts.
|
||||
|
||||
`preview` writes `preview.html` from `outline/deck.json` and `slides/*.svg`.
|
||||
|
||||
## Run Directory Contract
|
||||
|
||||
```text
|
||||
<run-dir>/
|
||||
run.json
|
||||
README.md
|
||||
request/request.json
|
||||
request/source_manifest.json
|
||||
research/research_notes.md
|
||||
research/sources.json
|
||||
brief/design_brief.json
|
||||
brief/visual_system.json
|
||||
outline/deck.json
|
||||
content/slide_content.md
|
||||
content/slide_content.json
|
||||
assets/assets_plan.json
|
||||
assets/images/
|
||||
assets/charts/
|
||||
slides/*.svg
|
||||
prompts/*.task.md
|
||||
schemas/*.schema.json
|
||||
receipts/*.json
|
||||
receipts/generation_summary.md
|
||||
repair_queue.md
|
||||
preview.html
|
||||
```
|
||||
|
||||
The run directory is local agent state. It should not be committed by default.
|
||||
|
||||
## State Model
|
||||
|
||||
`run.json` stores:
|
||||
|
||||
- version
|
||||
- runtime, always `codex` in v1
|
||||
- command name
|
||||
- title
|
||||
- created and updated timestamps
|
||||
- current stage
|
||||
- stage list with status, inputs, outputs, and receipt path
|
||||
- important artifact paths
|
||||
- policy flags: `publish_enabled=false`, `network_by_codex=true`, `image_generation_by_codex=true`, `overwrite=false`
|
||||
|
||||
Stage statuses:
|
||||
|
||||
```text
|
||||
pending
|
||||
ready
|
||||
in_progress
|
||||
done
|
||||
failed
|
||||
blocked
|
||||
needs_repair
|
||||
```
|
||||
|
||||
## Stage Design
|
||||
|
||||
### 1. request
|
||||
|
||||
Role: Request Interpreter
|
||||
|
||||
Input: CLI flags and local source path.
|
||||
|
||||
Output: `request/request.json`, `request/source_manifest.json`.
|
||||
|
||||
Validation: title, audience, delivery mode, page count, and source references must be explicit or marked missing.
|
||||
|
||||
### 2. research
|
||||
|
||||
Role: Researcher
|
||||
|
||||
Input: request files and source files.
|
||||
|
||||
Output: `research/research_notes.md`, `research/sources.json`.
|
||||
|
||||
Validation: key facts need source references. Codex may perform web research, but the CLI only validates resulting files.
|
||||
|
||||
### 3. design_brief
|
||||
|
||||
Role: Design Brief Resolver and Visual System Planner
|
||||
|
||||
Input: request and research outputs.
|
||||
|
||||
Output: `brief/design_brief.json`, `brief/visual_system.json`.
|
||||
|
||||
Validation: narrative spine, depth, tone, and visual system dimensions must be present.
|
||||
|
||||
### 4. outline
|
||||
|
||||
Role: Outline Planner
|
||||
|
||||
Input: design brief.
|
||||
|
||||
Output: `outline/deck.json`.
|
||||
|
||||
Validation: page count matches request; each slide has id, title, summary, role, and key message.
|
||||
|
||||
### 5. slide_content
|
||||
|
||||
Role: Content Builder
|
||||
|
||||
Input: deck outline and research notes.
|
||||
|
||||
Output: `content/slide_content.md`, `content/slide_content.json`.
|
||||
|
||||
Validation: every slide has key material, content blocks, and source notes. This is content planning, not final layout.
|
||||
|
||||
### 6. assets
|
||||
|
||||
Role: Asset Planner and Chart Generator
|
||||
|
||||
Input: slide content and visual system.
|
||||
|
||||
Output: `assets/assets_plan.json`, optional `assets/images/*`, optional `assets/charts/*.svg`.
|
||||
|
||||
Validation: every planned asset has purpose plus either a local path or a fallback. Chart takeaway must be written before chart type.
|
||||
|
||||
### 7. svg_author
|
||||
|
||||
Role: SVG Author
|
||||
|
||||
Input: deck, slide content, visual system, and assets.
|
||||
|
||||
Output: `slides/*.svg`.
|
||||
|
||||
Validation: each slide must contain more than a background. Each slide needs a background, title, visible content or visual element, semantic id, and valid SVG root.
|
||||
|
||||
### 8. validate_preview_repair
|
||||
|
||||
Role: Protocol Validator, Preview Agent, and Repair Agent
|
||||
|
||||
Input: generated slides.
|
||||
|
||||
Output: `receipts/lint.json`, `receipts/preview.json`, `repair_queue.md`, `preview.html`.
|
||||
|
||||
Validation: SVG protocol lint, local href checks, slide count match, preview write success, and unresolved issues recorded in the repair queue.
|
||||
|
||||
## Code Layout
|
||||
|
||||
```text
|
||||
shortcuts/slides/
|
||||
slides_create_svglide.go
|
||||
slides_create_svglide_test.go
|
||||
|
||||
internal/svglide/
|
||||
run.go
|
||||
init.go
|
||||
stage.go
|
||||
prompt.go
|
||||
schema.go
|
||||
validate.go
|
||||
preview.go
|
||||
receipt.go
|
||||
```
|
||||
|
||||
The shortcut package should stay thin. State, prompt rendering, validation, and preview logic belong in `internal/svglide` so they can be tested without a Cobra/runtime-heavy command harness.
|
||||
|
||||
## Skill Documentation
|
||||
|
||||
Update `skills/lark-slides/SKILL.md` and add a focused reference file for the local SVG runtime. The skill should explain that `+create-svglide` is local-only in v1, requires Codex to fill stage outputs, and must not be described as an online publish path.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Missing required inputs block the stage and write a receipt.
|
||||
- Invalid JSON or schema mismatch marks the stage failed.
|
||||
- Invalid SVG marks `needs_repair` and writes `repair_queue.md`.
|
||||
- Existing output paths are not overwritten unless an explicit overwrite policy is enabled.
|
||||
- Partially completed stages remain inspectable; reruns resume from the current stage.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests:
|
||||
|
||||
- `init` creates the expected directory tree and `run.json`.
|
||||
- `init` refuses to overwrite an existing run directory by default.
|
||||
- `status` identifies missing outputs.
|
||||
- `next` renders the correct stage prompt and does not mark Codex-only stages done.
|
||||
- `validate` catches invalid SVG, missing hrefs, placeholder slides, and slide count mismatch.
|
||||
- `preview` writes HTML that references generated SVG files.
|
||||
|
||||
Fixtures:
|
||||
|
||||
- `testdata/svglide_run_valid/`
|
||||
- `testdata/svglide_run_invalid/`
|
||||
|
||||
No live end-to-end test is required for v1 because this version does not call Feishu APIs.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- A user can initialize a run directory from local input.
|
||||
- Codex can follow generated task prompts stage by stage.
|
||||
- The CLI can report status and missing artifacts.
|
||||
- The CLI can validate a completed local SVG deck.
|
||||
- The CLI can generate local preview HTML.
|
||||
- Failed validation produces actionable repair output.
|
||||
- No online presentation is created.
|
||||
|
||||
## Further Judgment
|
||||
|
||||
This design deliberately optimizes for artifact contracts rather than agent-count symmetry. Once the local runtime is stable, individual stages can be split into fuller agents without changing the run directory contract.
|
||||
2420
docs/vendor/anygen-svg/source.full.md
vendored
2420
docs/vendor/anygen-svg/source.full.md
vendored
File diff suppressed because it is too large
Load Diff
8
docs/vendor/anygen-svg/source.meta.json
vendored
8
docs/vendor/anygen-svg/source.meta.json
vendored
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"doc_url": "https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd",
|
||||
"local_full_snapshot": "/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/lark_doc_KnCLd7xr5ohWONxhKsncZ3Lxnvd/full.md",
|
||||
"local_handoff": "/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/anygen-slides-svg-prompt-handoff.md",
|
||||
"fetched_by": "local export",
|
||||
"fetched_for": "slides +create-svglide AnyGen SVG prompt runtime experiment",
|
||||
"experiment_mode": "experiment_unrestricted_assets"
|
||||
}
|
||||
20
docs/vendor/anygen-svg/source.outline.md
vendored
20
docs/vendor/anygen-svg/source.outline.md
vendored
@@ -1,20 +0,0 @@
|
||||
# AnyGen SVG Slides Local Outline
|
||||
|
||||
Source full snapshot: `docs/vendor/anygen-svg/source.full.md`
|
||||
Source handoff: `/Users/bytedance/Documents/Codex/2026-07-01/https-bytedance-larkoffice-com-docx-kncld7xr5ohwonxhksncz3lxnvd/outputs/anygen-slides-svg-prompt-handoff.md`
|
||||
Remote doc: `https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd`
|
||||
|
||||
Required sections to split:
|
||||
|
||||
- System prompt(编排 / mode_system_prompt_svg)
|
||||
- SVG reference(协议 schema + 设计规范 / svg_reference)
|
||||
- resolve_design_brief
|
||||
- slide_outline
|
||||
- activate_slides_edit
|
||||
- slides_edit
|
||||
- finish_slides_edit
|
||||
- slide_organize
|
||||
- compute_custom_shape_bbox
|
||||
- generate_svg_chart
|
||||
- slides_convert
|
||||
- slides_parse_template
|
||||
@@ -48,22 +48,6 @@ type Factory struct {
|
||||
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
|
||||
}
|
||||
|
||||
type skipCredentialBootstrapKey struct{}
|
||||
|
||||
// ContextWithCredentialBootstrapDisabled marks a command-tree build as
|
||||
// credential-free. Use it only for purely local command surfaces that must be
|
||||
// constructed without probing strict-mode, profile, or keychain state.
|
||||
func ContextWithCredentialBootstrapDisabled(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, skipCredentialBootstrapKey{}, true)
|
||||
}
|
||||
|
||||
// IsCredentialBootstrapDisabled reports whether credential-backed bootstrap
|
||||
// probes must be skipped for this context.
|
||||
func IsCredentialBootstrapDisabled(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(skipCredentialBootstrapKey{}).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
||||
// The provider controls whether the returned instance is fresh or cached.
|
||||
func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
|
||||
@@ -125,9 +109,6 @@ func autoDetectIdentityFromHint(hint *credential.IdentityHint) core.Identity {
|
||||
}
|
||||
|
||||
func (f *Factory) resolveIdentityHint(ctx context.Context) *credential.IdentityHint {
|
||||
if IsCredentialBootstrapDisabled(ctx) {
|
||||
return nil
|
||||
}
|
||||
if f.Credential == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -167,9 +148,6 @@ func (f *Factory) CheckIdentity(as core.Identity, supported []string) error {
|
||||
// ResolveStrictMode returns the effective strict mode by reading
|
||||
// Account.SupportedIdentities from the credential provider chain.
|
||||
func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
|
||||
if IsCredentialBootstrapDisabled(ctx) {
|
||||
return core.StrictModeOff
|
||||
}
|
||||
if f.Credential == nil {
|
||||
return core.StrictModeOff
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func validatePreparedImageAssetPath(raw string) (string, error) {
|
||||
path := strings.TrimSpace(raw)
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("image asset path must not be empty")
|
||||
}
|
||||
if strings.Contains(path, `\`) {
|
||||
return "", fmt.Errorf("image asset path %q must use forward slashes", raw)
|
||||
}
|
||||
if strings.Contains(path, "%") {
|
||||
return "", fmt.Errorf("image asset path %q must not contain percent encoding", raw)
|
||||
}
|
||||
if strings.Contains(path, ":") || strings.Contains(path, "//") || isAbsoluteRunPath(path) {
|
||||
return "", fmt.Errorf("image asset path %q must be a local assets/images/<file> path", raw)
|
||||
}
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) != 3 || parts[0] != "assets" || parts[1] != "images" {
|
||||
return "", fmt.Errorf("image asset path %q must match assets/images/<file>", raw)
|
||||
}
|
||||
fileName := parts[2]
|
||||
if fileName == "" || fileName == "." || fileName == ".." {
|
||||
return "", fmt.Errorf("image asset path %q must include a file name", raw)
|
||||
}
|
||||
if strings.HasPrefix(fileName, ".") || strings.Contains(fileName, "..") {
|
||||
return "", fmt.Errorf("image asset file name %q must not contain dot segments", fileName)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidatePreparedImageAssetPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", path: "assets/images/hero.png", want: "assets/images/hero.png"},
|
||||
{name: "trim", path: " assets/images/hero.png ", want: "assets/images/hero.png"},
|
||||
{name: "empty", path: "", wantErr: true},
|
||||
{name: "remote", path: "https://example.com/hero.png", wantErr: true},
|
||||
{name: "parent directory", path: "../hero.png", wantErr: true},
|
||||
{name: "absolute", path: "/Users/example/hero.png", wantErr: true},
|
||||
{name: "file url", path: "file:///tmp/hero.png", wantErr: true},
|
||||
{name: "protocol relative", path: "//example.com/hero.png", wantErr: true},
|
||||
{name: "data url", path: "data:image/png;base64,AAAA", wantErr: true},
|
||||
{name: "percent", path: "assets/images/hero%2epng", wantErr: true},
|
||||
{name: "nested", path: "assets/images/nested/hero.png", wantErr: true},
|
||||
{name: "wrong directory", path: "assets/other/hero.png", wantErr: true},
|
||||
{name: "leading dot", path: "assets/images/.hero.png", wantErr: true},
|
||||
{name: "dot dot filename", path: "assets/images/hero..png", wantErr: true},
|
||||
{name: "backslash", path: `assets\images\hero.png`, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := validatePreparedImageAssetPath(tt.path)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got path %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("path = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSlideWidth = 960
|
||||
defaultSlideHeight = 540
|
||||
defaultAuthorBgColor = "#FFFFFF"
|
||||
defaultAuthorInkColor = "#111827"
|
||||
defaultAuthorMuteColor = "#6B7280"
|
||||
defaultAuthorAccent = "#2563EB"
|
||||
svgAuthorReceipt = "receipts/svg_author.json"
|
||||
)
|
||||
|
||||
type AuthorReport struct {
|
||||
Status string `json:"status"`
|
||||
Slides []string `json:"slides"`
|
||||
}
|
||||
|
||||
type authorDeck struct {
|
||||
Title string `json:"title"`
|
||||
Slides []authorDeckSlide `json:"slides"`
|
||||
}
|
||||
|
||||
type authorDeckSlide struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Role string `json:"role"`
|
||||
KeyMessage string `json:"key_message"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type authorSlideContentFile struct {
|
||||
Slides []authorSlideContent `json:"slides"`
|
||||
}
|
||||
|
||||
type authorSlideContent struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Notes string `json:"notes"`
|
||||
SourceRefs []string `json:"source_refs"`
|
||||
Visuals []authorSlideVisual `json:"visuals"`
|
||||
}
|
||||
|
||||
type authorSlideVisual struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Instruction string `json:"instruction"`
|
||||
}
|
||||
|
||||
type authorAssetsFile struct {
|
||||
Assets []authorAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type authorAsset struct {
|
||||
ID string `json:"id"`
|
||||
SlideID string `json:"slide_id"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
Usage string `json:"usage"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type authorVisualSystem struct {
|
||||
ColorSystem struct {
|
||||
Background string `json:"background"`
|
||||
Ink string `json:"ink"`
|
||||
Muted string `json:"muted"`
|
||||
Accent string `json:"accent"`
|
||||
} `json:"color_system"`
|
||||
Typography struct {
|
||||
Title int `json:"title"`
|
||||
Body int `json:"body"`
|
||||
} `json:"typography"`
|
||||
LayoutLanguage string `json:"layout_language"`
|
||||
}
|
||||
|
||||
type authorTheme struct {
|
||||
Background string
|
||||
Ink string
|
||||
Muted string
|
||||
Accent string
|
||||
TitleSize int
|
||||
BodySize int
|
||||
}
|
||||
|
||||
type authorSlideTarget struct {
|
||||
Slide authorDeckSlide
|
||||
Content authorSlideContent
|
||||
Assets []authorAsset
|
||||
Path string
|
||||
Target string
|
||||
Page int
|
||||
}
|
||||
|
||||
func AuthorSlides(root string) (AuthorReport, error) {
|
||||
return authorSlides(root, nil)
|
||||
}
|
||||
|
||||
func authorSlides(root string, selectedPaths map[string]bool) (AuthorReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
|
||||
deck, err := readAuthorDeck(safeRoot, strings.TrimSpace(run.Artifacts.Deck))
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
contentByID, err := readAuthorContent(safeRoot, "content/slide_content.json")
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
theme, err := readAuthorTheme(safeRoot, "brief/visual_system.json")
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
assetsBySlideID, err := readAuthorAssets(safeRoot, "assets/assets_plan.json")
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
if err := validateAuthorDeckContent(deck, contentByID); err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
|
||||
targets := make([]authorSlideTarget, 0, len(deck.Slides))
|
||||
report := AuthorReport{
|
||||
Status: StatusDone,
|
||||
Slides: make([]string, 0, len(deck.Slides)),
|
||||
}
|
||||
for i, slide := range deck.Slides {
|
||||
slidePath, err := previewSlideObjectPath(slide.Path)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
if selectedPaths != nil && !selectedPaths[slidePath] {
|
||||
continue
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
targets = append(targets, authorSlideTarget{
|
||||
Slide: slide,
|
||||
Content: contentByID[strings.TrimSpace(slide.ID)],
|
||||
Assets: selectAuthorRenderableImageAssets(safeRoot, contentByID[strings.TrimSpace(slide.ID)], assetsBySlideID[strings.TrimSpace(slide.ID)]),
|
||||
Path: slidePath,
|
||||
Target: target,
|
||||
Page: i + 1,
|
||||
})
|
||||
report.Slides = append(report.Slides, slidePath)
|
||||
}
|
||||
receiptTarget, err := ensureRunFileTargetForWrite(safeRoot, svgAuthorReceipt)
|
||||
if err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
|
||||
for _, target := range targets {
|
||||
svg := renderAuthorSVG(deck.Title, target.Slide, target.Content, target.Assets, theme, target.Page, len(deck.Slides))
|
||||
if err := writeText(target.Target, svg); err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
}
|
||||
if err := writeJSON(receiptTarget, StageReceipt{
|
||||
Stage: StageSVGAuthor,
|
||||
Status: StatusDone,
|
||||
Artifacts: report.Slides,
|
||||
}); err != nil {
|
||||
return AuthorReport{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func readAuthorDeck(safeRoot string, deckPath string) (authorDeck, error) {
|
||||
if deckPath == "" {
|
||||
return authorDeck{}, fmt.Errorf("deck artifact path is empty")
|
||||
}
|
||||
raw, err := readRunRegularArtifact(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
return authorDeck{}, fmt.Errorf("read deck %q: %w", deckPath, err)
|
||||
}
|
||||
var deck authorDeck
|
||||
if err := json.Unmarshal(raw, &deck); err != nil {
|
||||
return authorDeck{}, fmt.Errorf("read deck %q: %w", deckPath, err)
|
||||
}
|
||||
if len(deck.Slides) == 0 {
|
||||
return authorDeck{}, fmt.Errorf("deck %q contains no slides", deckPath)
|
||||
}
|
||||
return deck, nil
|
||||
}
|
||||
|
||||
func readAuthorContent(safeRoot string, path string) (map[string]authorSlideContent, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read slide content %q: %w", path, err)
|
||||
}
|
||||
var file authorSlideContentFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return nil, fmt.Errorf("read slide content %q: %w", path, err)
|
||||
}
|
||||
byID := make(map[string]authorSlideContent, len(file.Slides))
|
||||
for _, slide := range file.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("slide content id must not be empty")
|
||||
}
|
||||
if _, exists := byID[id]; exists {
|
||||
return nil, fmt.Errorf("slide content id %q is duplicated", id)
|
||||
}
|
||||
byID[id] = slide
|
||||
}
|
||||
return byID, nil
|
||||
}
|
||||
|
||||
func readAuthorAssets(safeRoot string, path string) (map[string][]authorAsset, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read assets plan %q: %w", path, err)
|
||||
}
|
||||
var file authorAssetsFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return nil, fmt.Errorf("read assets plan %q: %w", path, err)
|
||||
}
|
||||
bySlideID := make(map[string][]authorAsset, len(file.Assets))
|
||||
for _, asset := range file.Assets {
|
||||
if strings.TrimSpace(asset.Status) != "ready" {
|
||||
continue
|
||||
}
|
||||
slideID := strings.TrimSpace(asset.SlideID)
|
||||
bySlideID[slideID] = append(bySlideID[slideID], asset)
|
||||
}
|
||||
return bySlideID, nil
|
||||
}
|
||||
|
||||
func selectAuthorRenderableImageAssets(safeRoot string, content authorSlideContent, assets []authorAsset) []authorAsset {
|
||||
if len(content.Visuals) == 0 || len(assets) == 0 {
|
||||
return nil
|
||||
}
|
||||
assetByID := make(map[string]authorAsset, len(assets))
|
||||
for _, asset := range assets {
|
||||
if strings.TrimSpace(asset.Type) != "image" {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(asset.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
assetByID[id] = asset
|
||||
}
|
||||
for _, visual := range content.Visuals {
|
||||
if strings.TrimSpace(visual.Type) != "image" {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(visual.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
asset, ok := assetByID[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !authorImageAssetUsable(safeRoot, asset) {
|
||||
continue
|
||||
}
|
||||
return []authorAsset{asset}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func authorImageAssetUsable(_ string, asset authorAsset) bool {
|
||||
if strings.TrimSpace(asset.Type) != "image" {
|
||||
return false
|
||||
}
|
||||
path := strings.TrimSpace(asset.Path)
|
||||
return path != ""
|
||||
}
|
||||
|
||||
func validateAuthorDeckContent(deck authorDeck, contentByID map[string]authorSlideContent) error {
|
||||
deckIDs := make(map[string]bool, len(deck.Slides))
|
||||
for _, slide := range deck.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
if id == "" {
|
||||
return fmt.Errorf("deck slide id must not be empty")
|
||||
}
|
||||
if deckIDs[id] {
|
||||
return fmt.Errorf("deck slide id %q is duplicated", id)
|
||||
}
|
||||
deckIDs[id] = true
|
||||
if _, ok := contentByID[id]; !ok {
|
||||
return fmt.Errorf("deck slide id %q is missing from slide content", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readAuthorTheme(safeRoot string, path string) (authorTheme, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return authorTheme{}, fmt.Errorf("read visual system %q: %w", path, err)
|
||||
}
|
||||
var visual authorVisualSystem
|
||||
if err := json.Unmarshal(raw, &visual); err != nil {
|
||||
return authorTheme{}, fmt.Errorf("read visual system %q: %w", path, err)
|
||||
}
|
||||
theme := authorTheme{
|
||||
Background: normalizeAuthorColor(visual.ColorSystem.Background, defaultAuthorBgColor),
|
||||
Ink: normalizeAuthorColor(visual.ColorSystem.Ink, defaultAuthorInkColor),
|
||||
Muted: normalizeAuthorColor(visual.ColorSystem.Muted, defaultAuthorMuteColor),
|
||||
Accent: normalizeAuthorColor(visual.ColorSystem.Accent, defaultAuthorAccent),
|
||||
TitleSize: visual.Typography.Title,
|
||||
BodySize: visual.Typography.Body,
|
||||
}
|
||||
if theme.TitleSize <= 0 {
|
||||
theme.TitleSize = 32
|
||||
}
|
||||
if theme.BodySize <= 0 {
|
||||
theme.BodySize = 16
|
||||
}
|
||||
return theme, nil
|
||||
}
|
||||
|
||||
func normalizeAuthorColor(value string, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if isAllowedAuthorHexColor(value) {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func isAllowedAuthorHexColor(value string) bool {
|
||||
if len(value) != 4 && len(value) != 7 && len(value) != 9 {
|
||||
return false
|
||||
}
|
||||
if value[0] != '#' {
|
||||
return false
|
||||
}
|
||||
for _, r := range value[1:] {
|
||||
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func renderAuthorSVG(deckTitle string, slide authorDeckSlide, content authorSlideContent, assets []authorAsset, theme authorTheme, page int, total int) string {
|
||||
title := firstNonEmpty(slide.Title, "Untitled slide")
|
||||
keyMessage := firstNonEmpty(slide.KeyMessage, slide.Summary)
|
||||
bodyLines := authorBodyLines(content.Content)
|
||||
footer := strings.TrimSpace(deckTitle)
|
||||
if footer == "" {
|
||||
footer = "SVGlide"
|
||||
}
|
||||
footnote := authorSourceFootnote(content.SourceRefs)
|
||||
heroAsset := firstReadyAuthorImageAsset(assets)
|
||||
contentWidth := 848
|
||||
contentHeight := 404
|
||||
if heroAsset != nil {
|
||||
contentWidth = 500
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, `<svg xmlns="%s" xmlns:slide="%s" width="%d" height="%d" viewBox="0 0 960 540" slide:role="slide">`+"\n", svgNamespace, slideNamespace, defaultSlideWidth, defaultSlideHeight)
|
||||
fmt.Fprintf(&b, ` <rect x="0" y="0" width="960" height="540" fill="%s" data-role="background"/>`+"\n", escapeAttr(theme.Background))
|
||||
fmt.Fprintf(&b, ` <rect x="0" y="0" width="960" height="8" fill="%s"/>`+"\n", escapeAttr(theme.Accent))
|
||||
fmt.Fprintf(&b, ` <foreignObject x="56" y="48" width="%d" height="%d" slide:role="shape" slide:shape-type="text">`+"\n", contentWidth, contentHeight)
|
||||
fmt.Fprintf(&b, ` <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:Arial, Helvetica, sans-serif;color:%s;">`+"\n", escapeAttr(theme.Ink))
|
||||
fmt.Fprintf(&b, ` <div style="font-size:%dpx;font-weight:700;line-height:1.16;margin-bottom:16px;">%s</div>`+"\n", theme.TitleSize, escapeText(title))
|
||||
if keyMessage != "" {
|
||||
fmt.Fprintf(&b, ` <div style="font-size:%dpx;line-height:1.35;color:%s;margin-bottom:22px;">%s</div>`+"\n", maxInt(theme.BodySize+4, 18), escapeAttr(theme.Accent), escapeText(keyMessage))
|
||||
}
|
||||
fmt.Fprintf(&b, ` <div style="border:1px solid #E5E7EB;border-radius:6px;padding:20px 24px;min-height:190px;background:#F9FAFB;">`+"\n")
|
||||
for _, line := range bodyLines {
|
||||
fmt.Fprintf(&b, ` <div style="font-size:%dpx;line-height:1.55;margin-bottom:8px;">- %s</div>`+"\n", theme.BodySize, escapeText(line))
|
||||
}
|
||||
fmt.Fprintf(&b, " </div>\n")
|
||||
fmt.Fprintf(&b, " </div>\n")
|
||||
fmt.Fprintf(&b, " </foreignObject>\n")
|
||||
if footnote != "" {
|
||||
fmt.Fprintf(&b, ` <foreignObject x="56" y="456" width="520" height="18" slide:role="shape" slide:shape-type="text">`+"\n")
|
||||
fmt.Fprintf(&b, ` <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:Arial, Helvetica, sans-serif;color:%s;font-size:12px;line-height:1.2;">%s</div>`+"\n", escapeAttr(theme.Muted), escapeText(footnote))
|
||||
fmt.Fprintf(&b, " </foreignObject>\n")
|
||||
}
|
||||
if heroAsset != nil {
|
||||
fmt.Fprintf(&b, ` <image slide:role="image" slide:shape-type="image" href="%s" x="600" y="160" width="304" height="190"/>`+"\n", escapeAttr(heroAsset.Path))
|
||||
}
|
||||
fmt.Fprintf(&b, ` <foreignObject x="56" y="482" width="848" height="32" slide:role="shape" slide:shape-type="text">`+"\n")
|
||||
fmt.Fprintf(&b, ` <div xmlns="http://www.w3.org/1999/xhtml" style="font-family:Arial, Helvetica, sans-serif;color:%s;font-size:12px;display:flex;justify-content:space-between;">`+"\n", escapeAttr(theme.Muted))
|
||||
fmt.Fprintf(&b, " <span>%s</span><span>%d / %d</span>\n", escapeText(footer), page, total)
|
||||
fmt.Fprintf(&b, " </div>\n")
|
||||
fmt.Fprintf(&b, " </foreignObject>\n")
|
||||
fmt.Fprintf(&b, "</svg>\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func authorBodyLines(content string) []string {
|
||||
var lines []string
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return []string{"No content provided."}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func authorSourceFootnote(sourceRefs []string) string {
|
||||
if len(sourceRefs) == 0 {
|
||||
return ""
|
||||
}
|
||||
refs := make([]string, 0, len(sourceRefs))
|
||||
for _, ref := range sourceRefs {
|
||||
if trimmed := strings.TrimSpace(ref); trimmed != "" {
|
||||
refs = append(refs, trimmed)
|
||||
}
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "来源:" + strings.Join(refs, " / ")
|
||||
}
|
||||
|
||||
func firstReadyAuthorImageAsset(assets []authorAsset) *authorAsset {
|
||||
for i := range assets {
|
||||
asset := &assets[i]
|
||||
if strings.TrimSpace(asset.Type) != "image" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(asset.Path) == "" {
|
||||
continue
|
||||
}
|
||||
return asset
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func escapeText(value string) string {
|
||||
return html.EscapeString(value)
|
||||
}
|
||||
|
||||
func escapeAttr(value string) string {
|
||||
return html.EscapeString(value)
|
||||
}
|
||||
|
||||
func maxInt(a int, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1,458 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthorSlidesWritesVisibleSVGForEachDeckSlide(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/02.svg"}]}`)
|
||||
writeAuthorInputsWithAnyGenContracts(t, `{"assets":[]}`)
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
report, err := AuthorSlides("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != StatusDone {
|
||||
t.Fatalf("Status = %q, want %q", report.Status, StatusDone)
|
||||
}
|
||||
if len(report.Slides) != 2 {
|
||||
t.Fatalf("Slides len = %d, want 2: %+v", len(report.Slides), report.Slides)
|
||||
}
|
||||
receipt := readAuthorReceiptForTest(t)
|
||||
if receipt["stage"] != StageSVGAuthor {
|
||||
t.Fatalf("receipt stage = %v, want %q", receipt["stage"], StageSVGAuthor)
|
||||
}
|
||||
if receipt["status"] != StatusDone {
|
||||
t.Fatalf("receipt status = %v, want %q", receipt["status"], StatusDone)
|
||||
}
|
||||
if _, ok := receipt["artifacts"].([]any); !ok {
|
||||
t.Fatalf("receipt artifacts = %T, want array", receipt["artifacts"])
|
||||
}
|
||||
if _, ok := receipt["generated_at"]; ok {
|
||||
t.Fatalf("receipt contains generated_at, want StageReceipt-compatible schema: %+v", receipt)
|
||||
}
|
||||
|
||||
for _, rel := range []string{"slides/01.svg", "slides/02.svg"} {
|
||||
raw, err := os.ReadFile(filepath.Join("demo", rel))
|
||||
if err != nil {
|
||||
t.Fatalf("missing %s: %v", rel, err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, want := range []string{
|
||||
`slide:role="slide"`,
|
||||
`viewBox="0 0 960 540"`,
|
||||
`foreignObject`,
|
||||
`slide:role="shape"`,
|
||||
`slide:shape-type="text"`,
|
||||
} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("%s missing %q:\n%s", rel, want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false, issues: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesFallsBackForUnsafeColorTokens(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"url(https://example.com/bg.svg)","ink":"red;background:url(https://example.com/x)","muted":"not-a-color","accent":"#abc"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, banned := range []string{"url(", "https://example.com", "red;background", "not-a-color"} {
|
||||
if strings.Contains(svg, banned) {
|
||||
t.Fatalf("SVG contains unsafe color token %q:\n%s", banned, svg)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`fill="#FFFFFF"`, `color:#111827`, `color:#6B7280`, `fill="#abc"`, `color:#abc`} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("SVG missing normalized/default color %q:\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesPreflightsSlidePathsBeforeWriting(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/../02.svg"}]}`,
|
||||
)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err == nil {
|
||||
t.Fatal("expected invalid second slide path to fail")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "slides", "01.svg")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("first slide output exists after preflight failure, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRejectsMissingContentBeforeWriting(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/02.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line"}]}`)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err == nil {
|
||||
t.Fatal("expected missing slide content to fail")
|
||||
}
|
||||
for _, rel := range []string{"slides/01.svg", "receipts/svg_author.json"} {
|
||||
if _, err := os.Stat(filepath.Join("demo", rel)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("%s exists after content preflight failure, stat err = %v", rel, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRejectsDuplicateContentID(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":[],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]},{"id":"s1","content":"Duplicate body line","source_refs":[],"visuals":[{"id":"none-s1b","type":"none","instruction":"Text-only"}]}]}`)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err == nil {
|
||||
t.Fatal("expected duplicate slide content id to fail")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "receipts", "svg_author.json")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("svg_author receipt exists after duplicate content id failure, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesDoesNotRenderImageForNoneVisualDespiteReadyAsset(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), `<image slide:role="image"`) {
|
||||
t.Fatalf("visual type none should not render image:\n%s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesDoesNotRenderImageForMismatchedVisualID(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"other","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), `<image slide:role="image"`) {
|
||||
t.Fatalf("mismatched visual id should not render image:\n%s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersExperimentRemoteImageAsset(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Hero slide","summary":"Hero summary","role":"cover","key_message":"Hero key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the remote hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"https://example.com/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, want := range []string{
|
||||
`<image slide:role="image"`,
|
||||
`href="https://example.com/hero.png"`,
|
||||
} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("experiment remote image missing %q:\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesSkipsUnsupportedReadyImageAssets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
asset string
|
||||
}{
|
||||
{
|
||||
name: "diagram",
|
||||
asset: `{"assets":[{"id":"hero","slide_id":"s1","type":"diagram","path":"assets/images/hero.png","usage":"Hero diagram","status":"ready"}]}`,
|
||||
},
|
||||
{
|
||||
name: "missing",
|
||||
asset: `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"missing"}]}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", tt.asset)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), `<image slide:role="image"`) {
|
||||
t.Fatalf("unsupported asset should not render image:\n%s", string(raw))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersExistingAbsoluteImageAssetInExperiment(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
outside := filepath.Join(t.TempDir(), "hero.png")
|
||||
if err := os.WriteFile(outside, []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"`+outside+`","usage":"Hero image","status":"ready"}]}`)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), outside) || !strings.Contains(string(raw), `<image slide:role="image"`) {
|
||||
t.Fatalf("absolute asset should render image in experiment mode:\n%s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func initAuthorDemoRun(t *testing.T, visualSystem string, deck string) {
|
||||
t.Helper()
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", visualSystem)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", deck)
|
||||
writeAuthorInputsWithAnyGenContracts(t, `{"assets":[]}`)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersSourceFootnotes(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line","notes":"Speaker note","source_refs":["web1"],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, want := range []string{
|
||||
`来源`,
|
||||
`web1`,
|
||||
`slide:role="shape"`,
|
||||
} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("source footnote missing %q:\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersPreparedImageAsset(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Hero slide","summary":"Hero summary","role":"cover","key_message":"Hero key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line\nThird body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svg := string(raw)
|
||||
for _, want := range []string{
|
||||
`<image slide:role="image"`,
|
||||
`slide:shape-type="image"`,
|
||||
`href="assets/images/hero.png"`,
|
||||
} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Fatalf("prepared image asset missing %q:\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false, issues: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorSlidesRendersImageFootnoteAndMultilineBodyWithValidation(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
mustWriteTestFile(t, "demo/brief/design_brief.json", `{"narrative_spine":"A to B","depth":"medium","tone":"clear"}`)
|
||||
mustWriteTestFile(t, "demo/brief/visual_system.json", `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"Hero slide","summary":"Hero summary","role":"cover","key_message":"Hero key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line\nThird body line","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use the prepared hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := AuthorSlides("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false, issues: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func writeAuthorInputsWithAnyGenContracts(t *testing.T, assets string) {
|
||||
t.Helper()
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line","notes":"Speaker note","source_refs":["web1"],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]},{"id":"s2","content":"Point A\nPoint B\nPoint C","source_refs":["web1"],"visuals":[{"id":"none-s2","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", assets)
|
||||
}
|
||||
|
||||
func readAuthorReceiptForTest(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "svg_author.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
func mustWriteTestFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type InitOptions struct {
|
||||
Title string
|
||||
Input string
|
||||
Audience string
|
||||
DeliveryMode string
|
||||
Pages int
|
||||
Now time.Time
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
func InitRun(root string, opts InitOptions) error {
|
||||
root = strings.TrimSpace(root)
|
||||
opts.Title = strings.TrimSpace(opts.Title)
|
||||
opts.Input = strings.TrimSpace(opts.Input)
|
||||
if root == "" {
|
||||
return fmt.Errorf("out path is required")
|
||||
}
|
||||
if opts.Title == "" {
|
||||
return fmt.Errorf("title is required")
|
||||
}
|
||||
if opts.Input == "" {
|
||||
return fmt.Errorf("input is required")
|
||||
}
|
||||
safeRoot, err := validate.SafeOutputPath(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRunRoot(root, safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
safeInput, err := validate.SafeInputPath(opts.Input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateInputOutsideRunRoot(safeRoot, safeInput); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Input = safeInput
|
||||
|
||||
if opts.Overwrite {
|
||||
return initOverwrite(safeRoot, opts)
|
||||
}
|
||||
|
||||
return initNoReplace(safeRoot, opts)
|
||||
}
|
||||
|
||||
func validateRunRoot(root string, safeRoot string) error {
|
||||
if filepath.Clean(root) == "." {
|
||||
return fmt.Errorf("out path must be a child directory, got %q", root)
|
||||
}
|
||||
cwd, err := vfs.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot determine working directory: %w", err)
|
||||
}
|
||||
canonicalCwd, err := vfs.EvalSymlinks(cwd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve working directory: %w", err)
|
||||
}
|
||||
if filepath.Clean(safeRoot) == filepath.Clean(canonicalCwd) {
|
||||
return fmt.Errorf("out path must be a child directory, got %q", root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateInputOutsideRunRoot(safeRoot string, safeInput string) error {
|
||||
root := filepath.Clean(safeRoot)
|
||||
input := filepath.Clean(safeInput)
|
||||
rel, err := filepath.Rel(root, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot compare input and output paths: %w", err)
|
||||
}
|
||||
if rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) {
|
||||
return fmt.Errorf("input path %q must be outside output run directory %q", safeInput, safeRoot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initNoReplace(safeRoot string, opts InitOptions) error {
|
||||
if err := vfs.MkdirAll(filepath.Dir(safeRoot), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.Mkdir(safeRoot, 0o755); err != nil {
|
||||
return fmt.Errorf("%s already exists or cannot be created; refusing to overwrite: %w", safeRoot, err)
|
||||
}
|
||||
return writeClaimedRunDirectory(safeRoot, opts)
|
||||
}
|
||||
|
||||
func initOverwrite(safeRoot string, opts InitOptions) error {
|
||||
if err := vfs.RemoveAll(safeRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.MkdirAll(filepath.Dir(safeRoot), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.Mkdir(safeRoot, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeClaimedRunDirectory(safeRoot, opts)
|
||||
}
|
||||
|
||||
func writeClaimedRunDirectory(safeRoot string, opts InitOptions) error {
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = vfs.RemoveAll(safeRoot)
|
||||
}
|
||||
}()
|
||||
if err := writeRunDirectory(safeRoot, safeRoot, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeRunDirectory(writeRoot string, runRoot string, opts InitOptions) error {
|
||||
for _, dir := range []string{
|
||||
"request",
|
||||
"research",
|
||||
"brief",
|
||||
"outline",
|
||||
"content",
|
||||
"assets/images",
|
||||
"slides",
|
||||
"schemas",
|
||||
"receipts",
|
||||
} {
|
||||
if err := vfs.MkdirAll(filepath.Join(writeRoot, dir), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
run := NewRun(NewRunConfig{
|
||||
Title: opts.Title,
|
||||
Input: opts.Input,
|
||||
Audience: opts.Audience,
|
||||
DeliveryMode: opts.DeliveryMode,
|
||||
Pages: opts.Pages,
|
||||
Out: runRoot,
|
||||
Now: opts.Now,
|
||||
})
|
||||
run.Policy.Overwrite = opts.Overwrite
|
||||
if err := writeJSON(filepath.Join(writeRoot, "run.json"), run); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeJSON(filepath.Join(writeRoot, "request", "request.json"), map[string]any{
|
||||
"title": opts.Title,
|
||||
"input": opts.Input,
|
||||
"audience": opts.Audience,
|
||||
"delivery_mode": opts.DeliveryMode,
|
||||
"pages": opts.Pages,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeJSON(filepath.Join(writeRoot, "request", "source_manifest.json"), map[string]any{
|
||||
"sources": []map[string]string{{"path": opts.Input, "type": "local"}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeStaticFiles(writeRoot)
|
||||
}
|
||||
|
||||
func writeStaticFiles(root string) error {
|
||||
if err := writeText(filepath.Join(root, "README.md"), renderRunREADME()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePromptManifest(root); err != nil {
|
||||
return err
|
||||
}
|
||||
for name, schema := range DefaultSchemas() {
|
||||
if err := writeText(filepath.Join(root, "schemas", name), schema); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderRunREADME() string {
|
||||
var b bytes.Buffer
|
||||
b.WriteString("# SVGlide Local Run\n\n")
|
||||
b.WriteString("This directory is a local Codex-mediated SVG slides runtime. It does not publish to Feishu Slides.\n")
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInitRunWritesDirectoryContract(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
canonicalCwd, err := filepath.EvalSymlinks(cwd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := "demo"
|
||||
wantInput := filepath.Join(canonicalCwd, "source.md")
|
||||
err = InitRun(root, InitOptions{
|
||||
Title: "Demo",
|
||||
Input: "source.md",
|
||||
Audience: "产品负责人",
|
||||
DeliveryMode: "self_read",
|
||||
Pages: 8,
|
||||
Now: time.Date(2026, 7, 2, 20, 0, 0, 0, time.FixedZone("CST", 8*3600)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{
|
||||
"run.json",
|
||||
"README.md",
|
||||
"prompt_manifest.json",
|
||||
"request/request.json",
|
||||
"request/source_manifest.json",
|
||||
"research",
|
||||
"brief",
|
||||
"outline",
|
||||
"content",
|
||||
"schemas/request.schema.json",
|
||||
"schemas/deck.schema.json",
|
||||
"receipts",
|
||||
"slides",
|
||||
"assets/images",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(root, name)); err != nil {
|
||||
t.Fatalf("missing %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Title != "Demo" || run.CurrentStage != StageRequest {
|
||||
t.Fatalf("unexpected run: %+v", run)
|
||||
}
|
||||
if run.Input != wantInput {
|
||||
t.Fatalf("run.Input = %q, want %q", run.Input, wantInput)
|
||||
}
|
||||
|
||||
requestRaw, err := os.ReadFile(filepath.Join(root, "request", "request.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var request map[string]any
|
||||
if err := json.Unmarshal(requestRaw, &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request["title"] != "Demo" || request["input"] != wantInput || request["audience"] != "产品负责人" || request["delivery_mode"] != "self_read" || request["pages"] != float64(8) {
|
||||
t.Fatalf("unexpected request.json: %+v", request)
|
||||
}
|
||||
|
||||
manifestRaw, err := os.ReadFile(filepath.Join(root, "request", "source_manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var manifest struct {
|
||||
Sources []struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(manifest.Sources) != 1 || manifest.Sources[0].Path != wantInput || manifest.Sources[0].Type != "local" {
|
||||
t.Fatalf("unexpected source_manifest.json: %+v", manifest)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(root, "prompts")); !os.IsNotExist(err) {
|
||||
t.Fatalf("prompts directory should not be generated per run, stat err = %v", err)
|
||||
}
|
||||
|
||||
promptRaw, err := os.ReadFile(filepath.Join(root, "prompt_manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := string(promptRaw)
|
||||
for _, want := range []string{"mode_system_prompt_svg", "svg_reference", "tools/slides_edit.md", "tools/generate_svg_chart.md"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("prompt manifest missing %q:\n%s", want, prompt)
|
||||
}
|
||||
}
|
||||
|
||||
schemaRaw, err := os.ReadFile(filepath.Join(root, "schemas", "deck.schema.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var deckSchema map[string]any
|
||||
if err := json.Unmarshal(schemaRaw, &deckSchema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := deckSchema["properties"]; !ok || !strings.Contains(string(schemaRaw), "key_message") {
|
||||
t.Fatalf("deck schema missing properties/key_message: %s", string(schemaRaw))
|
||||
}
|
||||
if !strings.Contains(string(schemaRaw), `"minItems": 1`) || !strings.Contains(string(schemaRaw), `^slides/[^/]+\\.svg$`) {
|
||||
t.Fatalf("deck schema missing minItems/path pattern: %s", string(schemaRaw))
|
||||
}
|
||||
for _, name := range []string{
|
||||
"source_manifest.schema.json",
|
||||
"sources.schema.json",
|
||||
"slide_content.schema.json",
|
||||
"assets_plan.schema.json",
|
||||
"quality.schema.json",
|
||||
"receipt.schema.json",
|
||||
"lint.schema.json",
|
||||
"preview.schema.json",
|
||||
} {
|
||||
raw, err := os.ReadFile(filepath.Join(root, "schemas", name))
|
||||
if err != nil {
|
||||
t.Fatalf("missing schema %s: %v", name, err)
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("schema %s is not valid JSON: %v", name, err)
|
||||
}
|
||||
if schema["type"] == nil {
|
||||
t.Fatalf("schema %s missing type: %s", name, string(raw))
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
want []string
|
||||
}{
|
||||
{name: "request.schema.json", want: []string{`"purpose"`, `"language"`, `"visual_style_query"`}},
|
||||
{name: "design_brief.schema.json", want: []string{`"visual_system"`, `"narrative_spine"`, `"depth"`, `"tone"`}},
|
||||
{name: "deck.schema.json", want: []string{`"main_title"`, `"style_instruction"`, `"aesthetic_direction"`}},
|
||||
{name: "sources.schema.json", want: []string{`"retrieval"`}},
|
||||
{name: "slide_content.schema.json", want: []string{`"source_refs"`, `"visuals"`, `"chart"`, `"table"`, `"crop"`}},
|
||||
{name: "assets_plan.schema.json", want: []string{`"experiment_unrestricted_assets"`, `"slide_id"`, `"status"`, `"deferred"`, `"chart"`, `"table"`, `"crop"`}},
|
||||
{name: "quality.schema.json", want: []string{`"metrics"`}},
|
||||
} {
|
||||
raw, err := os.ReadFile(filepath.Join(root, "schemas", tc.name))
|
||||
if err != nil {
|
||||
t.Fatalf("missing schema %s: %v", tc.name, err)
|
||||
}
|
||||
text := string(raw)
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("schema %s missing %s: %s", tc.name, want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRefusesExistingRunJSON(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
root := "demo"
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "run.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md"})
|
||||
if err == nil {
|
||||
t.Fatal("expected overwrite refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRefusesExistingRootWithoutRunJSON(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
root := "demo"
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantREADME := "keep this readme\n"
|
||||
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte(wantREADME), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md"})
|
||||
gotREADME, readErr := os.ReadFile(filepath.Join(root, "README.md"))
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if string(gotREADME) != wantREADME {
|
||||
t.Fatalf("README overwritten: got %q, want %q", string(gotREADME), wantREADME)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected existing root refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunOverwriteReplacesOldRunDirectory(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
root := "demo"
|
||||
if err := os.MkdirAll(filepath.Join(root, "slides"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "slides", "old.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md", Overwrite: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "slides", "old.svg")); !os.IsNotExist(err) {
|
||||
t.Fatalf("old slide should be removed, stat err = %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !run.Policy.Overwrite {
|
||||
t.Fatalf("Policy.Overwrite = false, want true: %+v", run.Policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRejectsOverlappingInputAndOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
root string
|
||||
input string
|
||||
overwrite bool
|
||||
}{
|
||||
{name: "same path overwrite", root: "source.md", input: "source.md", overwrite: true},
|
||||
{name: "input under output overwrite", root: "demo", input: "demo/source.md", overwrite: true},
|
||||
{name: "input under output no overwrite", root: "demo", input: "demo/source.md", overwrite: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
if err := os.MkdirAll(filepath.Dir(tt.input), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(tt.input, []byte("source"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := InitRun(tt.root, InitOptions{Title: "Demo", Input: tt.input, Overwrite: tt.overwrite})
|
||||
if err == nil {
|
||||
t.Fatal("expected overlapping input/output refusal")
|
||||
}
|
||||
got, readErr := os.ReadFile(tt.input)
|
||||
if readErr != nil {
|
||||
t.Fatalf("source should remain readable: %v", readErr)
|
||||
}
|
||||
if string(got) != "source" {
|
||||
t.Fatalf("source content changed: got %q", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRejectsUnsafePaths(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
tests := []struct {
|
||||
name string
|
||||
root string
|
||||
opts InitOptions
|
||||
}{
|
||||
{name: "absolute root", root: filepath.Join(cwd, "demo"), opts: InitOptions{Title: "Demo", Input: "source.md"}},
|
||||
{name: "escaping root", root: "../escape", opts: InitOptions{Title: "Demo", Input: "source.md"}},
|
||||
{name: "escaping input", root: "demo", opts: InitOptions{Title: "Demo", Input: "../source.md"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := InitRun(tt.root, tt.opts); err == nil {
|
||||
t.Fatal("expected unsafe path refusal")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRejectsRootResolvingToCWDWhenOverwrite(t *testing.T) {
|
||||
for _, root := range []string{".", "./", "subdir/.."} {
|
||||
t.Run(root, func(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
markerPath := filepath.Join(cwd, "keep.txt")
|
||||
if err := os.WriteFile(markerPath, []byte("keep"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := InitRun(root, InitOptions{Title: "Demo", Input: "source.md", Overwrite: true})
|
||||
if err == nil {
|
||||
t.Fatal("expected root resolving to CWD to be rejected")
|
||||
}
|
||||
got, readErr := os.ReadFile(markerPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("marker should remain readable: %v", readErr)
|
||||
}
|
||||
if string(got) != "keep" {
|
||||
t.Fatalf("marker content changed: got %q", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPromptManifestContracts(t *testing.T) {
|
||||
manifest := DefaultPromptManifest()
|
||||
if manifest.Source != anyGenPromptRoot {
|
||||
t.Fatalf("Source = %q, want %q", manifest.Source, anyGenPromptRoot)
|
||||
}
|
||||
if manifest.Runtime != "codex" {
|
||||
t.Fatalf("Runtime = %q, want codex", manifest.Runtime)
|
||||
}
|
||||
entries := map[string]PromptManifestEntry{}
|
||||
for _, entry := range manifest.Entries {
|
||||
entries[entry.Name] = entry
|
||||
}
|
||||
for _, want := range []string{"anygen_svg_readme", "mode_system_prompt_svg", "svg_reference", "resolve_design_brief", "slide_outline", "activate_slides_edit", "slides_edit", "finish_slides_edit", "generate_svg_chart", "slides_convert", "slides_parse_template"} {
|
||||
if entries[want].Path == "" {
|
||||
t.Fatalf("manifest missing %q: %+v", want, manifest.Entries)
|
||||
}
|
||||
}
|
||||
if entries["anygen_svg_readme"].Path != "skills/lark-slides/references/anygen-svg/README.md" || !entries["anygen_svg_readme"].Always {
|
||||
t.Fatalf("anygen_svg_readme entry = %+v, want always README path", entries["anygen_svg_readme"])
|
||||
}
|
||||
if !entries["mode_system_prompt_svg"].Always || !entries["svg_reference"].Always {
|
||||
t.Fatalf("core prompt entries must be always available: %+v", manifest.Entries)
|
||||
}
|
||||
if entries["activate_slides_edit"].Stage != StageSVGAuthor {
|
||||
t.Fatalf("activate_slides_edit stage = %q, want %q", entries["activate_slides_edit"].Stage, StageSVGAuthor)
|
||||
}
|
||||
if entries["slides_edit"].Stage != StageSVGAuthor {
|
||||
t.Fatalf("slides_edit stage = %q, want %q", entries["slides_edit"].Stage, StageSVGAuthor)
|
||||
}
|
||||
if entries["generate_svg_chart"].Stage != StageAssets {
|
||||
t.Fatalf("generate_svg_chart stage = %q, want %q", entries["generate_svg_chart"].Stage, StageAssets)
|
||||
}
|
||||
paths := strings.Join(PromptPathsForStage(StageSVGAuthor), "\n")
|
||||
for _, want := range []string{"README.md", "mode_system_prompt_svg.md", "svg_reference.md", "tools/activate_slides_edit.md", "tools/slides_edit.md", "tools/compute_custom_shape_bbox.md"} {
|
||||
if !strings.Contains(paths, want) {
|
||||
t.Fatalf("SVG author prompt paths missing %q:\n%s", want, paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitRunRejectsBlankRequiredFields(t *testing.T) {
|
||||
blankRoot := " "
|
||||
t.Chdir(t.TempDir())
|
||||
tests := []struct {
|
||||
name string
|
||||
root string
|
||||
opts InitOptions
|
||||
}{
|
||||
{name: "root", root: blankRoot, opts: InitOptions{Title: "Demo", Input: "source.md"}},
|
||||
{name: "title", root: "title", opts: InitOptions{Title: " \t", Input: "source.md"}},
|
||||
{name: "input", root: "input", opts: InitOptions{Title: "Demo", Input: " \t"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := InitRun(tt.root, tt.opts); err == nil {
|
||||
t.Fatal("expected blank field refusal")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
func writeJSON(path string, value any) error {
|
||||
raw, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
return validate.AtomicWrite(path, raw, 0o644)
|
||||
}
|
||||
|
||||
func writeText(path string, content string) error {
|
||||
return validate.AtomicWrite(path, []byte(content), 0o644)
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
const defaultPreviewPath = "preview.html"
|
||||
const previewReceiptPath = "receipts/preview.json"
|
||||
|
||||
type PreviewReport struct {
|
||||
Status string `json:"status"`
|
||||
Slides []PreviewSlideReport `json:"slides"`
|
||||
}
|
||||
|
||||
type PreviewSlideReport struct {
|
||||
Path string `json:"path"`
|
||||
Rendered bool `json:"rendered"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type previewDeck struct {
|
||||
Title string `json:"title"`
|
||||
Slides []previewDeckSlide `json:"slides"`
|
||||
}
|
||||
|
||||
type previewDeckSlide struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Role string `json:"role"`
|
||||
KeyMessage string `json:"key_message"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type previewPageData struct {
|
||||
Title string
|
||||
Status string
|
||||
SlideCount int
|
||||
RenderedCount int
|
||||
Slides []previewPageSlide
|
||||
}
|
||||
|
||||
type previewPageSlide struct {
|
||||
Number int
|
||||
ID string
|
||||
Title string
|
||||
Summary string
|
||||
Role string
|
||||
KeyMessage string
|
||||
Path string
|
||||
Rendered bool
|
||||
Message string
|
||||
}
|
||||
|
||||
func WritePreview(root string) (PreviewReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return PreviewReport{}, err
|
||||
}
|
||||
|
||||
deckPath := strings.TrimSpace(run.Artifacts.Deck)
|
||||
if deckPath == "" {
|
||||
return writeFailedPreview(safeRoot, run, "", "deck artifact path is empty")
|
||||
}
|
||||
deckRaw, err := readRunRegularArtifact(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
return writeFailedPreview(safeRoot, run, deckPath, fmt.Sprintf("deck %q: %v", deckPath, err))
|
||||
}
|
||||
var deck previewDeck
|
||||
if err := json.Unmarshal(deckRaw, &deck); err != nil {
|
||||
return writeFailedPreview(safeRoot, run, deckPath, fmt.Sprintf("deck %q contains invalid JSON: %v", deckPath, err))
|
||||
}
|
||||
if len(deck.Slides) == 0 {
|
||||
return writeFailedPreview(safeRoot, run, deckPath, fmt.Sprintf("deck %q contains no slides", deckPath))
|
||||
}
|
||||
|
||||
report := PreviewReport{Slides: make([]PreviewSlideReport, 0, len(deck.Slides))}
|
||||
pageSlides := make([]previewPageSlide, 0, len(deck.Slides))
|
||||
for i, slide := range deck.Slides {
|
||||
slidePath, pathErr := previewSlideObjectPath(slide.Path)
|
||||
pageSlide := previewPageSlide{
|
||||
Number: i + 1,
|
||||
ID: strings.TrimSpace(slide.ID),
|
||||
Title: strings.TrimSpace(slide.Title),
|
||||
Summary: strings.TrimSpace(slide.Summary),
|
||||
Role: strings.TrimSpace(slide.Role),
|
||||
KeyMessage: strings.TrimSpace(slide.KeyMessage),
|
||||
Path: slidePath,
|
||||
}
|
||||
item := PreviewSlideReport{Path: slidePath}
|
||||
if pathErr != nil {
|
||||
item.Message = pathErr.Error()
|
||||
} else if slidePath == "" {
|
||||
item.Path = "(slide)"
|
||||
pageSlide.Path = item.Path
|
||||
item.Message = "slide path must not be empty"
|
||||
} else if _, err := readRunRegularArtifact(safeRoot, slidePath); err != nil {
|
||||
item.Message = err.Error()
|
||||
} else {
|
||||
item.Rendered = true
|
||||
pageSlide.Rendered = true
|
||||
}
|
||||
pageSlide.Message = item.Message
|
||||
report.Slides = append(report.Slides, item)
|
||||
pageSlides = append(pageSlides, pageSlide)
|
||||
}
|
||||
report = normalizePreviewReport(report)
|
||||
|
||||
if err := writePreviewArtifacts(safeRoot, run, deck.Title, report, pageSlides); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func writeFailedPreview(safeRoot string, run Run, path string, message string) (PreviewReport, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
path = "(deck)"
|
||||
}
|
||||
report := normalizePreviewReport(PreviewReport{
|
||||
Slides: []PreviewSlideReport{{
|
||||
Path: path,
|
||||
Rendered: false,
|
||||
Message: message,
|
||||
}},
|
||||
})
|
||||
pageSlides := []previewPageSlide{{
|
||||
Number: 1,
|
||||
Title: "Preview failed",
|
||||
Path: path,
|
||||
Rendered: false,
|
||||
Message: message,
|
||||
}}
|
||||
if err := writePreviewArtifacts(safeRoot, run, run.Title, report, pageSlides); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func normalizePreviewReport(report PreviewReport) PreviewReport {
|
||||
if report.Slides == nil {
|
||||
report.Slides = []PreviewSlideReport{}
|
||||
}
|
||||
report.Status = "passed"
|
||||
for i := range report.Slides {
|
||||
report.Slides[i].Path = strings.TrimSpace(report.Slides[i].Path)
|
||||
if report.Slides[i].Path == "" {
|
||||
report.Slides[i].Path = "(slide)"
|
||||
}
|
||||
if !report.Slides[i].Rendered {
|
||||
report.Status = "failed"
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func previewSlideObjectPath(path string) (string, error) {
|
||||
raw := strings.TrimSpace(path)
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("slide path must not be empty")
|
||||
}
|
||||
if strings.Contains(raw, `\`) {
|
||||
return "", fmt.Errorf("slide path %q must use forward slashes", raw)
|
||||
}
|
||||
if strings.Contains(raw, "%") {
|
||||
return "", fmt.Errorf("slide path %q must not contain percent encoding", raw)
|
||||
}
|
||||
if strings.Contains(raw, ":") || strings.Contains(raw, "//") {
|
||||
return "", fmt.Errorf("slide path %q must be a local slides/*.svg path", raw)
|
||||
}
|
||||
parts := strings.Split(raw, "/")
|
||||
if len(parts) != 2 || parts[0] != "slides" {
|
||||
return "", fmt.Errorf("slide path %q must match slides/<file>.svg", raw)
|
||||
}
|
||||
fileName := parts[1]
|
||||
if fileName == "" || fileName == "." || fileName == ".." {
|
||||
return "", fmt.Errorf("slide path %q must include a slide file name", raw)
|
||||
}
|
||||
if strings.Contains(fileName, "/") || strings.Contains(fileName, `\`) {
|
||||
return "", fmt.Errorf("slide path %q must not contain nested directories", raw)
|
||||
}
|
||||
if strings.HasPrefix(fileName, ".") || strings.Contains(fileName, "..") {
|
||||
return "", fmt.Errorf("slide path %q must not contain dot segments", raw)
|
||||
}
|
||||
if strings.ToLower(filepath.Ext(fileName)) != ".svg" {
|
||||
return "", fmt.Errorf("slide path %q must end with .svg", raw)
|
||||
}
|
||||
cleaned := filepath.ToSlash(filepath.Clean(raw))
|
||||
if cleaned != raw {
|
||||
return "", fmt.Errorf("slide path %q must already be normalized", raw)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func writePreviewArtifacts(safeRoot string, run Run, title string, report PreviewReport, slides []previewPageSlide) error {
|
||||
report = normalizePreviewReport(report)
|
||||
previewPath := strings.TrimSpace(run.Artifacts.Preview)
|
||||
if previewPath == "" {
|
||||
previewPath = defaultPreviewPath
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, previewPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
htmlRaw, err := renderPreviewHTML(title, report, slides)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validate.AtomicWrite(target, htmlRaw, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
receiptTarget, err := ensureRunFileTargetForWrite(safeRoot, previewReceiptPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(report, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
return validate.AtomicWrite(receiptTarget, raw, 0o644)
|
||||
}
|
||||
|
||||
func renderPreviewHTML(title string, report PreviewReport, slides []previewPageSlide) ([]byte, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
title = "SVGlide Preview"
|
||||
}
|
||||
var rendered int
|
||||
for _, slide := range slides {
|
||||
if slide.Rendered {
|
||||
rendered++
|
||||
}
|
||||
}
|
||||
data := previewPageData{
|
||||
Title: title,
|
||||
Status: report.Status,
|
||||
SlideCount: len(slides),
|
||||
RenderedCount: rendered,
|
||||
Slides: slides,
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := previewTemplate.Execute(&b, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
var previewTemplate = template.Must(template.New("preview").Parse(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} - SVGlide Preview</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f7f9;
|
||||
--panel: #ffffff;
|
||||
--ink: #1f2933;
|
||||
--muted: #657286;
|
||||
--line: #d8dee8;
|
||||
--accent: #1d7a62;
|
||||
--warn: #b42318;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.45;
|
||||
}
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255,255,255,.94);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 18px 24px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
padding: 3px 9px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.status.failed { background: var(--warn); }
|
||||
main {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 24px 48px;
|
||||
}
|
||||
.deck {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.slide {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 260px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
padding: 16px;
|
||||
box-shadow: 0 12px 24px rgba(31,41,51,.06);
|
||||
}
|
||||
.frame {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
object {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
.missing {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
color: var(--warn);
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
.details {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.details h2 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.label {
|
||||
color: var(--ink);
|
||||
font-weight: 650;
|
||||
}
|
||||
.path {
|
||||
overflow-wrap: anywhere;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.message { color: var(--warn); overflow-wrap: anywhere; }
|
||||
@media (max-width: 860px) {
|
||||
.bar { align-items: flex-start; flex-direction: column; gap: 8px; }
|
||||
.meta { flex-wrap: wrap; white-space: normal; }
|
||||
.slide { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="bar">
|
||||
<h1>{{.Title}}</h1>
|
||||
<div class="meta">
|
||||
<span class="status {{.Status}}">{{.Status}}</span>
|
||||
<span>{{.RenderedCount}} / {{.SlideCount}} rendered</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<section class="deck">
|
||||
{{range .Slides}}
|
||||
<article class="slide">
|
||||
<div class="frame">
|
||||
{{if .Rendered}}
|
||||
<object data="{{.Path}}" type="image/svg+xml" aria-label="{{.Title}}"></object>
|
||||
{{else}}
|
||||
<div class="missing">{{.Message}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="details">
|
||||
<h2>{{printf "%02d" .Number}}. {{.Title}}</h2>
|
||||
{{if .Summary}}<div><span class="label">Summary</span><br>{{.Summary}}</div>{{end}}
|
||||
{{if .KeyMessage}}<div><span class="label">Key Message</span><br>{{.KeyMessage}}</div>{{end}}
|
||||
{{if .Role}}<div><span class="label">Role</span><br>{{.Role}}</div>{{end}}
|
||||
<div><span class="label">Path</span><br><span class="path">{{.Path}}</span></div>
|
||||
{{if .Message}}<div class="message">{{.Message}}</div>{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
@@ -1,299 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWritePreviewWritesHTMLAndReceipt(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
|
||||
}
|
||||
if len(report.Slides) != 1 || !report.Slides[0].Rendered || report.Slides[0].Path != "slides/01.svg" {
|
||||
t.Fatalf("Slides = %+v, want rendered slides/01.svg", report.Slides)
|
||||
}
|
||||
|
||||
htmlRaw, err := os.ReadFile(filepath.Join("demo", "preview.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
html := string(htmlRaw)
|
||||
for _, want := range []string{"Demo - SVGlide Preview", `data="slides/01.svg"`, "01. Slide", "Key Message"} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Fatalf("preview.html missing %q:\n%s", want, html)
|
||||
}
|
||||
}
|
||||
|
||||
receipt := readPreviewReceipt(t)
|
||||
if receipt.Status != "passed" || len(receipt.Slides) != 1 || !receipt.Slides[0].Rendered {
|
||||
t.Fatalf("preview receipt = %+v, want passed rendered slide", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewEscapesDeckText(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeDeckAt(t, filepath.Join("demo", "outline", "deck.json"), previewDeck{
|
||||
Title: `<Deck & Demo>`,
|
||||
Slides: []previewDeckSlide{{
|
||||
ID: "cover",
|
||||
Title: `<Cover & One>`,
|
||||
Summary: `Summary <script>bad()</script>`,
|
||||
Role: "cover",
|
||||
KeyMessage: `Message & context`,
|
||||
Path: "slides/01.svg",
|
||||
}},
|
||||
})
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
if _, err := WritePreview("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
htmlRaw, err := os.ReadFile(filepath.Join("demo", "preview.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
html := string(htmlRaw)
|
||||
if strings.Contains(html, "<script>bad()</script>") {
|
||||
t.Fatalf("preview.html contains unescaped script:\n%s", html)
|
||||
}
|
||||
if !strings.Contains(html, "<script>bad()</script>") || !strings.Contains(html, "<Deck & Demo>") {
|
||||
t.Fatalf("preview.html missing escaped deck text:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewUsesRunArtifactDeckAndPreviewPath(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
run := readValidateTestRunFile(t)
|
||||
run.Artifacts.Deck = "custom/deck.json"
|
||||
run.Artifacts.Preview = "public/deck.html"
|
||||
writeValidateTestRunFile(t, run)
|
||||
writeMinimalDeck(t, "demo", "slides/missing.svg")
|
||||
writeMinimalDeckAt(t, filepath.Join("demo", "custom", "deck.json"), "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "public", "deck.html")); err != nil {
|
||||
t.Fatalf("missing custom preview path: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "preview.html")); !os.IsNotExist(err) {
|
||||
t.Fatalf("default preview should not be written when artifact path is custom, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewReportsUnsafeSlidePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
slidePath string
|
||||
filePath string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "escape",
|
||||
slidePath: "../outside.svg",
|
||||
filePath: "outside.svg",
|
||||
wantMessage: "slides/<file>.svg",
|
||||
},
|
||||
{
|
||||
name: "remote scheme",
|
||||
slidePath: "https:/evil.example/a.svg",
|
||||
filePath: filepath.Join("demo", "https:", "evil.example", "a.svg"),
|
||||
wantMessage: "local slides/*.svg",
|
||||
},
|
||||
{
|
||||
name: "encoded dot segment",
|
||||
slidePath: "slides/%2e%2e.svg",
|
||||
filePath: filepath.Join("demo", "slides", "%2e%2e.svg"),
|
||||
wantMessage: "percent encoding",
|
||||
},
|
||||
{
|
||||
name: "nested directory",
|
||||
slidePath: "slides/nested/01.svg",
|
||||
filePath: filepath.Join("demo", "slides", "nested", "01.svg"),
|
||||
wantMessage: "slides/<file>.svg",
|
||||
},
|
||||
{
|
||||
name: "backslash",
|
||||
slidePath: `slides\01.svg`,
|
||||
filePath: filepath.Join("demo", `slides\01.svg`),
|
||||
wantMessage: "forward slashes",
|
||||
},
|
||||
{
|
||||
name: "wrong extension",
|
||||
slidePath: "slides/01.png",
|
||||
filePath: filepath.Join("demo", "slides", "01.png"),
|
||||
wantMessage: ".svg",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", tt.slidePath)
|
||||
writeValidateTestFile(t, tt.filePath, visibleTextSVG())
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if len(report.Slides) != 1 || report.Slides[0].Rendered {
|
||||
t.Fatalf("Slides = %+v, want unrendered slide", report.Slides)
|
||||
}
|
||||
if !strings.Contains(report.Slides[0].Message, tt.wantMessage) {
|
||||
t.Fatalf("Message = %q, want %q", report.Slides[0].Message, tt.wantMessage)
|
||||
}
|
||||
receipt := readPreviewReceipt(t)
|
||||
if receipt.Status != "failed" || len(receipt.Slides) != 1 || receipt.Slides[0].Rendered {
|
||||
t.Fatalf("preview receipt = %+v, want failed unrendered slide", receipt)
|
||||
}
|
||||
htmlRaw, err := os.ReadFile(filepath.Join("demo", "preview.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(htmlRaw), `data="`) {
|
||||
t.Fatalf("preview should not embed unsafe slide path:\n%s", string(htmlRaw))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewWritesFailureArtifactsForDeckReadFailures(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := WritePreview("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "preview.html")); err != nil {
|
||||
t.Fatalf("missing preview.html for failed deck read: %v", err)
|
||||
}
|
||||
receipt := readPreviewReceipt(t)
|
||||
if receipt.Status != "failed" || len(receipt.Slides) != 1 || receipt.Slides[0].Path != "outline/deck.json" {
|
||||
t.Fatalf("preview receipt = %+v, want failed deck report", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewRejectsPreviewSymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-preview.html")
|
||||
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join("demo", "preview.html")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "preview.html")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := WritePreview("demo"); err == nil {
|
||||
t.Fatal("expected preview symlink write refusal")
|
||||
}
|
||||
raw, err := os.ReadFile(outside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "outside" {
|
||||
t.Fatalf("outside preview overwritten: %q", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewRejectsPreviewReceiptSymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-preview.json")
|
||||
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join("demo", "receipts", "preview.json")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "receipts", "preview.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := WritePreview("demo"); err == nil {
|
||||
t.Fatal("expected preview receipt symlink write refusal")
|
||||
}
|
||||
raw, err := os.ReadFile(outside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "outside" {
|
||||
t.Fatalf("outside preview receipt overwritten: %q", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePreviewRejectsPreviewReceiptsDirectorySymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
if err := os.RemoveAll(filepath.Join("demo", "receipts")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-preview-receipts")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "receipts")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := WritePreview("demo"); err == nil {
|
||||
t.Fatal("expected preview receipts directory symlink write refusal")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "preview.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("preview receipt should not be written outside run root, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readPreviewReceipt(t *testing.T) PreviewReport {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "preview.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var receipt PreviewReport
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
func writeDeckAt(t *testing.T, path string, deck previewDeck) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(deck, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
writeValidateTestFile(t, path, string(raw))
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
package svglide
|
||||
|
||||
func DefaultSchemas() map[string]string {
|
||||
return map[string]string{
|
||||
"request.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["title", "input"],
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"input": {"type": "string"},
|
||||
"purpose": {"type": "string"},
|
||||
"audience": {"type": "string"},
|
||||
"delivery_mode": {"type": "string"},
|
||||
"language": {"type": "string"},
|
||||
"pages": {"type": "integer"},
|
||||
"visual_style_query": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"source_manifest.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["sources"],
|
||||
"properties": {
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "type"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["local"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"sources.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["sources"],
|
||||
"properties": {
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "path", "title", "excerpt", "usage", "retrieval"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"excerpt": {"type": "string"},
|
||||
"usage": {"type": "string"},
|
||||
"retrieval": {"type": "string", "enum": ["full_page", "local_file", "user_provided"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"design_brief.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["narrative_spine", "depth", "tone", "visual_system"],
|
||||
"properties": {
|
||||
"design_rationale": {"type": "string"},
|
||||
"narrative_spine": {"type": "object"},
|
||||
"depth": {"type": "object"},
|
||||
"tone": {"type": "string"},
|
||||
"visual_system": {
|
||||
"type": "object",
|
||||
"required": ["color_system", "typography", "layout_language"],
|
||||
"properties": {
|
||||
"color_system": {"type": "object"},
|
||||
"typography": {"type": "object"},
|
||||
"layout_language": {"type": "object"},
|
||||
"imagery_treatment": {"type": "object"},
|
||||
"material_texture": {"type": "object"},
|
||||
"decoration_language": {"type": "object"},
|
||||
"mood_coordinates": {"type": "object"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"visual_system.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["color_system", "typography", "layout_language"],
|
||||
"properties": {
|
||||
"color_system": {"type": "object"},
|
||||
"typography": {"type": "object"},
|
||||
"layout_language": {"type": "object"},
|
||||
"imagery_treatment": {"type": "object"},
|
||||
"material_texture": {"type": "object"},
|
||||
"decoration_language": {"type": "object"},
|
||||
"mood_coordinates": {"type": "object"}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"deck.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["main_title", "style_instruction", "slides"],
|
||||
"properties": {
|
||||
"main_title": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"style_instruction": {
|
||||
"type": "object",
|
||||
"required": ["aesthetic_direction", "color_palette", "typography"],
|
||||
"properties": {
|
||||
"aesthetic_direction": {"type": "string"},
|
||||
"color_palette": {"type": "object"},
|
||||
"typography": {"type": "object"}
|
||||
}
|
||||
},
|
||||
"slides": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "title", "summary", "role", "key_message", "path"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"page_title": {"type": "string"},
|
||||
"summary": {"type": "string"},
|
||||
"role": {"type": "string"},
|
||||
"key_message": {"type": "string"},
|
||||
"path": {"type": "string", "pattern": "^slides/[^/]+\\.svg$"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"slide_content.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["slides"],
|
||||
"properties": {
|
||||
"slides": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "content", "source_refs", "visuals"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
"notes": {"type": "string"},
|
||||
"source_refs": {"type": "array", "items": {"type": "string"}},
|
||||
"visuals": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "type", "instruction"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["image", "diagram", "icon", "chart", "table", "crop", "none"]},
|
||||
"instruction": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"assets_plan.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["mode", "assets"],
|
||||
"properties": {
|
||||
"mode": {"type": "string", "enum": ["experiment_unrestricted_assets"]},
|
||||
"assets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "slide_id", "type", "path", "usage", "status"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"slide_id": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["image", "diagram", "icon", "chart", "table", "crop"]},
|
||||
"path": {"type": "string"},
|
||||
"usage": {"type": "string"},
|
||||
"status": {"type": "string", "enum": ["ready", "missing", "deferred"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"quality.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "issues", "metrics"],
|
||||
"properties": {
|
||||
"status": {"type": "string", "enum": ["passed", "failed"]},
|
||||
"issues": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "code", "message", "severity"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"code": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
"severity": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["slides", "sources", "web_sources", "assets", "slides_with_source_refs", "slides_with_visuals"],
|
||||
"properties": {
|
||||
"slides": {"type": "integer"},
|
||||
"sources": {"type": "integer"},
|
||||
"web_sources": {"type": "integer"},
|
||||
"assets": {"type": "integer"},
|
||||
"slides_with_source_refs": {"type": "integer"},
|
||||
"slides_with_visuals": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"receipt.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["stage", "status"],
|
||||
"properties": {
|
||||
"stage": {"type": "string"},
|
||||
"status": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
"artifacts": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"lint.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "issues"],
|
||||
"properties": {
|
||||
"status": {"type": "string"},
|
||||
"issues": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "code", "message"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"code": {"type": "string"},
|
||||
"message": {"type": "string"},
|
||||
"severity": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
"preview.schema.json": `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["status", "slides"],
|
||||
"properties": {
|
||||
"status": {"type": "string"},
|
||||
"slides": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "rendered"],
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"rendered": {"type": "boolean"},
|
||||
"message": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
const anyGenPromptRoot = "skills/lark-slides/references/anygen-svg"
|
||||
|
||||
type PromptManifest struct {
|
||||
Source string `json:"source"`
|
||||
Runtime string `json:"runtime"`
|
||||
Entries []PromptManifestEntry `json:"entries"`
|
||||
}
|
||||
|
||||
type PromptManifestEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Always bool `json:"always,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultPromptManifest() PromptManifest {
|
||||
return PromptManifest{
|
||||
Source: anyGenPromptRoot,
|
||||
Runtime: "codex",
|
||||
Entries: []PromptManifestEntry{
|
||||
{Name: "anygen_svg_readme", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "README.md")), Always: true},
|
||||
{Name: "mode_system_prompt_svg", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "mode_system_prompt_svg.md")), Always: true},
|
||||
{Name: "svg_reference", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "svg_reference.md")), Always: true},
|
||||
{Name: "resolve_design_brief", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "resolve_design_brief.md")), Stage: StageDesignBrief},
|
||||
{Name: "slide_outline", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "slide_outline.md")), Stage: StageOutline},
|
||||
{Name: "activate_slides_edit", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "activate_slides_edit.md")), Stage: StageSVGAuthor},
|
||||
{Name: "slides_edit", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "slides_edit.md")), Stage: StageSVGAuthor},
|
||||
{Name: "finish_slides_edit", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "finish_slides_edit.md")), Stage: StageValidatePreviewRepair},
|
||||
{Name: "slide_organize", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "slide_organize.md")), Stage: StageOutline},
|
||||
{Name: "compute_custom_shape_bbox", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "compute_custom_shape_bbox.md")), Stage: StageSVGAuthor},
|
||||
{Name: "generate_svg_chart", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "generate_svg_chart.md")), Stage: StageAssets},
|
||||
{Name: "slides_convert", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "slides_convert.md"))},
|
||||
{Name: "slides_parse_template", Path: filepath.ToSlash(filepath.Join(anyGenPromptRoot, "tools", "slides_parse_template.md"))},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func PromptPathsForStage(stage string) []string {
|
||||
manifest := DefaultPromptManifest()
|
||||
paths := make([]string, 0, len(manifest.Entries))
|
||||
for _, entry := range manifest.Entries {
|
||||
if entry.Always || entry.Stage == stage {
|
||||
paths = append(paths, entry.Path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func writePromptManifest(root string) error {
|
||||
return writeJSON(filepath.Join(root, "prompt_manifest.json"), DefaultPromptManifest())
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type QualityReport struct {
|
||||
Status string `json:"status"`
|
||||
Issues []QualityIssue `json:"issues"`
|
||||
Metrics QualityMetrics `json:"metrics"`
|
||||
}
|
||||
|
||||
type QualityIssue struct {
|
||||
Path string `json:"path"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
type QualityMetrics struct {
|
||||
Slides int `json:"slides"`
|
||||
Sources int `json:"sources"`
|
||||
WebSources int `json:"web_sources"`
|
||||
Assets int `json:"assets"`
|
||||
SlidesWithSourceRef int `json:"slides_with_source_refs"`
|
||||
SlidesWithVisuals int `json:"slides_with_visuals"`
|
||||
}
|
||||
|
||||
type qualitySourcesFile struct {
|
||||
Sources []qualitySource `json:"sources"`
|
||||
}
|
||||
|
||||
type qualitySource struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Title string `json:"title"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
Usage string `json:"usage"`
|
||||
Retrieval string `json:"retrieval"`
|
||||
}
|
||||
|
||||
type qualityContentFile struct {
|
||||
Slides []qualityContentSlide `json:"slides"`
|
||||
}
|
||||
|
||||
type qualityContentSlide struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
SourceRefs []string `json:"source_refs"`
|
||||
Visuals []qualityVisual `json:"visuals"`
|
||||
}
|
||||
|
||||
type qualityVisual struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Instruction string `json:"instruction"`
|
||||
}
|
||||
|
||||
type qualityAssetsFile struct {
|
||||
Assets []qualityAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type qualityAsset struct {
|
||||
ID string `json:"id"`
|
||||
SlideID string `json:"slide_id"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
Usage string `json:"usage"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func CheckQuality(root string) (QualityReport, error) {
|
||||
safeRoot, _, err := readRun(root)
|
||||
if err != nil {
|
||||
return QualityReport{}, err
|
||||
}
|
||||
|
||||
deck, err := readAuthorDeck(safeRoot, "outline/deck.json")
|
||||
if err != nil {
|
||||
return QualityReport{}, err
|
||||
}
|
||||
sources, err := readQualitySources(safeRoot)
|
||||
if err != nil {
|
||||
return QualityReport{}, err
|
||||
}
|
||||
content, err := readQualityContent(safeRoot)
|
||||
if err != nil {
|
||||
return QualityReport{}, err
|
||||
}
|
||||
assets, err := readQualityAssets(safeRoot)
|
||||
if err != nil {
|
||||
return QualityReport{}, err
|
||||
}
|
||||
|
||||
report := QualityReport{
|
||||
Status: "passed",
|
||||
Issues: []QualityIssue{},
|
||||
Metrics: QualityMetrics{},
|
||||
}
|
||||
report.Metrics.Slides = len(deck.Slides)
|
||||
report.Metrics.Sources = len(sources.Sources)
|
||||
report.Metrics.Assets = len(assets.Assets)
|
||||
|
||||
sourceIDs := make(map[string]bool, len(sources.Sources))
|
||||
hasLocalOrUserProvidedSource := false
|
||||
for _, source := range sources.Sources {
|
||||
id := strings.TrimSpace(source.ID)
|
||||
if id != "" {
|
||||
sourceIDs[id] = true
|
||||
}
|
||||
retrieval := strings.TrimSpace(source.Retrieval)
|
||||
path := strings.TrimSpace(source.Path)
|
||||
if retrieval == "full_page" && (strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://")) {
|
||||
report.Metrics.WebSources++
|
||||
}
|
||||
if retrieval == "local_file" || retrieval == "user_provided" {
|
||||
hasLocalOrUserProvidedSource = true
|
||||
}
|
||||
}
|
||||
if report.Metrics.WebSources == 0 && !hasLocalOrUserProvidedSource {
|
||||
report.Issues = append(report.Issues, qualityIssue(
|
||||
"research/sources.json",
|
||||
"svglide.quality.research",
|
||||
"topic decks need at least one full_page web source or explicit local/user-provided source",
|
||||
))
|
||||
}
|
||||
|
||||
assetsBySlideAndID := make(map[string]qualityAsset, len(assets.Assets))
|
||||
deferredBySlideAndID := make(map[string]qualityAsset, len(assets.Assets))
|
||||
for _, asset := range assets.Assets {
|
||||
status := strings.TrimSpace(asset.Status)
|
||||
key := strings.TrimSpace(asset.SlideID) + "/" + strings.TrimSpace(asset.ID)
|
||||
if status == "deferred" {
|
||||
deferredBySlideAndID[key] = asset
|
||||
continue
|
||||
}
|
||||
if status != "ready" {
|
||||
continue
|
||||
}
|
||||
assetsBySlideAndID[key] = asset
|
||||
}
|
||||
|
||||
contentByID := make(map[string]qualityContentSlide, len(content.Slides))
|
||||
for _, slide := range content.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
contentByID[id] = slide
|
||||
}
|
||||
|
||||
for _, slide := range deck.Slides {
|
||||
id := strings.TrimSpace(slide.ID)
|
||||
item, ok := contentByID[id]
|
||||
if !ok {
|
||||
report.Issues = append(report.Issues, qualityIssue(
|
||||
"content/slide_content.json",
|
||||
"svglide.quality.content",
|
||||
fmt.Sprintf("deck slide %q is missing content", id),
|
||||
))
|
||||
continue
|
||||
}
|
||||
if len(item.SourceRefs) > 0 {
|
||||
report.Metrics.SlidesWithSourceRef++
|
||||
} else {
|
||||
report.Issues = append(report.Issues, qualityIssue(
|
||||
"content/slide_content.json",
|
||||
"svglide.quality.source_refs",
|
||||
fmt.Sprintf("slide %q has no source_refs", id),
|
||||
))
|
||||
}
|
||||
for _, ref := range item.SourceRefs {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" || !sourceIDs[ref] {
|
||||
report.Issues = append(report.Issues, qualityIssue(
|
||||
"content/slide_content.json",
|
||||
"svglide.quality.source_refs",
|
||||
fmt.Sprintf("slide %q references unknown source %q", id, ref),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if len(item.Visuals) == 0 {
|
||||
report.Issues = append(report.Issues, qualityIssue(
|
||||
"content/slide_content.json",
|
||||
"svglide.quality.visuals",
|
||||
fmt.Sprintf("slide %q has no visuals; use a type=none sentinel when no visual asset is needed", id),
|
||||
))
|
||||
}
|
||||
|
||||
hasVisual := false
|
||||
for _, visual := range item.Visuals {
|
||||
visualType := strings.TrimSpace(visual.Type)
|
||||
if visualType == "none" {
|
||||
continue
|
||||
}
|
||||
hasVisual = true
|
||||
key := id + "/" + strings.TrimSpace(visual.ID)
|
||||
asset, ok := assetsBySlideAndID[key]
|
||||
if !ok && visualTypeIsDeferredOnly(visualType) {
|
||||
if deferredAsset, deferred := deferredBySlideAndID[key]; deferred {
|
||||
asset = deferredAsset
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
report.Issues = append(report.Issues, qualityIssue(
|
||||
"assets/assets_plan.json",
|
||||
"svglide.quality.asset",
|
||||
fmt.Sprintf("slide %q visual %q has no ready asset", id, visual.ID),
|
||||
))
|
||||
continue
|
||||
}
|
||||
assetType := strings.TrimSpace(asset.Type)
|
||||
if assetType != visualType {
|
||||
report.Issues = append(report.Issues, qualityIssue(
|
||||
"assets/assets_plan.json",
|
||||
"svglide.quality.asset",
|
||||
fmt.Sprintf("slide %q visual %q type %q has ready asset type %q", id, visual.ID, visualType, assetType),
|
||||
))
|
||||
}
|
||||
}
|
||||
if hasVisual {
|
||||
report.Metrics.SlidesWithVisuals++
|
||||
}
|
||||
}
|
||||
|
||||
if len(report.Issues) > 0 {
|
||||
report.Status = "failed"
|
||||
}
|
||||
|
||||
if err := writeJSON(filepath.Join(safeRoot, "quality_report.json"), report); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func visualTypeIsDeferredOnly(value string) bool {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "chart", "table", "crop":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func readQualitySources(safeRoot string) (qualitySourcesFile, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, "research/sources.json")
|
||||
if err != nil {
|
||||
return qualitySourcesFile{}, err
|
||||
}
|
||||
var file qualitySourcesFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return qualitySourcesFile{}, fmt.Errorf("read sources %q: %w", "research/sources.json", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func readQualityContent(safeRoot string) (qualityContentFile, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, "content/slide_content.json")
|
||||
if err != nil {
|
||||
return qualityContentFile{}, err
|
||||
}
|
||||
var file qualityContentFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return qualityContentFile{}, fmt.Errorf("read slide content %q: %w", "content/slide_content.json", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func readQualityAssets(safeRoot string) (qualityAssetsFile, error) {
|
||||
raw, err := readRunRegularArtifact(safeRoot, "assets/assets_plan.json")
|
||||
if err != nil {
|
||||
return qualityAssetsFile{}, err
|
||||
}
|
||||
var file qualityAssetsFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return qualityAssetsFile{}, fmt.Errorf("read assets plan %q: %w", "assets/assets_plan.json", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func qualityIssue(path, code, message string) QualityIssue {
|
||||
return QualityIssue{
|
||||
Path: path,
|
||||
Code: code,
|
||||
Message: message,
|
||||
Severity: "error",
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckQualityAllowsExplicitLocalSourceWithoutFullPageWebSource(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"local1","path":"source.md","title":"Local Source","excerpt":"Input","usage":"Support","retrieval":"local_file"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["local1"],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed; issues = %+v", report.Status, report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsTopicDeckWithoutFullPageWebOrExplicitLocalSource(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"source1","path":"source.md","title":"Weak Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["source1"],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.research") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.research", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsSlideContentWithoutSourceRefs(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":[],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.source_refs") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.source_refs", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsMissingVisualAsset(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.asset") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.asset", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityPassesAnyGenReadyRun(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed", report.Status)
|
||||
}
|
||||
if len(report.Issues) != 0 {
|
||||
t.Fatalf("issues = %+v, want empty", report.Issues)
|
||||
}
|
||||
if report.Metrics.Slides != 1 || report.Metrics.Sources != 1 || report.Metrics.WebSources != 1 || report.Metrics.Assets != 1 || report.Metrics.SlidesWithSourceRef != 1 || report.Metrics.SlidesWithVisuals != 1 {
|
||||
t.Fatalf("metrics = %+v, want all ones", report.Metrics)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "quality_report.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing quality_report.json: %v", err)
|
||||
}
|
||||
var written QualityReport
|
||||
if err := json.Unmarshal(raw, &written); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if written.Status != "passed" {
|
||||
t.Fatalf("written status = %q, want passed", written.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityAllowsExperimentAssetsAndDeferredUnsupportedVisuals(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/report","title":"Report","excerpt":"Full page excerpt","usage":"evidence","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"main_title":"Demo Deck","style_instruction":{"aesthetic_direction":"Editorial report","color_palette":{},"typography":{}},"slides":[{"id":"s1","title":"Chart claim","summary":"Needs chart later","role":"content","key_message":"Chart is deferred","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Chart-backed point","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Use a remote hero image"},{"id":"chart1","type":"chart","instruction":"Use a real chart when chart generation is enabled"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"mode":"experiment_unrestricted_assets","assets":[{"id":"hero","slide_id":"s1","type":"image","path":"https://example.com/hero.png","usage":"Hero image","status":"ready"},{"id":"chart1","slide_id":"s1","type":"chart","path":"","usage":"Deferred chart generation","status":"deferred"}]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityAllowsAbsoluteReadyAssetPathInExperiment(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
outside := filepath.Join(t.TempDir(), "hero.png")
|
||||
if err := os.WriteFile(outside, []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"`+outside+`","usage":"Hero image","status":"ready"}]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed; issues = %+v", report.Status, report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsEmptyVisuals(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.visuals") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.visuals", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityRejectsVisualAssetTypeMismatch(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"diagram","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("status = %q, want failed", report.Status)
|
||||
}
|
||||
if !qualityIssueCodesContain(report.Issues, "svglide.quality.asset") {
|
||||
t.Fatalf("issues = %+v, want svglide.quality.asset", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityCountsSlidesWithVisualsPerPage(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"},{"id":"logo","type":"diagram","instruction":"Support diagram"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"},{"id":"logo","slide_id":"s1","type":"diagram","path":"assets/images/logo.svg","usage":"Support diagram","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "images", "logo.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed", report.Status)
|
||||
}
|
||||
if report.Metrics.SlidesWithVisuals != 1 {
|
||||
t.Fatalf("metrics.slides_with_visuals = %d, want 1", report.Metrics.SlidesWithVisuals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityAllowsSymlinkReadyAssetPathInExperiment(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"hero","type":"image","instruction":"Hero image"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[{"id":"hero","slide_id":"s1","type":"image","path":"assets/images/hero.png","usage":"Hero image","status":"ready"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "assets", "images"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("outside-hero.png"), []byte("png"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join("..", "..", "outside-hero.png"), filepath.Join("demo", "assets", "images", "hero.png")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed; issues = %+v", report.Status, report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQualityUsesOutlineDeckNotRunArtifactDeck(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.Artifacts.Deck = "custom/deck.json"
|
||||
writeStatusTestRunFile(t, run)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", `{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`)
|
||||
if err := os.MkdirAll(filepath.Join("demo", "custom"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, "demo/custom/deck.json", `{"title":"Custom Deck","slides":[{"id":"c1","title":"Custom 1","summary":"Custom summary 1","role":"cover","key_message":"Custom key 1","path":"slides/01.svg"},{"id":"c2","title":"Custom 2","summary":"Custom summary 2","role":"content","key_message":"Custom key 2","path":"slides/02.svg"}]}`)
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"web1","path":"https://example.com/page","title":"Web Source","excerpt":"Input","usage":"Support","retrieval":"full_page"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"Claim","source_refs":["web1"],"visuals":[{"id":"v1","type":"none","instruction":"Text-only"}]},{"id":"c1","content":"Custom claim 1","source_refs":["web1"],"visuals":[{"id":"v2","type":"none","instruction":"Text-only"}]},{"id":"c2","content":"Custom claim 2","source_refs":["web1"],"visuals":[{"id":"v3","type":"none","instruction":"Text-only"}]}]}`)
|
||||
mustWriteTestFile(t, "demo/assets/assets_plan.json", `{"assets":[]}`)
|
||||
|
||||
report, err := CheckQuality("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("status = %q, want passed", report.Status)
|
||||
}
|
||||
if report.Metrics.Slides != 1 {
|
||||
t.Fatalf("metrics.slides = %d, want 1 from outline/deck.json", report.Metrics.Slides)
|
||||
}
|
||||
}
|
||||
|
||||
func qualityIssueCodesContain(issues []QualityIssue, want string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Code == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type validationLintReceipt struct {
|
||||
Status string `json:"status"`
|
||||
Issues []ValidationIssue `json:"issues"`
|
||||
}
|
||||
|
||||
func writeValidationArtifacts(safeRoot string, report ValidationReport) error {
|
||||
report = normalizeValidationReport(report)
|
||||
lintPath, err := ensureRunFileTargetForWrite(safeRoot, "receipts/lint.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(validationLintReceipt{
|
||||
Status: validationReceiptStatus(report),
|
||||
Issues: report.Issues,
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := validate.AtomicWrite(lintPath, raw, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
queuePath, err := ensureRunFileTargetForWrite(safeRoot, "repair_queue.md")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(queuePath, []byte(renderRepairQueue(report)), 0o644)
|
||||
}
|
||||
|
||||
func normalizeValidationReport(report ValidationReport) ValidationReport {
|
||||
if report.Issues == nil {
|
||||
report.Issues = []ValidationIssue{}
|
||||
}
|
||||
report.OK = len(report.Issues) == 0
|
||||
for i := range report.Issues {
|
||||
report.Issues[i].Path = strings.TrimSpace(report.Issues[i].Path)
|
||||
if report.Issues[i].Path == "" {
|
||||
report.Issues[i].Path = "(deck)"
|
||||
}
|
||||
report.Issues[i].Code = strings.TrimSpace(report.Issues[i].Code)
|
||||
if report.Issues[i].Code == "" {
|
||||
report.Issues[i].Code = "svglide.validation"
|
||||
}
|
||||
report.Issues[i].Severity = strings.TrimSpace(report.Issues[i].Severity)
|
||||
if report.Issues[i].Severity == "" {
|
||||
report.Issues[i].Severity = "error"
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func validationReceiptStatus(report ValidationReport) string {
|
||||
if report.OK {
|
||||
return "passed"
|
||||
}
|
||||
return "failed"
|
||||
}
|
||||
|
||||
func renderRepairQueue(report ValidationReport) string {
|
||||
if report.OK {
|
||||
return "No repair needed.\n"
|
||||
}
|
||||
var b bytes.Buffer
|
||||
b.WriteString("# SVGlide Repair Queue\n\n")
|
||||
for _, issue := range report.Issues {
|
||||
fmt.Fprintf(&b, "- `%s` [%s]: %s\n", issue.Path, issue.Code, issue.Message)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func ensureRunFileTargetForWrite(safeRoot string, rel string) (string, error) {
|
||||
cleanRel := filepath.Clean(rel)
|
||||
if cleanRel == "." {
|
||||
return "", fmt.Errorf("run file path must not be root")
|
||||
}
|
||||
dirRel := filepath.Dir(cleanRel)
|
||||
if _, err := ensureRunDirectoryForWrite(safeRoot, dirRel); err != nil {
|
||||
return "", err
|
||||
}
|
||||
path, err := safeRunPath(safeRoot, cleanRel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := vfs.Lstat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return path, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("run file path %q must not be a symlink", rel)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("run file path %q must be a regular file", rel)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func ensureRunDirectoryForWrite(safeRoot string, rel string) (string, error) {
|
||||
path, err := safeRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
if cleanRel == "." {
|
||||
return path, nil
|
||||
}
|
||||
parts := strings.Split(cleanRel, string(filepath.Separator))
|
||||
cur := safeRoot
|
||||
for i, part := range parts {
|
||||
if part == "" || part == "." {
|
||||
continue
|
||||
}
|
||||
cur = filepath.Join(cur, part)
|
||||
info, err := vfs.Lstat(cur)
|
||||
if err != nil {
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
if err := vfs.Mkdir(cur, 0o755); err != nil {
|
||||
info, err = vfs.Lstat(cur)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("run directory path %q must not contain symlink component %q", rel, filepath.Join(parts[:i+1]...))
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("run directory path %q component %q is not a directory", rel, filepath.Join(parts[:i+1]...))
|
||||
}
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RepairReport struct {
|
||||
Status string `json:"status"`
|
||||
LintOK bool `json:"lint_ok"`
|
||||
Preview string `json:"preview"`
|
||||
Quality string `json:"quality"`
|
||||
Reauthored bool `json:"reauthored"`
|
||||
}
|
||||
|
||||
func RepairRun(root string) (RepairReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
|
||||
lint, validateErr := ValidateRun(root)
|
||||
if validateErr != nil {
|
||||
return RepairReport{}, validateErr
|
||||
}
|
||||
|
||||
reauthored := false
|
||||
if !lint.OK {
|
||||
repairPaths, ok := authorRepairPaths(lint)
|
||||
if ok {
|
||||
if _, err := authorSlides(root, repairPaths); err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
reauthored = true
|
||||
lint, validateErr = ValidateRun(root)
|
||||
}
|
||||
if validateErr != nil {
|
||||
return RepairReport{}, validateErr
|
||||
}
|
||||
}
|
||||
|
||||
preview, err := WritePreview(root)
|
||||
if err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
quality, err := CheckQuality(root)
|
||||
if err != nil {
|
||||
return RepairReport{}, err
|
||||
}
|
||||
|
||||
report := RepairReport{
|
||||
Status: "failed",
|
||||
LintOK: lint.OK,
|
||||
Preview: preview.Status,
|
||||
Quality: quality.Status,
|
||||
Reauthored: reauthored,
|
||||
}
|
||||
if report.LintOK && report.Preview == "passed" && report.Quality == "passed" {
|
||||
report.Status = "passed"
|
||||
}
|
||||
|
||||
previewPath := strings.TrimSpace(run.Artifacts.Preview)
|
||||
if previewPath == "" {
|
||||
previewPath = defaultPreviewPath
|
||||
}
|
||||
if err := writeStageReceipt(safeRoot, StageReceipt{
|
||||
Stage: StageValidatePreviewRepair,
|
||||
Status: report.Status,
|
||||
Message: repairReceiptMessage(report),
|
||||
Artifacts: []string{
|
||||
"receipts/lint.json",
|
||||
"receipts/preview.json",
|
||||
"quality_report.json",
|
||||
"repair_queue.md",
|
||||
previewPath,
|
||||
},
|
||||
}); err != nil {
|
||||
return report, err
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func canRepairByAuthoring(report ValidationReport) bool {
|
||||
_, ok := authorRepairPaths(report)
|
||||
return ok
|
||||
}
|
||||
|
||||
func authorRepairPaths(report ValidationReport) (map[string]bool, bool) {
|
||||
if report.OK || len(report.Issues) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
paths := make(map[string]bool)
|
||||
for _, issue := range report.Issues {
|
||||
path, ok := repairIssueAuthorPath(issue)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
paths[path] = true
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return paths, true
|
||||
}
|
||||
|
||||
func canRepairIssueByAuthoring(issue ValidationIssue) bool {
|
||||
_, ok := repairIssueAuthorPath(issue)
|
||||
return ok
|
||||
}
|
||||
|
||||
func repairIssueAuthorPath(issue ValidationIssue) (string, bool) {
|
||||
path := strings.TrimSpace(issue.Path)
|
||||
slidePath, err := previewSlideObjectPath(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(issue.Code) {
|
||||
case "svglide.path":
|
||||
return slidePath, strings.Contains(issue.Message, "missing or not a regular file")
|
||||
case "svglide.xml", "svglide.root", "svglide.slide_role", "svglide.viewbox", "svglide.visible_content":
|
||||
return slidePath, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func repairReceiptMessage(report RepairReport) string {
|
||||
if report.Status == "passed" {
|
||||
if report.Reauthored {
|
||||
return "lint, preview, and quality passed after reauthoring"
|
||||
}
|
||||
return "lint, preview, and quality passed"
|
||||
}
|
||||
if report.LintOK && report.Preview == "passed" && report.Quality != "passed" {
|
||||
return "quality gate failed"
|
||||
}
|
||||
if report.Reauthored {
|
||||
return "repair reauthored slides but lint or preview still failed"
|
||||
}
|
||||
return "lint or preview failed"
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRepairRunAuthorsMissingSlidesAndWritesFinalReceipt(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" {
|
||||
t.Fatalf("Status = %q, want passed: %+v", report.Status, report)
|
||||
}
|
||||
if !report.LintOK {
|
||||
t.Fatalf("LintOK = false, want true: %+v", report)
|
||||
}
|
||||
if report.Preview != "passed" {
|
||||
t.Fatalf("Preview = %q, want passed: %+v", report.Preview, report)
|
||||
}
|
||||
if report.Quality != "passed" {
|
||||
t.Fatalf("Quality = %q, want passed: %+v", report.Quality, report)
|
||||
}
|
||||
if !report.Reauthored {
|
||||
t.Fatalf("Reauthored = false, want true: %+v", report)
|
||||
}
|
||||
|
||||
for _, rel := range []string{
|
||||
"slides/01.svg",
|
||||
"preview.html",
|
||||
"receipts/lint.json",
|
||||
"receipts/preview.json",
|
||||
"quality_report.json",
|
||||
"receipts/validate_preview_repair.json",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join("demo", rel)); err != nil {
|
||||
t.Fatalf("missing %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
receipt := readRepairReceiptForTest(t)
|
||||
if receipt["stage"] != StageValidatePreviewRepair {
|
||||
t.Fatalf("receipt stage = %v, want %q", receipt["stage"], StageValidatePreviewRepair)
|
||||
}
|
||||
if receipt["status"] != "passed" {
|
||||
t.Fatalf("receipt status = %v, want passed", receipt["status"])
|
||||
}
|
||||
if receipt["message"] != "lint, preview, and quality passed after reauthoring" {
|
||||
t.Fatalf("receipt message = %v, want quality-aware pass message", receipt["message"])
|
||||
}
|
||||
if _, ok := receipt["artifacts"].([]any); !ok {
|
||||
t.Fatalf("receipt artifacts = %T, want array", receipt["artifacts"])
|
||||
}
|
||||
if _, ok := receipt["updated_at"]; ok {
|
||||
t.Fatalf("receipt contains updated_at, want StageReceipt-compatible schema: %+v", receipt)
|
||||
}
|
||||
if _, ok := receipt["generated_at"]; ok {
|
||||
t.Fatalf("receipt contains generated_at, want StageReceipt-compatible schema: %+v", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunFailsWhenQualityFails(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteTestFile(t, "demo/research/sources.json", `{"sources":[{"id":"local1","path":"research/local.md","title":"Local source","excerpt":"Local excerpt","usage":"support","retrieval":"local_file"}]}`)
|
||||
mustWriteTestFile(t, "demo/content/slide_content.json", `{"slides":[{"id":"s1","content":"First body line\nSecond body line","source_refs":[],"visuals":[{"id":"none-s1","type":"none","instruction":"Text-only"}]}]}`)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if report.LintOK != true {
|
||||
t.Fatalf("LintOK = %v, want true: %+v", report.LintOK, report)
|
||||
}
|
||||
if report.Preview != "passed" {
|
||||
t.Fatalf("Preview = %q, want passed: %+v", report.Preview, report)
|
||||
}
|
||||
if report.Quality != "failed" {
|
||||
t.Fatalf("Quality = %q, want failed: %+v", report.Quality, report)
|
||||
}
|
||||
|
||||
qualityRaw, err := os.ReadFile(filepath.Join("demo", "quality_report.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var quality map[string]any
|
||||
if err := json.Unmarshal(qualityRaw, &quality); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quality["status"] != "failed" {
|
||||
t.Fatalf("quality status = %v, want failed: %+v", quality["status"], quality)
|
||||
}
|
||||
|
||||
receipt := readRepairReceiptForTest(t)
|
||||
if receipt["status"] != "failed" {
|
||||
t.Fatalf("receipt status = %v, want failed", receipt["status"])
|
||||
}
|
||||
if receipt["message"] != "quality gate failed" {
|
||||
t.Fatalf("receipt message = %v, want quality gate failed", receipt["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairReceiptMessagePrioritizesLintPreviewFailuresOverQuality(t *testing.T) {
|
||||
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: false, Preview: "failed", Quality: "failed"}); got != "lint or preview failed" {
|
||||
t.Fatalf("message = %q, want lint or preview failed", got)
|
||||
}
|
||||
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: false, Preview: "failed", Quality: "failed", Reauthored: true}); got != "repair reauthored slides but lint or preview still failed" {
|
||||
t.Fatalf("reauthored message = %q, want reauthored lint/preview failure", got)
|
||||
}
|
||||
if got := repairReceiptMessage(RepairReport{Status: "failed", LintOK: true, Preview: "passed", Quality: "failed"}); got != "quality gate failed" {
|
||||
t.Fatalf("quality-only message = %q, want quality gate failed", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunOnlyReauthorsFailedSlidePaths(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"},{"id":"s2","title":"Second claim","summary":"Second summary","role":"content","key_message":"Second key message","path":"slides/02.svg"}]}`,
|
||||
)
|
||||
custom := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><rect width="960" height="540" fill="#fff"/><text x="48" y="80">KEEP-CUSTOM-01</text></svg>`
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", custom)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" || !report.Reauthored || !report.LintOK || report.Preview != "passed" {
|
||||
t.Fatalf("report = %+v, want passed reauthored repair", report)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != custom {
|
||||
t.Fatalf("slides/01.svg was overwritten:\n%s", string(raw))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "slides", "02.svg")); err != nil {
|
||||
t.Fatalf("missing reauthored slides/02.svg: %v", err)
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false after repair: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunReauthorsBackgroundOnlySVG(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", backgroundOnlySVG())
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "passed" || !report.Reauthored || !report.LintOK || report.Preview != "passed" {
|
||||
t.Fatalf("report = %+v, want passed reauthored repair", report)
|
||||
}
|
||||
|
||||
validation, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.OK {
|
||||
t.Fatalf("ValidateRun OK = false after repair: %+v", validation.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunDoesNotAuthorInvalidSlidePath(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/../01.svg"}]}`,
|
||||
)
|
||||
|
||||
report, err := RepairRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Status != "failed" {
|
||||
t.Fatalf("Status = %q, want failed: %+v", report.Status, report)
|
||||
}
|
||||
if report.Reauthored {
|
||||
t.Fatalf("Reauthored = true, want false for invalid path: %+v", report)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "receipts", "svg_author.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("svg_author receipt exists or stat failed, want no authoring: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairRunTreatsValidationArtifactWriteErrorAsFatal(t *testing.T) {
|
||||
initAuthorDemoRun(t,
|
||||
`{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`,
|
||||
`{"title":"Demo Deck","slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"slides/01.svg"}]}`,
|
||||
)
|
||||
if err := os.Remove(filepath.Join("demo", "repair_queue.md")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join("demo", "repair_queue.md"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := RepairRun("demo"); err == nil {
|
||||
t.Fatal("expected repair to return validation artifact write error")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("demo", "receipts", "validate_preview_repair.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("final repair receipt exists or stat failed, want no misleading final receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readRepairReceiptForTest(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "validate_preview_repair.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
StageRequest = "request"
|
||||
StageResearch = "research"
|
||||
StageDesignBrief = "design_brief"
|
||||
StageOutline = "outline"
|
||||
StageSlideContent = "slide_content"
|
||||
StageAssets = "assets"
|
||||
StageSVGAuthor = "svg_author"
|
||||
StageValidatePreviewRepair = "validate_preview_repair"
|
||||
|
||||
StatusPending = "pending"
|
||||
StatusReady = "ready"
|
||||
StatusInProgress = "in_progress"
|
||||
StatusDone = "done"
|
||||
StatusFailed = "failed"
|
||||
StatusBlocked = "blocked"
|
||||
StatusNeedsRepair = "needs_repair"
|
||||
)
|
||||
|
||||
type Run struct {
|
||||
Version int `json:"version"`
|
||||
Runtime string `json:"runtime"`
|
||||
Command string `json:"command"`
|
||||
Title string `json:"title"`
|
||||
Input string `json:"input"`
|
||||
Audience string `json:"audience,omitempty"`
|
||||
DeliveryMode string `json:"delivery_mode,omitempty"`
|
||||
Pages int `json:"pages,omitempty"`
|
||||
Out string `json:"out"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
CurrentStage string `json:"current_stage"`
|
||||
Stages []Stage `json:"stages"`
|
||||
Artifacts ArtifactPaths `json:"artifacts"`
|
||||
Policy Policy `json:"policy"`
|
||||
}
|
||||
|
||||
type Stage struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Inputs []string `json:"inputs"`
|
||||
Outputs []string `json:"outputs"`
|
||||
Receipt string `json:"receipt"`
|
||||
}
|
||||
|
||||
type ArtifactPaths struct {
|
||||
Deck string `json:"deck"`
|
||||
SlidesDir string `json:"slides_dir"`
|
||||
Preview string `json:"preview"`
|
||||
RepairQueue string `json:"repair_queue"`
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
PublishEnabled bool `json:"publish_enabled"`
|
||||
NetworkByCodex bool `json:"network_by_codex"`
|
||||
ImageGenerationByCodex bool `json:"image_generation_by_codex"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
type NewRunConfig struct {
|
||||
Title string
|
||||
Input string
|
||||
Audience string
|
||||
DeliveryMode string
|
||||
Pages int
|
||||
Out string
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
func NewRun(cfg NewRunConfig) Run {
|
||||
now := cfg.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
ts := now.Format(time.RFC3339)
|
||||
return Run{
|
||||
Version: 1,
|
||||
Runtime: "codex",
|
||||
Command: "slides +create-svglide",
|
||||
Title: cfg.Title,
|
||||
Input: cfg.Input,
|
||||
Audience: cfg.Audience,
|
||||
DeliveryMode: cfg.DeliveryMode,
|
||||
Pages: cfg.Pages,
|
||||
Out: cfg.Out,
|
||||
CreatedAt: ts,
|
||||
UpdatedAt: ts,
|
||||
CurrentStage: StageRequest,
|
||||
Stages: DefaultStages(),
|
||||
Artifacts: ArtifactPaths{
|
||||
Deck: "outline/deck.json",
|
||||
SlidesDir: "slides",
|
||||
Preview: "preview.html",
|
||||
RepairQueue: "repair_queue.md",
|
||||
},
|
||||
Policy: Policy{
|
||||
PublishEnabled: false,
|
||||
NetworkByCodex: true,
|
||||
ImageGenerationByCodex: true,
|
||||
Overwrite: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultStages() []Stage {
|
||||
return []Stage{
|
||||
{Name: StageRequest, Status: StatusPending, Inputs: []string{}, Outputs: []string{"request/request.json", "request/source_manifest.json"}, Receipt: "receipts/request.json"},
|
||||
{Name: StageResearch, Status: StatusPending, Inputs: []string{"request/request.json", "request/source_manifest.json"}, Outputs: []string{"research/research_notes.md", "research/sources.json"}, Receipt: "receipts/research.json"},
|
||||
{Name: StageDesignBrief, Status: StatusPending, Inputs: []string{"request/request.json", "research/research_notes.md"}, Outputs: []string{"brief/design_brief.json", "brief/visual_system.json"}, Receipt: "receipts/design_brief.json"},
|
||||
{Name: StageOutline, Status: StatusPending, Inputs: []string{"brief/design_brief.json", "brief/visual_system.json"}, Outputs: []string{"outline/deck.json"}, Receipt: "receipts/outline.json"},
|
||||
{Name: StageSlideContent, Status: StatusPending, Inputs: []string{"outline/deck.json", "research/research_notes.md"}, Outputs: []string{"content/slide_content.md", "content/slide_content.json"}, Receipt: "receipts/slide_content.json"},
|
||||
{Name: StageAssets, Status: StatusPending, Inputs: []string{"content/slide_content.json", "brief/visual_system.json"}, Outputs: []string{"assets/assets_plan.json"}, Receipt: "receipts/assets.json"},
|
||||
{Name: StageSVGAuthor, Status: StatusPending, Inputs: []string{"outline/deck.json", "content/slide_content.json", "brief/visual_system.json", "assets/assets_plan.json"}, Outputs: []string{"slides/*.svg"}, Receipt: "receipts/svg_author.json"},
|
||||
{Name: StageValidatePreviewRepair, Status: StatusPending, Inputs: []string{"slides/*.svg"}, Outputs: []string{"receipts/lint.json", "receipts/preview.json", "quality_report.json", "repair_queue.md", "preview.html"}, Receipt: "receipts/validate_preview_repair.json"},
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultStagesAreOrdered(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
want := []string{
|
||||
StageRequest,
|
||||
StageResearch,
|
||||
StageDesignBrief,
|
||||
StageOutline,
|
||||
StageSlideContent,
|
||||
StageAssets,
|
||||
StageSVGAuthor,
|
||||
StageValidatePreviewRepair,
|
||||
}
|
||||
if len(stages) != len(want) {
|
||||
t.Fatalf("stage count = %d, want %d", len(stages), len(want))
|
||||
}
|
||||
for i, stage := range stages {
|
||||
if stage.Name != want[i] {
|
||||
t.Fatalf("stage[%d] = %q, want %q", i, stage.Name, want[i])
|
||||
}
|
||||
if stage.Status != StatusPending {
|
||||
t.Fatalf("stage[%d].Status = %q, want %q", i, stage.Status, StatusPending)
|
||||
}
|
||||
if stage.Inputs == nil {
|
||||
t.Fatalf("stage[%d].Inputs = nil, want stable empty array", i)
|
||||
}
|
||||
if stage.Outputs == nil {
|
||||
t.Fatalf("stage[%d].Outputs = nil, want stable empty array", i)
|
||||
}
|
||||
if stage.Receipt == "" {
|
||||
t.Fatalf("stage[%d] missing receipt path", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesRequireGeneratedSlideSVGs(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
svgAuthor := mustStage(t, stages, StageSVGAuthor)
|
||||
if !reflect.DeepEqual(svgAuthor.Outputs, []string{"slides/*.svg"}) {
|
||||
t.Fatalf("svg_author Outputs = %v, want slides/*.svg", svgAuthor.Outputs)
|
||||
}
|
||||
repair := mustStage(t, stages, StageValidatePreviewRepair)
|
||||
if !reflect.DeepEqual(repair.Inputs, []string{"slides/*.svg"}) {
|
||||
t.Fatalf("validate_preview_repair Inputs = %v, want slides/*.svg", repair.Inputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesFinalStageRequiresQualityReport(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
final := stages[len(stages)-1]
|
||||
if final.Name != StageValidatePreviewRepair {
|
||||
t.Fatalf("final stage = %q, want %q", final.Name, StageValidatePreviewRepair)
|
||||
}
|
||||
if !stringSliceContains(final.Outputs, "quality_report.json") {
|
||||
t.Fatalf("final outputs = %+v, want quality_report.json", final.Outputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesResearchInputsMatchPromptContract(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
research := mustStage(t, stages, StageResearch)
|
||||
want := []string{"request/request.json", "request/source_manifest.json"}
|
||||
if !reflect.DeepEqual(research.Inputs, want) {
|
||||
t.Fatalf("research Inputs = %v, want %v", research.Inputs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStagesOutlineInputsMatchPromptContract(t *testing.T) {
|
||||
stages := DefaultStages()
|
||||
outline := mustStage(t, stages, StageOutline)
|
||||
want := []string{"brief/design_brief.json", "brief/visual_system.json"}
|
||||
if !reflect.DeepEqual(outline.Inputs, want) {
|
||||
t.Fatalf("outline Inputs = %v, want %v", outline.Inputs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRunDefaultsToCodexRuntime(t *testing.T) {
|
||||
now := time.Date(2026, 7, 2, 15, 4, 5, 0, time.UTC)
|
||||
run := NewRun(NewRunConfig{
|
||||
Title: "Demo",
|
||||
Input: "source.md",
|
||||
Audience: "产品和工程负责人",
|
||||
DeliveryMode: "self_read",
|
||||
Pages: 8,
|
||||
Out: ".lark-slides/svglide-runs/demo",
|
||||
Now: now,
|
||||
})
|
||||
if run.Version != 1 {
|
||||
t.Fatalf("Version = %d, want 1", run.Version)
|
||||
}
|
||||
if run.Runtime != "codex" {
|
||||
t.Fatalf("Runtime = %q, want codex", run.Runtime)
|
||||
}
|
||||
if run.Command != "slides +create-svglide" {
|
||||
t.Fatalf("Command = %q, want slides +create-svglide", run.Command)
|
||||
}
|
||||
if run.Title != "Demo" {
|
||||
t.Fatalf("Title = %q, want Demo", run.Title)
|
||||
}
|
||||
if run.Input != "source.md" {
|
||||
t.Fatalf("Input = %q, want source.md", run.Input)
|
||||
}
|
||||
if run.Audience != "产品和工程负责人" {
|
||||
t.Fatalf("Audience = %q, want 产品和工程负责人", run.Audience)
|
||||
}
|
||||
if run.DeliveryMode != "self_read" {
|
||||
t.Fatalf("DeliveryMode = %q, want self_read", run.DeliveryMode)
|
||||
}
|
||||
if run.Pages != 8 {
|
||||
t.Fatalf("Pages = %d, want 8", run.Pages)
|
||||
}
|
||||
if run.Out != ".lark-slides/svglide-runs/demo" {
|
||||
t.Fatalf("Out = %q, want .lark-slides/svglide-runs/demo", run.Out)
|
||||
}
|
||||
wantTS := now.Format(time.RFC3339)
|
||||
if run.CreatedAt != wantTS {
|
||||
t.Fatalf("CreatedAt = %q, want %q", run.CreatedAt, wantTS)
|
||||
}
|
||||
if run.UpdatedAt != wantTS {
|
||||
t.Fatalf("UpdatedAt = %q, want %q", run.UpdatedAt, wantTS)
|
||||
}
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
wantArtifacts := ArtifactPaths{
|
||||
Deck: "outline/deck.json",
|
||||
SlidesDir: "slides",
|
||||
Preview: "preview.html",
|
||||
RepairQueue: "repair_queue.md",
|
||||
}
|
||||
if run.Artifacts != wantArtifacts {
|
||||
t.Fatalf("Artifacts = %+v, want %+v", run.Artifacts, wantArtifacts)
|
||||
}
|
||||
wantStages := DefaultStages()
|
||||
if !reflect.DeepEqual(run.Stages, wantStages) {
|
||||
t.Fatalf("Stages = %+v, want %+v", run.Stages, wantStages)
|
||||
}
|
||||
wantPolicy := Policy{
|
||||
PublishEnabled: false,
|
||||
NetworkByCodex: true,
|
||||
ImageGenerationByCodex: true,
|
||||
Overwrite: false,
|
||||
}
|
||||
if run.Policy != wantPolicy {
|
||||
t.Fatalf("Policy = %+v, want %+v", run.Policy, wantPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func mustStage(t *testing.T, stages []Stage, name string) Stage {
|
||||
t.Helper()
|
||||
for _, stage := range stages {
|
||||
if stage.Name == name {
|
||||
return stage
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing stage %q", name)
|
||||
return Stage{}
|
||||
}
|
||||
|
||||
func stringSliceContains(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type liteJSONSchema struct {
|
||||
Type string `json:"type"`
|
||||
Required []string `json:"required"`
|
||||
AdditionalProperties *bool `json:"additionalProperties"`
|
||||
Properties map[string]liteJSONSchema `json:"properties"`
|
||||
Items *liteJSONSchema `json:"items"`
|
||||
MinItems *int `json:"minItems"`
|
||||
Enum []string `json:"enum"`
|
||||
Pattern string `json:"pattern"`
|
||||
}
|
||||
|
||||
var stageOutputSchemaPaths = map[string]string{
|
||||
"request/request.json": "schemas/request.schema.json",
|
||||
"request/source_manifest.json": "schemas/source_manifest.schema.json",
|
||||
"research/sources.json": "schemas/sources.schema.json",
|
||||
"brief/design_brief.json": "schemas/design_brief.schema.json",
|
||||
"brief/visual_system.json": "schemas/visual_system.schema.json",
|
||||
"outline/deck.json": "schemas/deck.schema.json",
|
||||
"content/slide_content.json": "schemas/slide_content.schema.json",
|
||||
"assets/assets_plan.json": "schemas/assets_plan.schema.json",
|
||||
"quality_report.json": "schemas/quality.schema.json",
|
||||
"receipts/lint.json": "schemas/lint.schema.json",
|
||||
"receipts/preview.json": "schemas/preview.schema.json",
|
||||
}
|
||||
|
||||
func ValidateStageOutputs(root string) error {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stage, err := currentStage(run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, output := range stage.Outputs {
|
||||
if hasGlobMeta(output) || strings.ToLower(filepath.Ext(output)) != ".json" {
|
||||
continue
|
||||
}
|
||||
schemaPath, ok := stageOutputSchemaPaths[output]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := validateStageOutputSchema(safeRoot, output, schemaPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if output == "outline/deck.json" {
|
||||
if err := validateDeckSlideOutputPaths(safeRoot, output); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDeckSlideOutputPaths(safeRoot string, artifactPath string) error {
|
||||
raw, err := readRunRegularArtifact(safeRoot, artifactPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read artifact: %w", artifactPath, err)
|
||||
}
|
||||
var deck struct {
|
||||
Slides []struct {
|
||||
Path string `json:"path"`
|
||||
} `json:"slides"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &deck); err != nil {
|
||||
return fmt.Errorf("%s: invalid JSON: %w", artifactPath, err)
|
||||
}
|
||||
for i, slide := range deck.Slides {
|
||||
if _, err := previewSlideObjectPath(slide.Path); err != nil {
|
||||
return fmt.Errorf("%s: field slides[%d].path: %w", artifactPath, i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStageOutputSchema(safeRoot, artifactPath, schemaPath string) error {
|
||||
artifactRaw, err := readRunRegularArtifact(safeRoot, artifactPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read artifact: %w", artifactPath, err)
|
||||
}
|
||||
schemaRaw, err := readRunRegularArtifact(safeRoot, schemaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read schema %s: %w", artifactPath, schemaPath, err)
|
||||
}
|
||||
schema, err := decodeLiteJSONSchema(schemaRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: schema %s: %w", artifactPath, schemaPath, err)
|
||||
}
|
||||
value, err := decodeJSONValue(artifactRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: invalid JSON: %w", artifactPath, err)
|
||||
}
|
||||
if err := validateJSONValue(schema, value, ""); err != nil {
|
||||
return fmt.Errorf("%s: %w", artifactPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeLiteJSONSchema(raw []byte) (liteJSONSchema, error) {
|
||||
var schema liteJSONSchema
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
if err := decoder.Decode(&schema); err != nil {
|
||||
return liteJSONSchema{}, fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
if err := rejectTrailingJSON(decoder); err != nil {
|
||||
return liteJSONSchema{}, err
|
||||
}
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
func decodeJSONValue(raw []byte) (any, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rejectTrailingJSON(decoder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func rejectTrailingJSON(decoder *json.Decoder) error {
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("contains trailing JSON value")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONValue(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
switch schema.Type {
|
||||
case "":
|
||||
return nil
|
||||
case "object":
|
||||
return validateJSONObject(schema, value, fieldPath)
|
||||
case "array":
|
||||
return validateJSONArray(schema, value, fieldPath)
|
||||
case "string":
|
||||
return validateJSONString(schema, value, fieldPath)
|
||||
case "integer":
|
||||
if !isJSONInteger(value) {
|
||||
return fmt.Errorf("field %s expected integer, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
return nil
|
||||
case "boolean":
|
||||
if _, ok := value.(bool); !ok {
|
||||
return fmt.Errorf("field %s expected boolean, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("field %s uses unsupported schema type %q", displayFieldPath(fieldPath), schema.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func validateJSONObject(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %s expected object, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
for _, required := range schema.Required {
|
||||
if _, ok := object[required]; !ok {
|
||||
return fmt.Errorf("field %s is required", joinFieldPath(fieldPath, required))
|
||||
}
|
||||
}
|
||||
if schema.AdditionalProperties != nil && !*schema.AdditionalProperties {
|
||||
for name := range object {
|
||||
if _, ok := schema.Properties[name]; !ok {
|
||||
return fmt.Errorf("field %s is not allowed by additionalProperties:false", joinFieldPath(fieldPath, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, propertySchema := range schema.Properties {
|
||||
child, ok := object[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := validateJSONValue(propertySchema, child, joinFieldPath(fieldPath, name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONArray(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
array, ok := value.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %s expected array, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
if schema.MinItems != nil && len(array) < *schema.MinItems {
|
||||
return fmt.Errorf("field %s has %d items, want minItems %d", displayFieldPath(fieldPath), len(array), *schema.MinItems)
|
||||
}
|
||||
if schema.Items == nil {
|
||||
return nil
|
||||
}
|
||||
for i, item := range array {
|
||||
if err := validateJSONValue(*schema.Items, item, joinArrayFieldPath(fieldPath, i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONString(schema liteJSONSchema, value any, fieldPath string) error {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %s expected string, got %s", displayFieldPath(fieldPath), jsonValueType(value))
|
||||
}
|
||||
if len(schema.Enum) > 0 {
|
||||
for _, allowed := range schema.Enum {
|
||||
if text == allowed {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("field %s value %q is not in enum %v", displayFieldPath(fieldPath), text, schema.Enum)
|
||||
}
|
||||
if schema.Pattern != "" {
|
||||
matched, err := regexp.MatchString(schema.Pattern, text)
|
||||
if err != nil {
|
||||
return fmt.Errorf("field %s has invalid pattern %q: %w", displayFieldPath(fieldPath), schema.Pattern, err)
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("field %s value %q does not match pattern %q", displayFieldPath(fieldPath), text, schema.Pattern)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isJSONInteger(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
return isCanonicalJSONInteger(typed.String())
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isCanonicalJSONInteger(value string) bool {
|
||||
if value == "" || strings.ContainsAny(value, ".eE") {
|
||||
return false
|
||||
}
|
||||
var parsed big.Int
|
||||
_, ok := parsed.SetString(value, 10)
|
||||
return ok
|
||||
}
|
||||
|
||||
func jsonValueType(value any) string {
|
||||
switch value.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case map[string]any:
|
||||
return "object"
|
||||
case []any:
|
||||
return "array"
|
||||
case string:
|
||||
return "string"
|
||||
case json.Number, float64:
|
||||
return "number"
|
||||
case bool:
|
||||
return "boolean"
|
||||
default:
|
||||
return fmt.Sprintf("%T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func joinFieldPath(parent, name string) string {
|
||||
if parent == "" {
|
||||
return name
|
||||
}
|
||||
return parent + "." + name
|
||||
}
|
||||
|
||||
func joinArrayFieldPath(parent string, index int) string {
|
||||
if parent == "" {
|
||||
return fmt.Sprintf("[%d]", index)
|
||||
}
|
||||
return fmt.Sprintf("%s[%d]", parent, index)
|
||||
}
|
||||
|
||||
func displayFieldPath(path string) string {
|
||||
if path == "" {
|
||||
return "$"
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateStageOutputsRejectsMissingRequiredField(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.WriteFile(filepath.Join("demo", "request", "request.json"), []byte(`{"title":"Demo"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "request/request.json") || !strings.Contains(err.Error(), "input") {
|
||||
t.Fatalf("error = %v, want path and missing field", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsAcceptsCurrentRequestArtifacts(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
if err := ValidateStageOutputs("demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsDeckSlidePathsThatPreviewRejects(t *testing.T) {
|
||||
for _, path := range []string{"slides/a%20.svg", "slides/.hidden.svg", "slides/a..b.svg", "slides/a:b.svg"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageOutline)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", validSchemaDeckJSON(path))
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected deck slide path validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "outline/deck.json") || !strings.Contains(err.Error(), "slides[0].path") {
|
||||
t.Fatalf("error = %v, want deck path context", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsInvalidDeckSlidePath(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageOutline)
|
||||
mustWriteTestFile(t, "demo/outline/deck.json", validSchemaDeckJSON("slides/a%20.svg"))
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected deck slide path validation error")
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageOutline {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageOutline)
|
||||
}
|
||||
if got := stageStatus(t, run, StageOutline); got == StatusDone {
|
||||
t.Fatalf("outline stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("demo", "receipts", "outline.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("outline receipt should not be written, stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsInvalidValidatePreviewRepairReceipts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T)
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "lint",
|
||||
setup: func(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "lint.json"), []byte(`{"status":"failed"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "quality_report.json"), []byte(validQualityReportJSON()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
path: "receipts/lint.json",
|
||||
},
|
||||
{
|
||||
name: "preview",
|
||||
setup: func(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "lint.json"), []byte(`{"status":"passed","issues":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "preview.json"), []byte(`{"status":"passed","slides":[{"path":"slides/01.svg","rendered":"yes"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "quality_report.json"), []byte(validQualityReportJSON()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
path: "receipts/preview.json",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
tt.setup(t)
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.path) {
|
||||
t.Fatalf("error = %v, want path %s", err, tt.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsInvalidQualityReportSchema(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "lint.json"), []byte(`{"status":"passed","issues":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "receipts", "preview.json"), []byte(`{"status":"passed","slides":[{"path":"slides/01.svg","rendered":true}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "quality_report.json"), []byte(`{"status":"passed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":0,"assets":0,"slides_with_source_refs":1}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected quality report schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "quality_report.json") {
|
||||
t.Fatalf("error = %v, want path quality_report.json", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsSourcesMissingRetrieval(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageResearch)
|
||||
if err := os.WriteFile(filepath.Join("demo", "research", "sources.json"), []byte(`{"sources":[{"id":"s1","path":"https://example.com","title":"Example","excerpt":"Ex","usage":"supporting evidence"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected retrieval schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "research/sources.json") || !strings.Contains(err.Error(), "retrieval") {
|
||||
t.Fatalf("error = %v, want research/sources.json and retrieval", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsSlideContentMissingSourceRefsOrVisualIds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing source_refs",
|
||||
raw: `{"slides":[{"id":"s1","content":"Plan","visuals":[{"id":"v1","type":"none","instruction":"No visual needed"}]}]}`,
|
||||
want: "source_refs",
|
||||
},
|
||||
{
|
||||
name: "missing visual id",
|
||||
raw: `{"slides":[{"id":"s1","content":"Plan","source_refs":["s1"],"visuals":[{"type":"none","instruction":"No visual needed"}]}]}`,
|
||||
want: "visuals[0].id",
|
||||
},
|
||||
{
|
||||
name: "empty visuals",
|
||||
raw: `{"slides":[{"id":"s1","content":"Plan","source_refs":["s1"],"visuals":[]}]}`,
|
||||
want: "visuals",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSlideContent)
|
||||
if err := os.WriteFile(filepath.Join("demo", "content", "slide_content.json"), []byte(tt.raw), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected slide content schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "content/slide_content.json") || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("error = %v, want content/slide_content.json and %s", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsAssetsMissingStatus(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "assets_plan.json"), []byte(`{"mode":"experiment_unrestricted_assets","assets":[{"id":"a1","slide_id":"s1","type":"image","path":"https://example.com/a.png","usage":"hero image"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected asset schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "assets/assets_plan.json") || !strings.Contains(err.Error(), "status") {
|
||||
t.Fatalf("error = %v, want assets/assets_plan.json and status", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsAcceptsExperimentAssetPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "outside images", path: "../a.png"},
|
||||
{name: "dot dot filename", path: "assets/images/hero..png"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageAssets)
|
||||
if err := os.WriteFile(filepath.Join("demo", "assets", "assets_plan.json"), []byte(`{"mode":"experiment_unrestricted_assets","assets":[{"id":"a1","slide_id":"s1","type":"image","path":"`+tt.path+`","usage":"hero image","status":"ready"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected experiment asset path to pass schema validation, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSchemasIncludeAnyGenQualityContracts(t *testing.T) {
|
||||
schemas := DefaultSchemas()
|
||||
for _, name := range []string{
|
||||
"sources.schema.json",
|
||||
"slide_content.schema.json",
|
||||
"assets_plan.schema.json",
|
||||
"quality.schema.json",
|
||||
} {
|
||||
if strings.TrimSpace(schemas[name]) == "" {
|
||||
t.Fatalf("schema %s is missing", name)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(schemas["sources.schema.json"], `"retrieval"`) {
|
||||
t.Fatalf("sources schema missing retrieval contract: %s", schemas["sources.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["slide_content.schema.json"], `"source_refs"`) {
|
||||
t.Fatalf("slide content schema missing source_refs: %s", schemas["slide_content.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["slide_content.schema.json"], `"visuals"`) {
|
||||
t.Fatalf("slide content schema missing visuals: %s", schemas["slide_content.schema.json"])
|
||||
}
|
||||
if !strings.Contains(schemas["assets_plan.schema.json"], `"slide_id"`) {
|
||||
t.Fatalf("assets schema missing slide_id: %s", schemas["assets_plan.schema.json"])
|
||||
}
|
||||
for _, want := range []string{`"experiment_unrestricted_assets"`, `"chart"`, `"table"`, `"crop"`, `"deferred"`} {
|
||||
if !strings.Contains(schemas["assets_plan.schema.json"], want) {
|
||||
t.Fatalf("assets schema missing %s: %s", want, schemas["assets_plan.schema.json"])
|
||||
}
|
||||
}
|
||||
if !strings.Contains(schemas["quality.schema.json"], `"metrics"`) {
|
||||
t.Fatalf("quality schema missing metrics: %s", schemas["quality.schema.json"])
|
||||
}
|
||||
}
|
||||
|
||||
func validSchemaDeckJSON(path string) string {
|
||||
return `{"main_title":"Demo Deck","style_instruction":{"aesthetic_direction":"Editorial report","color_palette":{},"typography":{}},"slides":[{"id":"s1","title":"First claim","summary":"First summary","role":"cover","key_message":"First key message","path":"` + path + `"}]}`
|
||||
}
|
||||
|
||||
func validQualityReportJSON() string {
|
||||
return `{"status":"passed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":0,"assets":0,"slides_with_source_refs":1,"slides_with_visuals":1}}`
|
||||
}
|
||||
|
||||
func TestValidateStageOutputsRejectsNonCanonicalIntegers(t *testing.T) {
|
||||
for _, pages := range []string{"8.0", "8e0", "0.99999999999999999"} {
|
||||
t.Run(pages, func(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
raw := `{"title":"Demo","input":"source.md","pages":` + pages + `}`
|
||||
if err := os.WriteFile(filepath.Join("demo", "request", "request.json"), []byte(raw), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ValidateStageOutputs("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "request/request.json") || !strings.Contains(err.Error(), "pages") {
|
||||
t.Fatalf("error = %v, want path and pages field", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsInvalidCurrentStageOutputSchema(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.WriteFile(filepath.Join("demo", "request", "source_manifest.json"), []byte(`{"sources":[{"path":"source.md","type":"remote"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error")
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
if got := stageStatus(t, run, StageRequest); got == StatusDone {
|
||||
t.Fatalf("request stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("demo", "receipts", "request.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("receipt should not be written, stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StageReceipt struct {
|
||||
Stage string `json:"stage"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Artifacts []string `json:"artifacts,omitempty"`
|
||||
}
|
||||
|
||||
func CompleteCurrentStage(root string) (StatusReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
index, stage, err := currentStageWithIndex(run)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
missingOutputs, err := missingRunPaths(safeRoot, stage.Outputs)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if len(missingOutputs) > 0 {
|
||||
return StatusReport{}, fmt.Errorf("current stage %q missing outputs: %s", stage.Name, strings.Join(missingOutputs, ", "))
|
||||
}
|
||||
|
||||
if err := ValidateStageOutputs(root); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
if stage.Name == StageValidatePreviewRepair {
|
||||
if err := validateFinalStageReceiptsPassed(safeRoot); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeStageReceipt(safeRoot, StageReceipt{
|
||||
Stage: stage.Name,
|
||||
Status: StatusDone,
|
||||
Artifacts: stage.Outputs,
|
||||
}); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
|
||||
run.Stages[index].Status = StatusDone
|
||||
if index < len(run.Stages)-1 {
|
||||
nextStage := &run.Stages[index+1]
|
||||
run.CurrentStage = nextStage.Name
|
||||
if nextStage.Status == "" {
|
||||
nextStage.Status = StatusPending
|
||||
}
|
||||
} else {
|
||||
run.CurrentStage = stage.Name
|
||||
}
|
||||
run.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
|
||||
if err := writeRunFile(safeRoot, run); err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
return InspectStatus(root)
|
||||
}
|
||||
|
||||
type stageStatusReceipt struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func validateFinalStageReceiptsPassed(safeRoot string) error {
|
||||
for _, path := range []string{"receipts/lint.json", "receipts/preview.json", "quality_report.json"} {
|
||||
raw, err := readRunRegularArtifact(safeRoot, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: read receipt: %w", path, err)
|
||||
}
|
||||
var receipt stageStatusReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
return fmt.Errorf("%s: invalid JSON: %w", path, err)
|
||||
}
|
||||
if receipt.Status != "passed" {
|
||||
return fmt.Errorf("%s: status is %q, want passed", path, receipt.Status)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentStageWithIndex(run Run) (int, Stage, error) {
|
||||
for i, stage := range run.Stages {
|
||||
if stage.Name == run.CurrentStage {
|
||||
return i, stage, nil
|
||||
}
|
||||
}
|
||||
return -1, Stage{}, fmt.Errorf("current stage %q not found in run", run.CurrentStage)
|
||||
}
|
||||
|
||||
func writeRunFile(safeRoot string, run Run) error {
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, "run.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, run)
|
||||
}
|
||||
|
||||
func writeStageReceipt(safeRoot string, receipt StageReceipt) error {
|
||||
if strings.TrimSpace(receipt.Stage) == "" {
|
||||
return fmt.Errorf("stage receipt stage must not be empty")
|
||||
}
|
||||
if strings.ContainsAny(receipt.Stage, `/\`) || receipt.Stage == "." || receipt.Stage == ".." {
|
||||
return fmt.Errorf("stage receipt stage %q must be a file name", receipt.Stage)
|
||||
}
|
||||
target, err := ensureRunFileTargetForWrite(safeRoot, filepath.Join("receipts", receipt.Stage+".json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(target, receipt)
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompleteCurrentStageAdvancesToNextStage(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
status, err := CompleteCurrentStage("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.CurrentStage != StageResearch {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageResearch)
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageResearch {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageResearch)
|
||||
}
|
||||
if got := stageStatus(t, run, StageRequest); got != StatusDone {
|
||||
t.Fatalf("request stage status = %q, want %q", got, StatusDone)
|
||||
}
|
||||
if got := stageStatus(t, run, StageResearch); got != StatusPending {
|
||||
t.Fatalf("research stage status = %q, want %q", got, StatusPending)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "request.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing request receipt: %v", err)
|
||||
}
|
||||
var receipt StageReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("invalid request receipt: %v", err)
|
||||
}
|
||||
if receipt.Stage != StageRequest || receipt.Status != StatusDone {
|
||||
t.Fatalf("receipt = %+v, want request done", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsMissingOutput(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected missing output error")
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageDoesNotAdvanceRunWhenReceiptWriteFails(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Mkdir(filepath.Join("demo", "receipts", "request.json"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected receipt write error")
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageRequest {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
|
||||
}
|
||||
if got := stageStatus(t, run, StageRequest); got == StatusDone {
|
||||
t.Fatalf("request stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsFailedValidatePreviewRepairReceipts(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteTestFile(t, "demo/receipts/lint.json", `{"status":"failed","issues":[]}`)
|
||||
mustWriteTestFile(t, "demo/receipts/preview.json", `{"status":"failed","slides":[{"path":"slides/01.svg","rendered":false}]}`)
|
||||
mustWriteTestFile(t, "demo/quality_report.json", `{"status":"passed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":1,"assets":0,"slides_with_source_refs":1,"slides_with_visuals":0}}`)
|
||||
mustWriteTestFile(t, "demo/repair_queue.md", "# repair\n")
|
||||
mustWriteTestFile(t, "demo/preview.html", "<!doctype html><title>preview</title>")
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected failed lint/preview receipts to block completion")
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageValidatePreviewRepair {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageValidatePreviewRepair)
|
||||
}
|
||||
if got := stageStatus(t, run, StageValidatePreviewRepair); got == StatusDone {
|
||||
t.Fatalf("validate stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("demo", "receipts", "validate_preview_repair.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("final receipt should not be written, stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteCurrentStageRejectsFailedQualityReport(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageValidatePreviewRepair)
|
||||
mustWriteTestFile(t, "demo/slides/01.svg", visibleTextSVG())
|
||||
mustWriteTestFile(t, "demo/receipts/lint.json", `{"status":"passed","issues":[]}`)
|
||||
mustWriteTestFile(t, "demo/receipts/preview.json", `{"status":"passed","slides":[{"path":"slides/01.svg","rendered":true}]}`)
|
||||
mustWriteTestFile(t, "demo/quality_report.json", `{"status":"failed","issues":[],"metrics":{"slides":1,"sources":1,"web_sources":0,"assets":0,"slides_with_source_refs":1,"slides_with_visuals":0}}`)
|
||||
mustWriteTestFile(t, "demo/repair_queue.md", "# repair\n")
|
||||
mustWriteTestFile(t, "demo/preview.html", "<!doctype html><title>preview</title>")
|
||||
|
||||
_, err := CompleteCurrentStage("demo")
|
||||
if err == nil {
|
||||
t.Fatal("expected failed quality report to block completion")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "quality_report.json") && !strings.Contains(err.Error(), "status is \"failed\"") {
|
||||
t.Fatalf("error = %v, want quality report failure", err)
|
||||
}
|
||||
|
||||
run := readStatusTestRunFile(t)
|
||||
if run.CurrentStage != StageValidatePreviewRepair {
|
||||
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageValidatePreviewRepair)
|
||||
}
|
||||
if got := stageStatus(t, run, StageValidatePreviewRepair); got == StatusDone {
|
||||
t.Fatalf("validate stage status = %q, want not %q", got, StatusDone)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join("demo", "receipts", "validate_preview_repair.json")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("final receipt should not be written, stat err = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func stageStatus(t *testing.T, run Run, name string) string {
|
||||
t.Helper()
|
||||
for _, stage := range run.Stages {
|
||||
if stage.Name == name {
|
||||
return stage.Status
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing stage %q", name)
|
||||
return ""
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type StatusReport struct {
|
||||
CurrentStage string `json:"current_stage"`
|
||||
MissingInputs []string `json:"missing_inputs"`
|
||||
MissingOutputs []string `json:"missing_outputs"`
|
||||
NextCommand string `json:"next_command"`
|
||||
}
|
||||
|
||||
type NextTaskReport struct {
|
||||
Stage string `json:"stage"`
|
||||
Mode string `json:"mode"`
|
||||
ApprovalRequired bool `json:"approval_required"`
|
||||
BlockingOwner string `json:"blocking_owner"`
|
||||
BlockingReason string `json:"blocking_reason,omitempty"`
|
||||
PromptPath string `json:"prompt_path,omitempty"`
|
||||
PromptPaths []string `json:"prompt_paths"`
|
||||
AdapterPaths []string `json:"adapter_paths"`
|
||||
PromptManifest string `json:"prompt_manifest"`
|
||||
Inputs []string `json:"inputs"`
|
||||
Outputs []string `json:"outputs"`
|
||||
}
|
||||
|
||||
const (
|
||||
createSVGlideAdapterPath = "skills/lark-slides/references/lark-slides-create-svglide.md"
|
||||
svglideExecutionMode = "execution"
|
||||
svglideBlockingOwner = "svglide-runtime"
|
||||
)
|
||||
|
||||
func ReadRun(root string) (Run, error) {
|
||||
safeRoot, err := validate.SafeInputPath(root)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
return readRunFile(safeRoot)
|
||||
}
|
||||
|
||||
func InspectStatus(root string) (StatusReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
stage, err := currentStage(run)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
missingInputs, err := missingRunPaths(safeRoot, stage.Inputs)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
missingOutputs, err := missingRunPaths(safeRoot, stage.Outputs)
|
||||
if err != nil {
|
||||
return StatusReport{}, err
|
||||
}
|
||||
return StatusReport{
|
||||
CurrentStage: stage.Name,
|
||||
MissingInputs: missingInputs,
|
||||
MissingOutputs: missingOutputs,
|
||||
NextCommand: fmt.Sprintf("lark-cli slides +create-svglide --action next --run %s", shellQuote(root)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NextTask(root string) (NextTaskReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
stage, err := currentStage(run)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
missingInputs, err := missingRunPaths(safeRoot, stage.Inputs)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
if len(missingInputs) > 0 {
|
||||
return NextTaskReport{}, fmt.Errorf("current stage %q missing inputs: %s", stage.Name, strings.Join(missingInputs, ", "))
|
||||
}
|
||||
inputs, err := validateRunPaths(safeRoot, stage.Inputs)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
outputs, err := validateRunPaths(safeRoot, stage.Outputs)
|
||||
if err != nil {
|
||||
return NextTaskReport{}, err
|
||||
}
|
||||
return NextTaskReport{
|
||||
Stage: stage.Name,
|
||||
Mode: svglideExecutionMode,
|
||||
ApprovalRequired: false,
|
||||
BlockingOwner: svglideBlockingOwner,
|
||||
PromptPaths: PromptPathsForStage(stage.Name),
|
||||
AdapterPaths: []string{createSVGlideAdapterPath},
|
||||
PromptManifest: "prompt_manifest.json",
|
||||
Inputs: inputs,
|
||||
Outputs: outputs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readRun(root string) (string, Run, error) {
|
||||
safeRoot, err := validate.SafeInputPath(root)
|
||||
if err != nil {
|
||||
return "", Run{}, err
|
||||
}
|
||||
run, err := readRunFile(safeRoot)
|
||||
if err != nil {
|
||||
return "", Run{}, err
|
||||
}
|
||||
return safeRoot, run, nil
|
||||
}
|
||||
|
||||
func readRunFile(safeRoot string) (Run, error) {
|
||||
raw, err := vfs.ReadFile(filepath.Join(safeRoot, "run.json"))
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
return Run{}, fmt.Errorf("read run.json: %w", err)
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func currentStage(run Run) (Stage, error) {
|
||||
for _, stage := range run.Stages {
|
||||
if stage.Name == run.CurrentStage {
|
||||
return stage, nil
|
||||
}
|
||||
}
|
||||
return Stage{}, fmt.Errorf("current stage %q not found in run", run.CurrentStage)
|
||||
}
|
||||
|
||||
func missingRunPaths(safeRoot string, rels []string) ([]string, error) {
|
||||
var missing []string
|
||||
for _, rel := range rels {
|
||||
if hasGlobMeta(rel) {
|
||||
exists, err := runGlobExists(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
missing = append(missing, rel)
|
||||
}
|
||||
continue
|
||||
}
|
||||
exists, err := runRegularFileExists(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lstat run path %q: %w", rel, err)
|
||||
}
|
||||
if !exists {
|
||||
missing = append(missing, rel)
|
||||
}
|
||||
}
|
||||
return missing, nil
|
||||
}
|
||||
|
||||
func validateRunPaths(safeRoot string, rels []string) ([]string, error) {
|
||||
paths := make([]string, 0, len(rels))
|
||||
for _, rel := range rels {
|
||||
if hasGlobMeta(rel) {
|
||||
if _, _, _, err := validateRunGlobPattern(safeRoot, rel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if _, err := safeRunPath(safeRoot, rel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
paths = append(paths, rel)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func runGlobExists(safeRoot, rel string) (bool, error) {
|
||||
dirRel, pattern, dirPath, err := validateRunGlobPattern(safeRoot, rel)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
dirPath, exists, err := runDirectoryExists(safeRoot, dirRel)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("lstat glob directory for %q: %w", rel, err)
|
||||
}
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
entries, err := vfs.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("read glob directory for %q: %w", rel, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
matched, err := filepath.Match(pattern, entry.Name())
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid glob pattern %q: %w", rel, err)
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
matchRel := filepath.Join(dirRel, entry.Name())
|
||||
exists, err := runRegularFileExists(safeRoot, matchRel)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("lstat glob match %q: %w", matchRel, err)
|
||||
}
|
||||
if exists {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func validateRunGlobPattern(safeRoot, rel string) (string, string, string, error) {
|
||||
if strings.TrimSpace(rel) == "" {
|
||||
return "", "", "", fmt.Errorf("run path must not be empty")
|
||||
}
|
||||
if isAbsoluteRunPath(rel) {
|
||||
return "", "", "", fmt.Errorf("run path %q must be relative to run root", rel)
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
dirRel, pattern := filepath.Split(cleanRel)
|
||||
dirRel = strings.TrimSuffix(dirRel, string(filepath.Separator))
|
||||
if pattern == "" {
|
||||
return "", "", "", fmt.Errorf("glob path %q is missing a file pattern", rel)
|
||||
}
|
||||
if _, err := filepath.Match(pattern, ""); err != nil {
|
||||
return "", "", "", fmt.Errorf("invalid glob pattern %q: %w", rel, err)
|
||||
}
|
||||
if dirRel == "" {
|
||||
dirRel = "."
|
||||
}
|
||||
if hasGlobMeta(dirRel) {
|
||||
return "", "", "", fmt.Errorf("glob path %q is only supported in the file name", rel)
|
||||
}
|
||||
dirPath, err := safeRunPath(safeRoot, dirRel)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return dirRel, pattern, dirPath, nil
|
||||
}
|
||||
|
||||
func runDirectoryExists(safeRoot, rel string) (string, bool, error) {
|
||||
info, path, exists, err := lstatRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return path, false, err
|
||||
}
|
||||
if !exists {
|
||||
return path, false, nil
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return path, false, fmt.Errorf("run path %q is not a directory", rel)
|
||||
}
|
||||
return path, true, nil
|
||||
}
|
||||
|
||||
func runRegularFileExists(safeRoot, rel string) (bool, error) {
|
||||
info, _, exists, err := lstatRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
return info.Mode().IsRegular(), nil
|
||||
}
|
||||
|
||||
func lstatRunPath(safeRoot, rel string) (fs.FileInfo, string, bool, error) {
|
||||
path, err := safeRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
if cleanRel == "." {
|
||||
info, err := vfs.Lstat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
return nil, path, false, err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
return info, path, true, nil
|
||||
}
|
||||
parts := strings.Split(cleanRel, string(filepath.Separator))
|
||||
cur := safeRoot
|
||||
var info fs.FileInfo
|
||||
for i, part := range parts {
|
||||
if part == "" || part == "." {
|
||||
continue
|
||||
}
|
||||
cur = filepath.Join(cur, part)
|
||||
info, err = vfs.Lstat(cur)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
return nil, path, false, err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink != 0 {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
if i < len(parts)-1 && !info.IsDir() {
|
||||
return nil, path, false, fmt.Errorf("run path component %q is not a directory", filepath.Join(parts[:i+1]...))
|
||||
}
|
||||
}
|
||||
if info == nil {
|
||||
return nil, path, false, nil
|
||||
}
|
||||
return info, path, true, nil
|
||||
}
|
||||
|
||||
func hasGlobMeta(path string) bool {
|
||||
return strings.ContainsAny(path, "*?[")
|
||||
}
|
||||
|
||||
func safeRunPath(safeRoot, rel string) (string, error) {
|
||||
if strings.TrimSpace(rel) == "" {
|
||||
return "", fmt.Errorf("run path must not be empty")
|
||||
}
|
||||
if isAbsoluteRunPath(rel) {
|
||||
return "", fmt.Errorf("run path %q must be relative to run root", rel)
|
||||
}
|
||||
cleanRel := filepath.Clean(rel)
|
||||
path := filepath.Clean(filepath.Join(safeRoot, cleanRel))
|
||||
rootRel, err := filepath.Rel(safeRoot, path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot compare run path %q with run root: %w", rel, err)
|
||||
}
|
||||
if rootRel == ".." || strings.HasPrefix(rootRel, ".."+string(filepath.Separator)) || filepath.IsAbs(rootRel) {
|
||||
return "", fmt.Errorf("run path %q escapes run root", rel)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func isAbsoluteRunPath(path string) bool {
|
||||
path = strings.TrimSpace(path)
|
||||
if filepath.IsAbs(path) || strings.HasPrefix(path, "/") || strings.HasPrefix(path, `\`) {
|
||||
return true
|
||||
}
|
||||
if len(path) >= 3 && path[1] == ':' && (path[2] == '/' || path[2] == '\\') {
|
||||
drive := path[0]
|
||||
return ('A' <= drive && drive <= 'Z') || ('a' <= drive && drive <= 'z')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
if value == "" {
|
||||
return "''"
|
||||
}
|
||||
if isShellBareword(value) {
|
||||
return value
|
||||
}
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
func isShellBareword(value string) bool {
|
||||
for _, r := range value {
|
||||
if ('a' <= r && r <= 'z') || ('A' <= r && r <= 'Z') || ('0' <= r && r <= '9') {
|
||||
continue
|
||||
}
|
||||
if strings.ContainsRune("_@%+=:,./-", r) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,497 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStatusReportsMissingOutputs(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if status.CurrentStage != StageRequest {
|
||||
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageRequest)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "request/source_manifest.json") {
|
||||
t.Fatalf("MissingOutputs = %v, want request/source_manifest.json", status.MissingOutputs)
|
||||
}
|
||||
if len(status.MissingInputs) != 0 {
|
||||
t.Fatalf("MissingInputs = %v, want empty", status.MissingInputs)
|
||||
}
|
||||
if status.NextCommand != "lark-cli slides +create-svglide --action next --run demo" {
|
||||
t.Fatalf("NextCommand = %q, want --action next shortcut with caller root", status.NextCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusQuotesNextCommandRunPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
root string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
root: "demo dir",
|
||||
want: "lark-cli slides +create-svglide --action next --run 'demo dir'",
|
||||
},
|
||||
{
|
||||
root: "demo' dir",
|
||||
want: "lark-cli slides +create-svglide --action next --run 'demo'\\'' dir'",
|
||||
},
|
||||
{
|
||||
root: "demo trail ",
|
||||
want: "lark-cli slides +create-svglide --action next --run 'demo trail '",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.root, func(t *testing.T) {
|
||||
cwd := initStatusTestRunAt(t, tt.root)
|
||||
|
||||
status, err := InspectStatus(tt.root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if status.NextCommand != tt.want {
|
||||
t.Fatalf("NextCommand = %q, want %q", status.NextCommand, tt.want)
|
||||
}
|
||||
if strings.Contains(status.NextCommand, cwd) {
|
||||
t.Fatalf("NextCommand = %q, should not contain absolute safe root %q", status.NextCommand, cwd)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskReturnsAnyGenPromptAssets(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if next.Stage != StageRequest {
|
||||
t.Fatalf("Stage = %q, want %q", next.Stage, StageRequest)
|
||||
}
|
||||
if next.PromptManifest != "prompt_manifest.json" {
|
||||
t.Fatalf("PromptManifest = %q, want prompt_manifest.json", next.PromptManifest)
|
||||
}
|
||||
if next.PromptPath != "" {
|
||||
t.Fatalf("PromptPath = %q, want empty deprecated field", next.PromptPath)
|
||||
}
|
||||
got := strings.Join(next.PromptPaths, "\n")
|
||||
for _, want := range []string{
|
||||
"skills/lark-slides/references/anygen-svg/mode_system_prompt_svg.md",
|
||||
"skills/lark-slides/references/anygen-svg/svg_reference.md",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("PromptPaths missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if len(next.Inputs) != 0 {
|
||||
t.Fatalf("Inputs = %v, want empty", next.Inputs)
|
||||
}
|
||||
if !slices.Equal(next.Outputs, []string{"request/request.json", "request/source_manifest.json"}) {
|
||||
t.Fatalf("Outputs = %v, want request outputs", next.Outputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskSeparatesAnyGenPromptsFromRuntimeAdapter(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask: %v", err)
|
||||
}
|
||||
|
||||
gotPrompts := strings.Join(next.PromptPaths, "\n")
|
||||
if strings.Contains(gotPrompts, "lark-slides-create-svglide.md") {
|
||||
t.Fatalf("PromptPaths should contain AnyGen assets only, got:\n%s", gotPrompts)
|
||||
}
|
||||
if !strings.Contains(gotPrompts, "skills/lark-slides/references/anygen-svg/README.md") {
|
||||
t.Fatalf("PromptPaths missing AnyGen README:\n%s", gotPrompts)
|
||||
}
|
||||
if len(next.AdapterPaths) != 1 || next.AdapterPaths[0] != "skills/lark-slides/references/lark-slides-create-svglide.md" {
|
||||
t.Fatalf("AdapterPaths = %#v, want create-svglide adapter", next.AdapterPaths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskDeclaresExecutionModeWithoutApprovalGate(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
|
||||
next, err := NextTask("demo")
|
||||
if err != nil {
|
||||
t.Fatalf("NextTask: %v", err)
|
||||
}
|
||||
|
||||
if next.Mode != "execution" {
|
||||
t.Fatalf("Mode = %q, want execution", next.Mode)
|
||||
}
|
||||
if next.ApprovalRequired {
|
||||
t.Fatalf("ApprovalRequired = true, want false")
|
||||
}
|
||||
if next.BlockingOwner != "svglide-runtime" {
|
||||
t.Fatalf("BlockingOwner = %q, want svglide-runtime", next.BlockingOwner)
|
||||
}
|
||||
if next.BlockingReason != "" {
|
||||
t.Fatalf("BlockingReason = %q, want empty", next.BlockingReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusRejectsUnsafeRunPath(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
if _, err := InspectStatus("../escape"); err == nil {
|
||||
t.Fatal("expected unsafe run path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRunReadsRunJSONAndRejectsAbsoluteRunPath(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
|
||||
run, err := ReadRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Title != "Demo" || run.CurrentStage != StageRequest {
|
||||
t.Fatalf("unexpected run: %+v", run)
|
||||
}
|
||||
|
||||
if _, err := ReadRun(filepath.Join(cwd, "demo")); err == nil {
|
||||
t.Fatal("expected absolute run path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusRejectsEscapingStagePath(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"../outside.json"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := InspectStatus("demo"); err == nil {
|
||||
t.Fatal("expected escaping stage output path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusReturnsStatErrorsThatAreNotMissing(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.RemoveAll(filepath.Join("demo", "request")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("demo", "request"), []byte("not a directory"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := InspectStatus("demo"); err == nil {
|
||||
t.Fatal("expected stat error when output parent is a file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusReportsDirectoryArtifactAsMissing(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
path := filepath.Join("demo", "request", "source_manifest.json")
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "request/source_manifest.json") {
|
||||
t.Fatalf("MissingOutputs = %v, want directory artifact to be missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsEscapingStagePath(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"../outside.json"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected escaping stage output path refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsMissingCurrentStageInputs(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageDesignBrief
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected missing current stage inputs to reject next task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsResearchMissingSourceManifest(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageResearch
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected missing research source manifest to reject next task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsOutlineMissingVisualSystem(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
if err := os.WriteFile(filepath.Join("demo", "brief", "design_brief.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageOutline
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected missing outline visual system to reject next task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusReportsMissingGlobUntilMatched(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = StageSVGAuthor
|
||||
setStatusTestStageOutputs(t, &run, StageSVGAuthor, []string{"slides/*.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want slides/*.svg", status.MissingOutputs)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join("demo", "slides", "01.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err = InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want glob satisfied by slides/01.svg", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobThroughIntermediateSymlink(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageSVGAuthor, []string{"link/bar/*.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside")
|
||||
if err := os.MkdirAll(filepath.Join(outside, "bar"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "bar", "01.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "link/bar/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want intermediate symlink glob to leave link/bar/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyArtifactThroughIntermediateSymlink(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"link/request.json"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "request.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "link/request.json") {
|
||||
t.Fatalf("MissingOutputs = %v, want intermediate symlink artifact to leave link/request.json missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobWithEscapingSymlinkDirectory(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
if err := os.RemoveAll(filepath.Join("demo", "slides")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outsideSlides := filepath.Join(filepath.Dir(cwd), "outside-slides")
|
||||
if err := os.MkdirAll(outsideSlides, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outsideSlides, "01.svg"), []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideSlides, filepath.Join("demo", "slides")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want symlink directory glob to leave slides/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobWithDirectory(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
if err := os.Mkdir(filepath.Join("demo", "slides", "01.svg"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want directory match to leave slides/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusDoesNotSatisfyGlobWithEscapingSymlink(t *testing.T) {
|
||||
cwd := initStatusTestRun(t)
|
||||
setCurrentStageForStatusTest(t, StageSVGAuthor)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside.svg")
|
||||
if err := os.WriteFile(outside, []byte("<svg/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "slides", "01.svg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
status, err := InspectStatus("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
|
||||
t.Fatalf("MissingOutputs = %v, want symlink match to leave slides/*.svg missing", status.MissingOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStatusRejectsInvalidGlobPattern(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"slides/[.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := InspectStatus("demo"); err == nil {
|
||||
t.Fatal("expected invalid glob pattern error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTaskRejectsInvalidGlobPattern(t *testing.T) {
|
||||
initStatusTestRun(t)
|
||||
run := readStatusTestRunFile(t)
|
||||
setStatusTestStageOutputs(t, &run, StageRequest, []string{"slides/[.svg"})
|
||||
writeStatusTestRunFile(t, run)
|
||||
|
||||
if _, err := NextTask("demo"); err == nil {
|
||||
t.Fatal("expected invalid glob pattern error")
|
||||
}
|
||||
}
|
||||
|
||||
func initStatusTestRun(t *testing.T) string {
|
||||
return initStatusTestRunAt(t, "demo")
|
||||
}
|
||||
|
||||
func initStatusTestRunAt(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initRoot := root
|
||||
if trimmed := strings.TrimSpace(root); trimmed != root {
|
||||
initRoot = trimmed
|
||||
}
|
||||
if err := InitRun(initRoot, InitOptions{Title: "Demo", Input: "source.md"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if initRoot != root {
|
||||
if err := os.Rename(initRoot, root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
func readStatusTestRunFile(t *testing.T) Run {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func writeStatusTestRunFile(t *testing.T, run Run) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(run, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(filepath.Join("demo", "run.json"), raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func setStatusTestStageOutputs(t *testing.T, run *Run, stageName string, outputs []string) {
|
||||
t.Helper()
|
||||
for i := range run.Stages {
|
||||
if run.Stages[i].Name == stageName {
|
||||
run.Stages[i].Outputs = outputs
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing stage %q", stageName)
|
||||
}
|
||||
|
||||
func setCurrentStageForStatusTest(t *testing.T, stageName string) {
|
||||
t.Helper()
|
||||
run := readStatusTestRunFile(t)
|
||||
run.CurrentStage = stageName
|
||||
writeStatusTestRunFile(t, run)
|
||||
}
|
||||
@@ -1,524 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const slideNamespace = "https://slides.bytedance.com/ns"
|
||||
const svgNamespace = "http://www.w3.org/2000/svg"
|
||||
const xlinkNamespace = "http://www.w3.org/1999/xlink"
|
||||
|
||||
type ValidationReport struct {
|
||||
OK bool `json:"ok"`
|
||||
Issues []ValidationIssue `json:"issues"`
|
||||
}
|
||||
|
||||
type ValidationIssue struct {
|
||||
Path string `json:"path"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
}
|
||||
|
||||
type validationDeck struct {
|
||||
Slides []validationDeckSlide `json:"slides"`
|
||||
}
|
||||
|
||||
type validationDeckSlide struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type svgViewBox struct {
|
||||
Width float64
|
||||
Height float64
|
||||
Valid bool
|
||||
}
|
||||
|
||||
type svgLintElement struct {
|
||||
Excluded bool
|
||||
TextCandidate bool
|
||||
}
|
||||
|
||||
func ValidateRun(root string) (ValidationReport, error) {
|
||||
safeRoot, run, err := readRun(root)
|
||||
if err != nil {
|
||||
return ValidationReport{}, err
|
||||
}
|
||||
|
||||
deckPath := strings.TrimSpace(run.Artifacts.Deck)
|
||||
if deckPath == "" {
|
||||
return failValidation(safeRoot, ValidationIssue{Code: "svglide.deck", Message: "deck artifact path is empty"}, fmt.Errorf("deck artifact path is empty"))
|
||||
}
|
||||
deckRaw, err := readRunRegularArtifact(safeRoot, deckPath)
|
||||
if err != nil {
|
||||
issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q: %v", deckPath, err)}
|
||||
return failValidation(safeRoot, issue, fmt.Errorf("read deck %q: %w", deckPath, err))
|
||||
}
|
||||
var deck validationDeck
|
||||
if err := json.Unmarshal(deckRaw, &deck); err != nil {
|
||||
issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q contains invalid JSON: %v", deckPath, err)}
|
||||
return failValidation(safeRoot, issue, fmt.Errorf("read deck %q: %w", deckPath, err))
|
||||
}
|
||||
if len(deck.Slides) == 0 {
|
||||
issue := ValidationIssue{Path: deckPath, Code: "svglide.deck", Message: fmt.Sprintf("deck %q contains no slides", deckPath)}
|
||||
return failValidation(safeRoot, issue, fmt.Errorf("deck %q contains no slides", deckPath))
|
||||
}
|
||||
|
||||
report := ValidationReport{Issues: []ValidationIssue{}}
|
||||
for _, slide := range deck.Slides {
|
||||
slidePath := strings.TrimSpace(slide.Path)
|
||||
if slidePath == "" {
|
||||
report.Issues = append(report.Issues, ValidationIssue{Code: "svglide.path", Message: "slide path must not be empty"})
|
||||
continue
|
||||
}
|
||||
|
||||
raw, err := readRunRegularArtifact(safeRoot, slidePath)
|
||||
if err != nil {
|
||||
report.Issues = append(report.Issues, ValidationIssue{Path: slidePath, Code: "svglide.path", Message: err.Error()})
|
||||
continue
|
||||
}
|
||||
report.Issues = append(report.Issues, lintSVG(slidePath, raw)...)
|
||||
}
|
||||
report = normalizeValidationReport(report)
|
||||
|
||||
if err := writeValidationArtifacts(safeRoot, report); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func failValidation(safeRoot string, issue ValidationIssue, err error) (ValidationReport, error) {
|
||||
report := ValidationReport{Issues: []ValidationIssue{issue}}
|
||||
report = normalizeValidationReport(report)
|
||||
if writeErr := writeValidationArtifacts(safeRoot, report); writeErr != nil {
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("%w; write validation artifacts: %v", err, writeErr)
|
||||
}
|
||||
return report, writeErr
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func readRunRegularArtifact(safeRoot string, rel string) ([]byte, error) {
|
||||
info, path, exists, err := lstatRunPath(safeRoot, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists || !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("run path %q is missing or not a regular file inside run root", rel)
|
||||
}
|
||||
raw, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read run path %q: %w", rel, err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func lintSVG(path string, raw []byte) []ValidationIssue {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(raw))
|
||||
var issues []ValidationIssue
|
||||
var rootSeen bool
|
||||
var rootIsSVG bool
|
||||
var hasSlideRole bool
|
||||
var hasViewBox bool
|
||||
var hasVisibleContent bool
|
||||
var viewBox svgViewBox
|
||||
var stack []svgLintElement
|
||||
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return []ValidationIssue{{Path: path, Code: "svglide.xml", Message: fmt.Sprintf("invalid XML: %v", err)}}
|
||||
}
|
||||
switch typed := token.(type) {
|
||||
case xml.StartElement:
|
||||
parentExcluded := len(stack) > 0 && stack[len(stack)-1].Excluded
|
||||
excluded := parentExcluded || elementIsHidden(typed) || elementIsNonRendering(typed)
|
||||
ctx := svgLintElement{
|
||||
Excluded: excluded,
|
||||
TextCandidate: elementIsTextCandidate(typed),
|
||||
}
|
||||
if !rootSeen {
|
||||
rootSeen = true
|
||||
rootIsSVG = typed.Name.Local == "svg" && typed.Name.Space == svgNamespace
|
||||
hasSlideRole = hasRootSlideRole(typed)
|
||||
viewBox, hasViewBox = rootViewBox(typed)
|
||||
issues = append(issues, lintSVGElementProtocol(path, typed, excluded)...)
|
||||
stack = append(stack, ctx)
|
||||
continue
|
||||
}
|
||||
issues = append(issues, lintSVGElementProtocol(path, typed, excluded)...)
|
||||
if elementCountsAsVisibleContent(typed, viewBox, excluded) {
|
||||
hasVisibleContent = true
|
||||
}
|
||||
stack = append(stack, ctx)
|
||||
case xml.CharData:
|
||||
if strings.TrimSpace(string(typed)) != "" && activeVisibleTextCandidate(stack) {
|
||||
hasVisibleContent = true
|
||||
}
|
||||
case xml.EndElement:
|
||||
if len(stack) > 0 {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !rootSeen {
|
||||
return []ValidationIssue{{Path: path, Code: "svglide.xml", Message: "invalid XML: missing root element"}}
|
||||
}
|
||||
if !rootIsSVG {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.root", Message: "root element must be <svg>"})
|
||||
}
|
||||
if !hasSlideRole {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.slide_role", Message: `root element must include slide:role="slide"`})
|
||||
}
|
||||
if !hasViewBox {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.viewbox", Message: "root element must include viewBox"})
|
||||
} else if !viewBox.Valid {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.viewbox", Message: "root element must include valid viewBox"})
|
||||
}
|
||||
if rootIsSVG && !hasVisibleContent {
|
||||
issues = append(issues, ValidationIssue{Path: path, Code: "svglide.visible_content", Message: "slide contains only background/placeholder content"})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func hasRootSlideRole(start xml.StartElement) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if strings.TrimSpace(attr.Value) != "slide" {
|
||||
continue
|
||||
}
|
||||
if attr.Name.Local == "role" && attr.Name.Space == slideNamespace {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func rootViewBox(start xml.StartElement) (svgViewBox, bool) {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space != "" || attr.Name.Local != "viewBox" || strings.TrimSpace(attr.Value) == "" {
|
||||
continue
|
||||
}
|
||||
return parseViewBox(attr.Value), true
|
||||
}
|
||||
return svgViewBox{}, false
|
||||
}
|
||||
|
||||
func parseViewBox(value string) svgViewBox {
|
||||
fields := strings.Fields(strings.ReplaceAll(value, ",", " "))
|
||||
if len(fields) != 4 {
|
||||
return svgViewBox{}
|
||||
}
|
||||
values := make([]float64, 4)
|
||||
for i, field := range fields {
|
||||
parsed, err := strconv.ParseFloat(field, 64)
|
||||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||||
return svgViewBox{}
|
||||
}
|
||||
values[i] = parsed
|
||||
}
|
||||
width := values[2]
|
||||
height := values[3]
|
||||
if width <= 0 || height <= 0 {
|
||||
return svgViewBox{}
|
||||
}
|
||||
return svgViewBox{Width: width, Height: height, Valid: true}
|
||||
}
|
||||
|
||||
func lintSVGElementProtocol(path string, start xml.StartElement, excluded bool) []ValidationIssue {
|
||||
if start.Name.Space != svgNamespace {
|
||||
return nil
|
||||
}
|
||||
|
||||
var issues []ValidationIssue
|
||||
if excluded {
|
||||
return issues
|
||||
}
|
||||
if elementHasNonPositiveDimension(start) {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Path: path,
|
||||
Code: "svglide.geometry",
|
||||
Message: fmt.Sprintf("<%s> has non-positive width or height", start.Name.Local),
|
||||
})
|
||||
}
|
||||
if start.Name.Local == "image" {
|
||||
if !hasSlideAttr(start, "role", "image") {
|
||||
issues = append(issues, ValidationIssue{
|
||||
Path: path,
|
||||
Code: "svglide.image_role",
|
||||
Message: `image must include slide:role="image"`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func elementHasNonPositiveDimension(start xml.StartElement) bool {
|
||||
for _, name := range []string{"width", "height"} {
|
||||
value, ok := plainAttr(start, name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
parsed, ok := parseSVGDimension(value)
|
||||
if ok && parsed <= 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasSlideAttr(start xml.StartElement, local string, value string) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space == slideNamespace && attr.Name.Local == local && strings.TrimSpace(attr.Value) == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func plainAttr(start xml.StartElement, local string) (string, bool) {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space == "" && attr.Name.Local == local {
|
||||
return attr.Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func parseSVGDimension(value string) (float64, bool) {
|
||||
s := strings.TrimSpace(value)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
lower := strings.ToLower(s)
|
||||
for _, suffix := range []string{"vmax", "vmin", "rem", "px", "%", "em", "pt", "pc", "in", "cm", "mm", "qh", "q", "ex", "ch", "vw", "vh"} {
|
||||
if strings.HasSuffix(lower, suffix) {
|
||||
s = strings.TrimSpace(s[:len(s)-len(suffix)])
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func elementCountsAsVisibleContent(start xml.StartElement, viewBox svgViewBox, excluded bool) bool {
|
||||
if excluded {
|
||||
return false
|
||||
}
|
||||
if start.Name.Space != svgNamespace {
|
||||
return false
|
||||
}
|
||||
if hasSemanticMarker(start, "background", "placeholder") {
|
||||
return false
|
||||
}
|
||||
switch start.Name.Local {
|
||||
case "text", "tspan":
|
||||
return false
|
||||
case "foreignObject", "chart":
|
||||
return true
|
||||
case "image", "use":
|
||||
return elementHasHref(start)
|
||||
case "g":
|
||||
return hasSemanticMarker(start, "chart", "shape")
|
||||
case "path", "circle", "ellipse", "line", "polyline", "polygon":
|
||||
return true
|
||||
case "rect":
|
||||
return !isBackgroundRect(start, viewBox)
|
||||
default:
|
||||
return hasSemanticMarker(start, "chart", "shape")
|
||||
}
|
||||
}
|
||||
|
||||
func activeVisibleTextCandidate(stack []svgLintElement) bool {
|
||||
for i := len(stack) - 1; i >= 0; i-- {
|
||||
if stack[i].Excluded {
|
||||
return false
|
||||
}
|
||||
if stack[i].TextCandidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func elementIsTextCandidate(start xml.StartElement) bool {
|
||||
return start.Name.Space == svgNamespace && (start.Name.Local == "text" || start.Name.Local == "tspan")
|
||||
}
|
||||
|
||||
func elementIsHidden(start xml.StartElement) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space != "" {
|
||||
continue
|
||||
}
|
||||
switch attr.Name.Local {
|
||||
case "display":
|
||||
if strings.EqualFold(strings.TrimSpace(attr.Value), "none") {
|
||||
return true
|
||||
}
|
||||
case "visibility":
|
||||
if strings.EqualFold(strings.TrimSpace(attr.Value), "hidden") {
|
||||
return true
|
||||
}
|
||||
case "opacity":
|
||||
if opacityIsZero(attr.Value) {
|
||||
return true
|
||||
}
|
||||
case "style":
|
||||
if styleHidesElement(attr.Value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func styleHidesElement(style string) bool {
|
||||
for _, declaration := range strings.Split(style, ";") {
|
||||
name, value, ok := strings.Cut(declaration, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||
case "display":
|
||||
if strings.EqualFold(strings.TrimSpace(value), "none") {
|
||||
return true
|
||||
}
|
||||
case "visibility":
|
||||
if strings.EqualFold(strings.TrimSpace(value), "hidden") {
|
||||
return true
|
||||
}
|
||||
case "opacity":
|
||||
if opacityIsZero(value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func opacityIsZero(value string) bool {
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||||
return false
|
||||
}
|
||||
return floatEqual(parsed, 0)
|
||||
}
|
||||
|
||||
func elementIsNonRendering(start xml.StartElement) bool {
|
||||
if start.Name.Space != svgNamespace {
|
||||
return false
|
||||
}
|
||||
switch start.Name.Local {
|
||||
case "defs", "symbol", "clipPath", "mask", "pattern", "linearGradient", "radialGradient", "marker", "metadata", "title", "desc", "style", "script":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func elementHasHref(start xml.StartElement) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Local != "href" || strings.TrimSpace(attr.Value) == "" {
|
||||
continue
|
||||
}
|
||||
if attr.Name.Space == "" || attr.Name.Space == xlinkNamespace {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasSemanticMarker(start xml.StartElement, terms ...string) bool {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space != "" {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(attr.Name.Local)
|
||||
if name != "role" && name != "class" && name != "id" && !strings.HasPrefix(name, "data-") {
|
||||
continue
|
||||
}
|
||||
value := strings.ToLower(attr.Value)
|
||||
for _, term := range terms {
|
||||
if strings.Contains(value, term) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isBackgroundRect(start xml.StartElement, viewBox svgViewBox) bool {
|
||||
if hasSemanticMarker(start, "background", "placeholder") {
|
||||
return true
|
||||
}
|
||||
width := attrValue(start, "width")
|
||||
height := attrValue(start, "height")
|
||||
if width == "100%" && height == "100%" {
|
||||
return true
|
||||
}
|
||||
if !viewBox.Valid {
|
||||
return false
|
||||
}
|
||||
x := attrFloatDefault(start, "x", 0)
|
||||
y := attrFloatDefault(start, "y", 0)
|
||||
w, okW := parseAttrFloat(width)
|
||||
h, okH := parseAttrFloat(height)
|
||||
if !okW || !okH {
|
||||
return false
|
||||
}
|
||||
return floatEqual(x, 0) && floatEqual(y, 0) && floatEqual(w, viewBox.Width) && floatEqual(h, viewBox.Height)
|
||||
}
|
||||
|
||||
func attrValue(start xml.StartElement, name string) string {
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space == "" && attr.Name.Local == name {
|
||||
return strings.TrimSpace(attr.Value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func attrFloatDefault(start xml.StartElement, name string, fallback float64) float64 {
|
||||
value := attrValue(start, name)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, ok := parseAttrFloat(value)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func parseAttrFloat(value string) (float64, bool) {
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func floatEqual(a float64, b float64) bool {
|
||||
return math.Abs(a-b) < 0.001
|
||||
}
|
||||
@@ -1,993 +0,0 @@
|
||||
package svglide
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateRunRejectsBackgroundOnlySVGAndWritesRepairArtifacts(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), backgroundOnlySVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if len(report.Issues) == 0 {
|
||||
t.Fatal("expected background-only SVG issue")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "background") {
|
||||
t.Fatalf("Issues = %+v, want background/placeholder issue", report.Issues)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "lint.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing lint receipt: %v", err)
|
||||
}
|
||||
var receipt ValidationReport
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("lint receipt is not ValidationReport JSON: %v", err)
|
||||
}
|
||||
if receipt.OK || len(receipt.Issues) == 0 {
|
||||
t.Fatalf("lint receipt = %+v, want failing issues", receipt)
|
||||
}
|
||||
var lintReceipt validationLintReceipt
|
||||
if err := json.Unmarshal(raw, &lintReceipt); err != nil {
|
||||
t.Fatalf("lint receipt is not schema-compatible JSON: %v", err)
|
||||
}
|
||||
if lintReceipt.Status != "failed" {
|
||||
t.Fatalf("lint receipt status = %q, want failed", lintReceipt.Status)
|
||||
}
|
||||
if lintReceipt.Issues[0].Code == "" || lintReceipt.Issues[0].Severity == "" {
|
||||
t.Fatalf("lint receipt issue = %+v, want code and severity", lintReceipt.Issues[0])
|
||||
}
|
||||
|
||||
queue, err := os.ReadFile(filepath.Join("demo", "repair_queue.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing repair queue: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(queue), "slides/01.svg") {
|
||||
t.Fatalf("repair queue = %q, want slide path", string(queue))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunPassesVisibleTextSVG(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
if len(report.Issues) != 0 {
|
||||
t.Fatalf("Issues = %+v, want empty", report.Issues)
|
||||
}
|
||||
queue, err := os.ReadFile(filepath.Join("demo", "repair_queue.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing repair queue: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(queue)) != "No repair needed." {
|
||||
t.Fatalf("repair queue = %q, want no repair text", string(queue))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsEscapingSlidePath(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "../outside.svg")
|
||||
writeValidateTestFile(t, "outside.svg", visibleTextSVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err == nil && report.OK {
|
||||
t.Fatalf("ValidateRun OK with escaping slide path: %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsSlideSymlinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deckPath string
|
||||
setupLink func(t *testing.T, outside string)
|
||||
}{
|
||||
{
|
||||
name: "file symlink",
|
||||
deckPath: "slides/01.svg",
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
if err := os.Symlink(filepath.Join(outside, "01.svg"), filepath.Join("demo", "slides", "01.svg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "intermediate symlink",
|
||||
deckPath: "slides/link/01.svg",
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "slides", "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", tt.deckPath)
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "01.svg"), []byte(visibleTextSVG()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.setupLink(t, outside)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err == nil && report.OK {
|
||||
t.Fatalf("ValidateRun OK with symlinked slide path: %+v", report)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsDeckSymlinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deckPath string
|
||||
setupLink func(t *testing.T, outside string)
|
||||
}{
|
||||
{
|
||||
name: "file symlink",
|
||||
deckPath: filepath.Join("demo", "outline", "deck.json"),
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(outside, "deck.json"), filepath.Join("demo", "outline", "deck.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "intermediate symlink",
|
||||
deckPath: filepath.Join("demo", "outline_link", "deck.json"),
|
||||
setupLink: func(t *testing.T, outside string) {
|
||||
run := readValidateTestRunFile(t)
|
||||
run.Artifacts.Deck = "outline_link/deck.json"
|
||||
writeValidateTestRunFile(t, run)
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "outline_link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeMinimalDeckAt(t, filepath.Join(outside, "deck.json"), "slides/01.svg")
|
||||
tt.setupLink(t, outside)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("ValidateRun OK with symlinked deck path %q: %+v", tt.deckPath, report)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsEmptyDeck(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo")
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertValidationFailureArtifacts(t, "demo", report, "no slides")
|
||||
}
|
||||
|
||||
func TestValidateRunWritesRepairArtifactsForDeckReadFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing deck",
|
||||
setup: func(t *testing.T) {
|
||||
if err := os.Remove(filepath.Join("demo", "outline", "deck.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
wantErr: "deck",
|
||||
},
|
||||
{
|
||||
name: "invalid deck json",
|
||||
setup: func(t *testing.T) {
|
||||
writeValidateTestFile(t, filepath.Join("demo", "outline", "deck.json"), `{`)
|
||||
},
|
||||
wantErr: "deck",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
tt.setup(t)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertValidationFailureArtifacts(t, "demo", report, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunReadsDeckFromRunArtifacts(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
run := readValidateTestRunFile(t)
|
||||
run.Artifacts.Deck = "custom/deck.json"
|
||||
writeValidateTestRunFile(t, run)
|
||||
writeMinimalDeck(t, "demo", "slides/bad.svg")
|
||||
writeMinimalDeckAt(t, filepath.Join("demo", "custom", "deck.json"), "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunReportsInvalidXML(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg><text>broken`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "XML") && !validationIssuesContain(report.Issues, "xml") {
|
||||
t.Fatalf("Issues = %+v, want XML parse issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRequiresSVGRootSlideRoleAndViewBox(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
svg string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "non svg root",
|
||||
svg: `<html><body>not svg</body></html>`,
|
||||
want: "<svg>",
|
||||
},
|
||||
{
|
||||
name: "wrong svg namespace",
|
||||
svg: `<svg xmlns="https://wrong.example/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: "<svg>",
|
||||
},
|
||||
{
|
||||
name: "missing slide role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: `slide:role`,
|
||||
},
|
||||
{
|
||||
name: "missing viewBox",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><text>hello</text></svg>`,
|
||||
want: `viewBox`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespaced slide role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:foo="https://wrong.example" foo:role="slide" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: `slide:role`,
|
||||
},
|
||||
{
|
||||
name: "unbound slide prefix role",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" slide:role="slide" viewBox="0 0 960 540"><text>hello</text></svg>`,
|
||||
want: `slide:role`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), tt.svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, tt.want) {
|
||||
t.Fatalf("Issues = %+v, want %q", report.Issues, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsInvalidViewBox(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
svg string
|
||||
}{
|
||||
{
|
||||
name: "bad viewBox with text",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="bad"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "bad viewBox origin fields",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="bad bad 960 540"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "nan viewBox width",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 NaN 540"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "zero viewBox with text",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 0 540"><text>hello</text></svg>`,
|
||||
},
|
||||
{
|
||||
name: "bad viewBox with full page rect",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="bad"><rect width="960" height="540" fill="#fff"/></svg>`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), tt.svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "viewBox") {
|
||||
t.Fatalf("Issues = %+v, want viewBox issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunIgnoresNonVisibleContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "text in defs",
|
||||
body: `<defs><text>hidden template</text></defs>`,
|
||||
},
|
||||
{
|
||||
name: "display none text",
|
||||
body: `<text display="none">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "visibility hidden text",
|
||||
body: `<text visibility="hidden">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "style display none text",
|
||||
body: `<text style="display:none">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "style visibility hidden text",
|
||||
body: `<text style="visibility:hidden">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "opacity zero text",
|
||||
body: `<text opacity="0">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "style opacity zero text",
|
||||
body: `<text style="opacity:0">hidden</text>`,
|
||||
},
|
||||
{
|
||||
name: "empty text",
|
||||
body: `<text> </text>`,
|
||||
},
|
||||
{
|
||||
name: "image without href",
|
||||
body: `<image slide:role="image" width="120" height="80"/>`,
|
||||
},
|
||||
{
|
||||
name: "use without href",
|
||||
body: `<use x="10" y="10"/>`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540">` + tt.body + `</svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "background") && !validationIssuesContain(report.Issues, "placeholder") {
|
||||
t.Fatalf("Issues = %+v, want placeholder issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsWrongNamespaceVisibleContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "wrong namespace path",
|
||||
body: `<bad:path xmlns:bad="https://wrong.example/svg" d="M10 10h20v20z"/>`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespace text",
|
||||
body: `<bad:text xmlns:bad="https://wrong.example/svg">hidden by namespace</bad:text>`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespace image href",
|
||||
body: `<image xmlns:bad="https://wrong.example/svg" bad:href="asset.png" width="120" height="80"/>`,
|
||||
},
|
||||
{
|
||||
name: "wrong namespace viewBox",
|
||||
body: `<text>hello</text>`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
viewBox := `viewBox="0 0 960 540"`
|
||||
if tt.name == "wrong namespace viewBox" {
|
||||
viewBox = `bad:viewBox="0 0 960 540" xmlns:bad="https://wrong.example/svg"`
|
||||
}
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" ` + viewBox + `>` + tt.body + `</svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if tt.name == "wrong namespace viewBox" {
|
||||
if !validationIssuesContain(report.Issues, "viewBox") {
|
||||
t.Fatalf("Issues = %+v, want viewBox issue", report.Issues)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, "background") && !validationIssuesContain(report.Issues, "placeholder") {
|
||||
t.Fatalf("Issues = %+v, want placeholder issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAcceptsNamespacedXLinkHref(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" xmlns:xlink="http://www.w3.org/1999/xlink" slide:role="slide" viewBox="0 0 960 540"><image slide:role="image" xlink:href="assets/images/asset.png" width="120" height="80"/></svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsNegativeElementDimensions(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<foreignObject x="10" y="10" width="100" height="-4" slide:role="shape" slide:shape-type="text">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">Bad size</div>
|
||||
</foreignObject>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want geometry issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHref(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image slide:role="image" slide:shape-type="image" href="https://example.com/hero.png" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true: %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHrefCaseInsensitive(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image slide:role="image" slide:shape-type="image" href="HTTPS://example.com/hero.png" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true: %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsImageWithoutImageRole(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image href="assets/images/hero.png" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if !validationIssuesContainCode(report.Issues, "svglide.image_role") {
|
||||
t.Fatalf("issues = %+v, want image role issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunIgnoresGeometryAndImageRoleInsideExcludedContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "defs image",
|
||||
body: `<defs><image href="assets/images/defs.png" width="-4px" height="120"/></defs><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "pattern image",
|
||||
body: `<pattern id="p"><image href="assets/images/pattern.png" width="120" height="0%"/></pattern><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "mask image",
|
||||
body: `<mask id="m"><image href="assets/images/mask.png" width="auto" height="-4px"/></mask><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "display none image",
|
||||
body: `<g display="none"><image href="assets/images/hidden.png" width="-4px" height="120"/></g><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "visibility hidden image",
|
||||
body: `<g visibility="hidden"><image href="assets/images/hidden.png" width="120" height="-4px"/></g><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
{
|
||||
name: "marker image role",
|
||||
body: `<marker id="mk"><image href="assets/images/marker.png" width="120" height="80"/></marker><text x="48" y="80">Hello</text>`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">` + tt.body + `</svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want no geometry issue", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, want no remote asset issue", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.image_role") {
|
||||
t.Fatalf("issues = %+v, want no image role issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHrefWithXLink(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image slide:role="image" xlink:href="https://example.com/hero.png" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true: %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHrefVariants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
href string
|
||||
}{
|
||||
{name: "parent directory", href: "../outside.png"},
|
||||
{name: "absolute path", href: "/Users/example/secret.png"},
|
||||
{name: "file url", href: "file:///tmp/secret.png"},
|
||||
{name: "protocol relative", href: "//example.com/hero.png"},
|
||||
{name: "data url", href: "data:image/png;base64,AAAA"},
|
||||
{name: "percent encoding", href: "assets/images/hero%2epng"},
|
||||
{name: "nested asset path", href: "assets/images/nested/hero.png"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<image slide:role="image" slide:shape-type="image" href="`+tt.href+`" x="10" y="10" width="200" height="120"/>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true for %s: %+v", tt.href, report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAllowsExperimentImageHrefInsideExcludedContent(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<defs><image href="file:///tmp/secret.png" width="-4px" height="120"/></defs>
|
||||
<text x="48" y="80">Hello</text>
|
||||
</svg>`)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, want true: %+v", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.remote_asset") {
|
||||
t.Fatalf("issues = %+v, did not expect remote asset issue", report.Issues)
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want no geometry issue inside excluded content", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsDimensionUnits(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
svg string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "negative px",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<foreignObject x="10" y="10" width="100" height="-4px" slide:role="shape" slide:shape-type="text">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">Bad size</div>
|
||||
</foreignObject>
|
||||
</svg>`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "zero percent",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<foreignObject x="10" y="10" width="0%" height="20" slide:role="shape" slide:shape-type="text">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">Bad size</div>
|
||||
</foreignObject>
|
||||
</svg>`,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "auto width",
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" viewBox="0 0 960 540" slide:role="slide">
|
||||
<foreignObject x="10" y="10" width="auto" height="20" slide:role="shape" slide:shape-type="text">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">Fine</div>
|
||||
</foreignObject>
|
||||
<text x="48" y="80">Hello</text>
|
||||
</svg>`,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), tt.svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.want {
|
||||
if !validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want geometry issue", report.Issues)
|
||||
}
|
||||
return
|
||||
}
|
||||
if validationIssuesContainCode(report.Issues, "svglide.geometry") {
|
||||
t.Fatalf("issues = %+v, want no geometry issue", report.Issues)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunAcceptsPlainHref(t *testing.T) {
|
||||
initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><image slide:role="image" href="assets/images/asset.png" width="120" height="80"/></svg>`
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), svg)
|
||||
|
||||
report, err := ValidateRun("demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("OK = false, issues = %+v", report.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsReceiptSymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
if err := os.RemoveAll(filepath.Join("demo", "receipts")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-receipts")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "receipts")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := ValidateRun("demo"); err == nil {
|
||||
t.Fatal("expected receipt symlink write refusal")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "lint.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("lint receipt should not be written outside run root, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunRejectsLintReceiptFileSymlink(t *testing.T) {
|
||||
cwd := initValidateTestRun(t)
|
||||
writeMinimalDeck(t, "demo", "slides/01.svg")
|
||||
writeValidateTestFile(t, filepath.Join("demo", "slides", "01.svg"), visibleTextSVG())
|
||||
if err := os.Remove(filepath.Join("demo", "receipts", "lint.json")); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(cwd), "outside-lint.json")
|
||||
if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join("demo", "receipts", "lint.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := ValidateRun("demo"); err == nil {
|
||||
t.Fatal("expected lint receipt file symlink write refusal")
|
||||
}
|
||||
raw, err := os.ReadFile(outside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "outside" {
|
||||
t.Fatalf("outside file was overwritten: %q", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func initValidateTestRun(t *testing.T) string {
|
||||
t.Helper()
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := InitRun("demo", InitOptions{Title: "Demo", Input: "source.md"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
func writeMinimalDeck(t *testing.T, root string, slidePaths ...string) {
|
||||
t.Helper()
|
||||
writeMinimalDeckAt(t, filepath.Join(root, "outline", "deck.json"), slidePaths...)
|
||||
}
|
||||
|
||||
func writeMinimalDeckAt(t *testing.T, path string, slidePaths ...string) {
|
||||
t.Helper()
|
||||
slides := make([]map[string]string, 0, len(slidePaths))
|
||||
for i, path := range slidePaths {
|
||||
slides = append(slides, map[string]string{
|
||||
"id": "slide-" + string(rune('1'+i)),
|
||||
"title": "Slide",
|
||||
"summary": "Summary",
|
||||
"role": "content",
|
||||
"key_message": "Message",
|
||||
"path": path,
|
||||
})
|
||||
}
|
||||
raw, err := json.MarshalIndent(map[string]any{
|
||||
"title": "Demo",
|
||||
"slides": slides,
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
writeValidateTestFile(t, path, string(raw))
|
||||
}
|
||||
|
||||
func readValidateTestRunFile(t *testing.T) Run {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("demo", "run.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var run Run
|
||||
if err := json.Unmarshal(raw, &run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func writeValidateTestRunFile(t *testing.T, run Run) {
|
||||
t.Helper()
|
||||
raw, err := json.MarshalIndent(run, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
if err := os.WriteFile(filepath.Join("demo", "run.json"), raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeValidateTestFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func backgroundOnlySVG() string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><rect width="960" height="540" fill="#fff"/></svg>`
|
||||
}
|
||||
|
||||
func visibleTextSVG() string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><rect width="960" height="540" fill="#fff"/><text x="48" y="80">Hello</text></svg>`
|
||||
}
|
||||
|
||||
func validationIssuesContain(issues []ValidationIssue, needle string) bool {
|
||||
for _, issue := range issues {
|
||||
if strings.Contains(issue.Path, needle) || strings.Contains(issue.Message, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validationIssuesContainCode(issues []ValidationIssue, code string) bool {
|
||||
for _, issue := range issues {
|
||||
if issue.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertValidationFailureArtifacts(t *testing.T, root string, report ValidationReport, needle string) {
|
||||
t.Helper()
|
||||
if report.OK {
|
||||
t.Fatalf("OK = true, want false")
|
||||
}
|
||||
if len(report.Issues) == 0 {
|
||||
t.Fatal("expected validation issue")
|
||||
}
|
||||
if !validationIssuesContain(report.Issues, needle) {
|
||||
t.Fatalf("Issues = %+v, want %q", report.Issues, needle)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(root, "receipts", "lint.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing lint receipt: %v", err)
|
||||
}
|
||||
var receipt ValidationReport
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("lint receipt is not ValidationReport JSON: %v", err)
|
||||
}
|
||||
if receipt.OK || !validationIssuesContain(receipt.Issues, needle) {
|
||||
t.Fatalf("lint receipt = %+v, want failing issue containing %q", receipt, needle)
|
||||
}
|
||||
|
||||
queue, err := os.ReadFile(filepath.Join(root, "repair_queue.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing repair queue: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(queue), needle) {
|
||||
t.Fatalf("repair queue = %q, want %q", string(queue), needle)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
const (
|
||||
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
|
||||
cacheTTL = 24 * time.Hour
|
||||
fetchTimeout = 5 * time.Second
|
||||
fetchTimeout = 15 * time.Second
|
||||
stateFile = "update-state.json"
|
||||
maxBody = 256 << 10 // 256 KB
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ func OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error) {
|
||||
return DefaultFS.OpenFile(name, flag, perm)
|
||||
}
|
||||
func CreateTemp(dir, pattern string) (*os.File, error) { return DefaultFS.CreateTemp(dir, pattern) }
|
||||
func Mkdir(path string, perm fs.FileMode) error { return DefaultFS.Mkdir(path, perm) }
|
||||
func MkdirAll(path string, perm fs.FileMode) error { return DefaultFS.MkdirAll(path, perm) }
|
||||
func MkdirTemp(dir, pattern string) (string, error) { return DefaultFS.MkdirTemp(dir, pattern) }
|
||||
func ReadDir(name string) ([]os.DirEntry, error) { return DefaultFS.ReadDir(name) }
|
||||
|
||||
@@ -25,7 +25,6 @@ type FS interface {
|
||||
CreateTemp(dir, pattern string) (*os.File, error)
|
||||
|
||||
// Directory/File management
|
||||
Mkdir(path string, perm fs.FileMode) error
|
||||
MkdirAll(path string, perm fs.FileMode) error
|
||||
MkdirTemp(dir, pattern string) (string, error)
|
||||
ReadDir(name string) ([]os.DirEntry, error)
|
||||
|
||||
@@ -30,7 +30,6 @@ func (OsFs) OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error)
|
||||
func (OsFs) CreateTemp(dir, pattern string) (*os.File, error) { return os.CreateTemp(dir, pattern) }
|
||||
|
||||
// Directory/File management
|
||||
func (OsFs) Mkdir(path string, perm fs.FileMode) error { return os.Mkdir(path, perm) }
|
||||
func (OsFs) MkdirAll(path string, perm fs.FileMode) error { return os.MkdirAll(path, perm) }
|
||||
func (OsFs) MkdirTemp(dir, pattern string) (string, error) { return os.MkdirTemp(dir, pattern) }
|
||||
func (OsFs) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) }
|
||||
|
||||
@@ -23,15 +23,6 @@ func TestOsFsBasicOperations(t *testing.T) {
|
||||
fs := OsFs{}
|
||||
dir := t.TempDir()
|
||||
|
||||
// Mkdir
|
||||
one := filepath.Join(dir, "one")
|
||||
if err := fs.Mkdir(one, 0o755); err != nil {
|
||||
t.Fatalf("Mkdir: %v", err)
|
||||
}
|
||||
if err := Mkdir(filepath.Join(dir, "two"), 0o755); err != nil {
|
||||
t.Fatalf("package Mkdir: %v", err)
|
||||
}
|
||||
|
||||
// MkdirAll
|
||||
sub := filepath.Join(dir, "a", "b")
|
||||
if err := fs.MkdirAll(sub, 0o755); err != nil {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.63",
|
||||
"version": "1.0.64",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -40,7 +40,7 @@ var AppsDBAuditList = common.Shortcut{
|
||||
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
|
||||
@@ -35,7 +35,7 @@ var AppsDBAuditEnable = common.Shortcut{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "table to enable audit for", Required: true},
|
||||
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
@@ -96,7 +96,7 @@ var AppsDBAuditDisable = common.Shortcut{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "table to disable audit for", Required: true},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
|
||||
@@ -30,7 +30,7 @@ var AppsDBAuditStatus = common.Shortcut{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
|
||||
@@ -39,7 +39,7 @@ var AppsDBChangelogList = common.Shortcut{
|
||||
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
|
||||
@@ -47,7 +47,7 @@ var AppsDBDataExport = common.Shortcut{
|
||||
{Name: "table", Desc: "source table", Required: true},
|
||||
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
|
||||
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "source db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
|
||||
@@ -44,7 +44,7 @@ var AppsDBDataImport = common.Shortcut{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
|
||||
{Name: "table", Desc: "target table (default: file name without extension)"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
|
||||
@@ -66,7 +66,7 @@ var AppsDBExecute = common.Shortcut{
|
||||
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
|
||||
Input: []string{common.Stdin}},
|
||||
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
|
||||
@@ -29,7 +29,7 @@ var AppsDBQuotaGet = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
|
||||
@@ -37,7 +37,7 @@ var AppsDBTableGet = common.Shortcut{
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "app id", Required: true},
|
||||
{Name: "table", Desc: "table name", Required: true},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
|
||||
@@ -42,7 +42,7 @@ var AppsDBTableList = common.Shortcut{
|
||||
{Name: "app-id", Desc: "app id", Required: true},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
|
||||
@@ -927,10 +927,6 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
}
|
||||
}
|
||||
|
||||
if s.LocalOnly {
|
||||
return runLocalShortcut(cmd, f, s, botOnly)
|
||||
}
|
||||
|
||||
as, err := resolveShortcutIdentity(cmd, f, s)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -952,23 +948,6 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
return err
|
||||
}
|
||||
|
||||
return runShortcutWithContext(f, rctx, s)
|
||||
}
|
||||
|
||||
func runLocalShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
|
||||
as := core.AsBot
|
||||
if asFlag, _ := cmd.Flags().GetString("as"); asFlag != "" && core.Identity(asFlag) != core.AsAuto {
|
||||
as = core.Identity(asFlag)
|
||||
}
|
||||
if err := f.CheckIdentity(as, s.AuthTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
config := &core.CliConfig{Brand: core.BrandFeishu}
|
||||
rctx := newLocalRuntimeContext(cmd, f, s, config, as, botOnly)
|
||||
return runShortcutWithContext(f, rctx, s)
|
||||
}
|
||||
|
||||
func runShortcutWithContext(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error {
|
||||
if err := validateEnumFlags(rctx, s.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1052,21 +1031,6 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
return rctx, nil
|
||||
}
|
||||
|
||||
func newLocalRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) *RuntimeContext {
|
||||
ctx := cmd.Context()
|
||||
ctx = cmdutil.ContextWithShortcut(ctx, s.Service+":"+s.Command, uuid.New().String())
|
||||
rctx := &RuntimeContext{ctx: ctx, Config: config, Cmd: cmd, botOnly: botOnly, resolvedAs: as, Factory: f}
|
||||
rctx.apiClientFunc = sync.OnceValues(func() (*client.APIClient, error) {
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s %s is local-only and cannot call OpenAPI", s.Service, s.Command)
|
||||
})
|
||||
rctx.botInfoFunc = sync.OnceValues(func() (*BotInfo, error) {
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s %s is local-only and has no bot identity", s.Service, s.Command)
|
||||
})
|
||||
rctx.Format = rctx.Str("format")
|
||||
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
|
||||
return rctx
|
||||
}
|
||||
|
||||
// stripUTF8BOM removes a leading UTF-8 byte-order mark from content read from a
|
||||
// file or stdin. A BOM that survives into a CSV cell corrupts the first value
|
||||
// (e.g. "\ufeffNorth", which then makes a MAXIFS/lookup miss it), and a BOM at the
|
||||
@@ -1289,12 +1253,5 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
}
|
||||
}
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
if s.LocalOnly {
|
||||
cmd.Flags().String("as", "", "identity type: "+strings.Join(s.AuthTypes, " | "))
|
||||
cmdutil.RegisterFlagCompletion(cmd, "as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return s.AuthTypes, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
return
|
||||
}
|
||||
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ type Shortcut struct {
|
||||
HasFormat bool // Deprecated: --format is now always injected; this field has no effect.
|
||||
Tips []string // optional tips shown in --help output
|
||||
Hidden bool // hide from --help / tab completion (still executable); use when deprecating a command in favor of a replacement
|
||||
LocalOnly bool // pure local command: no identity, config, scope, SDK, or OpenAPI bootstrap
|
||||
|
||||
// Business logic hooks.
|
||||
DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set
|
||||
|
||||
@@ -23,8 +23,8 @@ var DocMediaUpload = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
|
||||
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard", Required: true},
|
||||
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard)", Required: true},
|
||||
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard | mindnote_image", Required: true},
|
||||
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard, mindnote token for mindnote)", Required: true},
|
||||
{Name: "doc-id", Desc: "document ID (for drive_route_token)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
|
||||
@@ -75,7 +75,7 @@ var DriveSearch = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
|
||||
{Name: "query", Desc: "search keyword (may be empty to browse by filter only); max 30 characters by Unicode code point (CJK counts 1 each), over 30 the server rejects with 99992402 field validation failed"},
|
||||
|
||||
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
|
||||
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
|
||||
|
||||
@@ -66,31 +66,24 @@ var MinutesSpeakerReplace = common.Shortcut{
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
dr := common.NewDryRunAPI()
|
||||
if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" {
|
||||
dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name")
|
||||
}
|
||||
return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
||||
return common.NewDryRunAPI().
|
||||
PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
||||
Body(buildSpeakerReplaceRequestBody(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||
fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
||||
fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
||||
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
|
||||
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
||||
|
||||
fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = runtime.CallAPITyped(http.MethodPut,
|
||||
_, err := runtime.CallAPITyped(http.MethodPut,
|
||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
|
||||
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
|
||||
if err != nil {
|
||||
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID))
|
||||
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerID, fromUserID))
|
||||
}
|
||||
|
||||
runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
|
||||
runtime.OutFormat(buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -114,26 +107,20 @@ func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID
|
||||
return body
|
||||
}
|
||||
|
||||
func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
||||
func buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
||||
out := map[string]interface{}{
|
||||
"minute_token": minuteToken,
|
||||
"to_user_id": toUserID,
|
||||
}
|
||||
if fromSpeakerID != "" {
|
||||
out["from_speaker_id"] = fromSpeakerID
|
||||
if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID {
|
||||
out["from_speaker_input"] = fromSpeakerInput
|
||||
}
|
||||
} else {
|
||||
out["from_user_id"] = fromUserID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string {
|
||||
if fromSpeakerInput != "" {
|
||||
return fromSpeakerInput
|
||||
}
|
||||
func speakerReplaceSourceLabel(fromSpeakerID, fromUserID string) string {
|
||||
if fromSpeakerID != "" {
|
||||
return fromSpeakerID
|
||||
}
|
||||
|
||||
@@ -153,58 +153,14 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||
"+speaker-replace",
|
||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||
"--from-speaker-id", "说话人1",
|
||||
"--to-user-id", "ou_new_speaker",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "GET") {
|
||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/transcript/speakerlist") {
|
||||
t.Errorf("expected speakerlist path, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "PUT") {
|
||||
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "ou_new_speaker") {
|
||||
t.Errorf("expected to_user_id in body, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
||||
func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"speakers": []interface{}{
|
||||
map[string]interface{}{
|
||||
"speaker_id": "ENCRYPTED_TOKEN_ABC",
|
||||
"name": "说话人1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
// Only the PUT is registered on purpose: an opaque speaker_id must be passed
|
||||
// straight through without a second speakerlist call. If the code still
|
||||
// prefetched speakerlist, the unregistered GET would fail the request.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodPut,
|
||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
|
||||
@@ -218,7 +174,7 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||
"+speaker-replace",
|
||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||
"--from-speaker-id", "说话人1",
|
||||
"--from-speaker-id", "ENCRYPTED_TOKEN_ABC",
|
||||
"--to-user-id", "ou_new_speaker",
|
||||
"--format", "json", "--as", "user",
|
||||
}, f, stdout)
|
||||
@@ -228,21 +184,19 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
MinuteToken string `json:"minute_token"`
|
||||
FromSpeakerInput string `json:"from_speaker_input"`
|
||||
FromSpeakerID string `json:"from_speaker_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
FromSpeakerID string `json:"from_speaker_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("unmarshal stdout: %v", err)
|
||||
}
|
||||
if envelope.Data.FromSpeakerInput != "说话人1" {
|
||||
t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput)
|
||||
}
|
||||
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
|
||||
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
|
||||
}
|
||||
if envelope.Data.ToUserID != "ou_new_speaker" {
|
||||
t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
||||
@@ -262,8 +216,11 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "GET") {
|
||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
||||
if strings.Contains(out, "/transcript/speakerlist") {
|
||||
t.Errorf("opaque speaker_id should not prefetch speakerlist, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "PUT") {
|
||||
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
|
||||
t.Errorf("expected from_speaker_id in body, got:\n%s", out)
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type minuteSpeaker struct {
|
||||
SpeakerID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func minuteTranscriptSpeakerlistPath(minuteToken string) string {
|
||||
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken))
|
||||
}
|
||||
|
||||
func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
items := common.GetSlice(data, "speakers")
|
||||
speakers := make([]minuteSpeaker, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(common.GetString(item, "speaker_id"))
|
||||
name := strings.TrimSpace(common.GetString(item, "name"))
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name})
|
||||
}
|
||||
return speakers, nil
|
||||
}
|
||||
|
||||
func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
var matches []minuteSpeaker
|
||||
for _, s := range speakers {
|
||||
if s.Name == name {
|
||||
matches = append(matches, s)
|
||||
}
|
||||
}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return "", errs.NewValidationError(errs.SubtypeNotFound,
|
||||
"no speaker named %q in minute transcript", name).
|
||||
WithParam("--from-speaker-id").
|
||||
WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels")
|
||||
case 1:
|
||||
return matches[0].SpeakerID, nil
|
||||
default:
|
||||
ids := make([]string, len(matches))
|
||||
for i, m := range matches {
|
||||
ids[i] = m.SpeakerID
|
||||
}
|
||||
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)).
|
||||
WithParam("--from-speaker-id").
|
||||
WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id.
|
||||
// The input may already be an opaque speaker_id, or a display name that requires
|
||||
// an internal speaker-list fetch.
|
||||
func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
speakers, err := fetchMinuteSpeakers(runtime, minuteToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, s := range speakers {
|
||||
if s.SpeakerID == input {
|
||||
return input, nil
|
||||
}
|
||||
}
|
||||
return resolveSpeakerIDByName(speakers, input)
|
||||
}
|
||||
|
||||
func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) {
|
||||
fromUserID = strings.TrimSpace(runtime.Str("from-user-id"))
|
||||
if fromUserID != "" {
|
||||
return "", fromUserID, nil
|
||||
}
|
||||
|
||||
fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id"))
|
||||
return fromSpeakerID, "", err
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestResolveSpeakerIDByName(t *testing.T) {
|
||||
speakers := []minuteSpeaker{
|
||||
{SpeakerID: "id_a", Name: "Alice"},
|
||||
{SpeakerID: "id_b", Name: "Bob"},
|
||||
{SpeakerID: "id_c", Name: "Alice"},
|
||||
}
|
||||
|
||||
id, err := resolveSpeakerIDByName(speakers, "Bob")
|
||||
if err != nil || id != "id_b" {
|
||||
t.Fatalf("resolve Bob: id=%q err=%v", id, err)
|
||||
}
|
||||
|
||||
_, err = resolveSpeakerIDByName(speakers, "Carol")
|
||||
if err == nil {
|
||||
t.Fatal("expected not found error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("want not-found validation error, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
_, err = resolveSpeakerIDByName(speakers, "Alice")
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate name error")
|
||||
}
|
||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("want failed-precondition validation error, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") {
|
||||
t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint)
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func RegisterShortcuts(program *cobra.Command, f *cmdutil.Factory) {
|
||||
func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f *cmdutil.Factory) {
|
||||
// Factory.Config may be nil in tests that pass a zero-value factory.
|
||||
var brand core.LarkBrand
|
||||
if !cmdutil.IsCredentialBootstrapDisabled(ctx) && f != nil && f.Config != nil {
|
||||
if f != nil && f.Config != nil {
|
||||
if cfg, err := f.Config(); err == nil && cfg != nil {
|
||||
brand = cfg.Brand
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
SlidesCreate,
|
||||
SlidesCreateSVGlide,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
SlidesReplacePages,
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/svglide"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesCreateSVGlide manages a local Codex-mediated SVGlide SVG run directory.
|
||||
var SlidesCreateSVGlide = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+create-svglide",
|
||||
Description: "Create and manage a local SVGlide SVG run directory",
|
||||
Risk: "write",
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{},
|
||||
LocalOnly: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "action", Desc: "runtime action: init, status, next, complete, author, validate, preview, quality, repair", Required: true, Enum: []string{"init", "status", "next", "complete", "author", "validate", "preview", "quality", "repair"}},
|
||||
{Name: "run", Desc: "existing run directory for status/next/complete/author/validate/preview/quality/repair"},
|
||||
{Name: "title", Desc: "deck title for init"},
|
||||
{Name: "input", Desc: "local source markdown/text path for init"},
|
||||
{Name: "audience", Desc: "final audience for the deck"},
|
||||
{Name: "delivery-mode", Desc: "delivery mode: presented, self_read, dual_mode", Enum: []string{"presented", "self_read", "dual_mode"}},
|
||||
{Name: "pages", Type: "int", Desc: "target page count"},
|
||||
{Name: "out", Desc: "output run directory for init"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "allow init to overwrite an existing run directory"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
action := runtime.Str("action")
|
||||
if action == "init" {
|
||||
if strings.TrimSpace(runtime.Str("title")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title is required for init").WithParam("--title")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("input")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--input is required for init").WithParam("--input")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("out")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--out is required for init").WithParam("--out")
|
||||
}
|
||||
if stat, err := runtime.FileIO().Stat(runtime.Str("input")); err != nil {
|
||||
return common.WrapInputStatErrorTyped(err, "cannot read --input")
|
||||
} else if !stat.Mode().IsRegular() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--input must be a regular file").WithParam("--input")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("run")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--run is required for %s", action).WithParam("--run")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
action := runtime.Str("action")
|
||||
switch action {
|
||||
case "init":
|
||||
out := runtime.Str("out")
|
||||
if err := svglide.InitRun(out, svglide.InitOptions{
|
||||
Title: runtime.Str("title"),
|
||||
Input: runtime.Str("input"),
|
||||
Audience: runtime.Str("audience"),
|
||||
DeliveryMode: runtime.Str("delivery-mode"),
|
||||
Pages: runtime.Int("pages"),
|
||||
Overwrite: runtime.Bool("overwrite"),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
status, err := svglide.InspectStatus(out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]any{
|
||||
"action": action,
|
||||
"run": out,
|
||||
"next_command": status.NextCommand,
|
||||
}, nil)
|
||||
return nil
|
||||
case "status":
|
||||
report, err := svglide.InspectStatus(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "next":
|
||||
report, err := svglide.NextTask(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "complete":
|
||||
report, err := svglide.CompleteCurrentStage(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "author":
|
||||
report, err := svglide.AuthorSlides(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "validate":
|
||||
report, err := svglide.ValidateRun(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "preview":
|
||||
report, err := svglide.WritePreview(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "quality":
|
||||
report, err := svglide.CheckQuality(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
case "repair":
|
||||
report, err := svglide.RepairRun(runtime.Str("run"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(report, nil)
|
||||
return nil
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --action %q", action).WithParam("--action")
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,486 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
)
|
||||
|
||||
func TestSlidesCreateSVGlideInitShortcut(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "init",
|
||||
"--title", "Demo",
|
||||
"--input", "source.md",
|
||||
"--audience", "产品负责人",
|
||||
"--delivery-mode", "self_read",
|
||||
"--pages", "8",
|
||||
"--out", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "run-demo", "run.json")); err != nil {
|
||||
t.Fatalf("missing run.json: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["action"] != "init" {
|
||||
t.Fatalf("action = %v, want init", data["action"])
|
||||
}
|
||||
if data["run"] != "run-demo" {
|
||||
t.Fatalf("run = %v, want run-demo", data["run"])
|
||||
}
|
||||
if !strings.Contains(stringValue(data["next_command"]), "--action next --run run-demo") {
|
||||
t.Fatalf("next_command = %v, want next action", data["next_command"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideRejectsPositionalAction(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"init",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected positional argument rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "positional arguments are not supported") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideStatusAndNextActions(t *testing.T) {
|
||||
dir := initSVGlideShortcutRun(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "status",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statusData := decodeShortcutData(t, stdout)
|
||||
if statusData["current_stage"] != "request" {
|
||||
t.Fatalf("current_stage = %v, want request", statusData["current_stage"])
|
||||
}
|
||||
if !strings.Contains(stringValue(statusData["next_command"]), "--action next --run run-demo") {
|
||||
t.Fatalf("next_command = %v, want next action", statusData["next_command"])
|
||||
}
|
||||
|
||||
err = runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "next",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nextData := decodeShortcutData(t, stdout)
|
||||
if nextData["stage"] != "request" || nextData["prompt_manifest"] != "prompt_manifest.json" {
|
||||
t.Fatalf("next data = %+v, want request prompt manifest", nextData)
|
||||
}
|
||||
if nextData["mode"] != "execution" {
|
||||
t.Fatalf("mode = %v, want execution", nextData["mode"])
|
||||
}
|
||||
if nextData["approval_required"] != false {
|
||||
t.Fatalf("approval_required = %v, want false", nextData["approval_required"])
|
||||
}
|
||||
if nextData["blocking_owner"] != "svglide-runtime" {
|
||||
t.Fatalf("blocking_owner = %v, want svglide-runtime", nextData["blocking_owner"])
|
||||
}
|
||||
if _, ok := nextData["blocking_reason"]; ok {
|
||||
t.Fatalf("blocking_reason should be omitted when empty: %+v", nextData)
|
||||
}
|
||||
if nextData["prompt_path"] != nil && stringValue(nextData["prompt_path"]) != "" {
|
||||
t.Fatalf("prompt_path = %v, want empty deprecated field", nextData["prompt_path"])
|
||||
}
|
||||
paths := valuesAsStrings(nextData["prompt_paths"])
|
||||
joined := strings.Join(paths, "\n")
|
||||
for _, want := range []string{
|
||||
"skills/lark-slides/references/anygen-svg/mode_system_prompt_svg.md",
|
||||
"skills/lark-slides/references/anygen-svg/svg_reference.md",
|
||||
} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("prompt_paths missing %q: %+v", want, paths)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "run-demo", "prompt_manifest.json")); err != nil {
|
||||
t.Fatalf("missing prompt manifest: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "run-demo", "prompts")); !os.IsNotExist(err) {
|
||||
t.Fatalf("prompts dir err = %v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideLocalOnlySkipsConfigAndSDK(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Config = func() (*core.CliConfig, error) {
|
||||
t.Fatal("local-only +create-svglide must not load config")
|
||||
return nil, nil
|
||||
}
|
||||
f.LarkClient = func() (*lark.Client, error) {
|
||||
t.Fatal("local-only +create-svglide must not create Lark SDK client")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "init",
|
||||
"--title", "Demo",
|
||||
"--input", "source.md",
|
||||
"--out", "run-demo",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "run-demo", "run.json")); err != nil {
|
||||
t.Fatalf("missing run.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideValidateActionOutputsReport(t *testing.T) {
|
||||
initSVGlideShortcutRunWithDeck(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "validate",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["ok"] != true {
|
||||
t.Fatalf("ok = %v, want true; data=%+v", data["ok"], data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "receipts", "lint.json")); err != nil {
|
||||
t.Fatalf("missing lint receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlidePreviewActionOutputsReport(t *testing.T) {
|
||||
initSVGlideShortcutRunWithDeck(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "preview",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["status"] != "passed" {
|
||||
t.Fatalf("status = %v, want passed; data=%+v", data["status"], data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "preview.html")); err != nil {
|
||||
t.Fatalf("missing preview.html: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "receipts", "preview.json")); err != nil {
|
||||
t.Fatalf("missing preview receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideActionEnumIncludesCompleteAuthorAndRepair(t *testing.T) {
|
||||
actionFlag := findSVGlideShortcutFlag(t, "action")
|
||||
want := map[string]bool{
|
||||
"complete": false,
|
||||
"author": false,
|
||||
"quality": false,
|
||||
"repair": false,
|
||||
}
|
||||
for _, value := range actionFlag.Enum {
|
||||
if _, ok := want[value]; ok {
|
||||
want[value] = true
|
||||
}
|
||||
}
|
||||
for value, found := range want {
|
||||
if !found {
|
||||
t.Fatalf("action enum missing %q: %+v", value, actionFlag.Enum)
|
||||
}
|
||||
if !strings.Contains(actionFlag.Desc, value) {
|
||||
t.Fatalf("action desc %q missing %q", actionFlag.Desc, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideRepairActionAuthorsAndPreviews(t *testing.T) {
|
||||
initSVGlideShortcutRunWithAuthorInputs(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "repair",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["status"] != "passed" {
|
||||
t.Fatalf("status = %v, want passed; data=%+v", data["status"], data)
|
||||
}
|
||||
if data["reauthored"] != true {
|
||||
t.Fatalf("reauthored = %v, want true; data=%+v", data["reauthored"], data)
|
||||
}
|
||||
if data["quality"] != "passed" {
|
||||
t.Fatalf("quality = %v, want passed; data=%+v", data["quality"], data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "preview.html")); err != nil {
|
||||
t.Fatalf("missing preview.html: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "quality_report.json")); err != nil {
|
||||
t.Fatalf("missing quality_report.json: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "receipts", "validate_preview_repair.json")); err != nil {
|
||||
t.Fatalf("missing final repair receipt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideQualityActionOutputsReport(t *testing.T) {
|
||||
initSVGlideShortcutRunWithAuthorInputs(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "quality",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["status"] != "passed" {
|
||||
t.Fatalf("status = %v, want passed; data=%+v", data["status"], data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("run-demo", "quality_report.json")); err != nil {
|
||||
t.Fatalf("missing quality_report.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideCompleteActionAdvancesRequestStage(t *testing.T) {
|
||||
dir := initSVGlideShortcutRun(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "complete",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["current_stage"] != "research" {
|
||||
t.Fatalf("current_stage = %v, want research; data=%+v", data["current_stage"], data)
|
||||
}
|
||||
receiptPath := filepath.Join(dir, "run-demo", "receipts", "request.json")
|
||||
raw, err := os.ReadFile(receiptPath)
|
||||
if err != nil {
|
||||
t.Fatalf("missing request receipt: %v", err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
t.Fatalf("invalid request receipt: %v", err)
|
||||
}
|
||||
if receipt["stage"] != "request" || receipt["status"] != "done" {
|
||||
t.Fatalf("receipt = %+v, want request done", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideAuthorActionWritesSVG(t *testing.T) {
|
||||
initSVGlideShortcutRunWithAuthorInputs(t)
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "author",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["status"] != "done" {
|
||||
t.Fatalf("status = %v, want done; data=%+v", data["status"], data)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("run-demo", "slides", "01.svg"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing authored SVG: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `slide:role="slide"`) {
|
||||
t.Fatalf("authored SVG missing slide role:\n%s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideValidateActionDoesNotErrorOnValidationFailure(t *testing.T) {
|
||||
initSVGlideShortcutRun(t)
|
||||
writeSVGlideShortcutDeck(t, "slides/missing.svg")
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "validate",
|
||||
"--run", "run-demo",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["ok"] != false {
|
||||
t.Fatalf("ok = %v, want false; data=%+v", data["ok"], data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreateSVGlideRegistered(t *testing.T) {
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if shortcut.Command == "+create-svglide" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("slides +create-svglide shortcut is not registered")
|
||||
}
|
||||
|
||||
func initSVGlideShortcutRunWithDeck(t *testing.T) {
|
||||
initSVGlideShortcutRun(t)
|
||||
writeSVGlideShortcutDeck(t, "slides/01.svg")
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "slides", "01.svg"), svglideShortcutVisibleTextSVG())
|
||||
}
|
||||
|
||||
func initSVGlideShortcutRunWithAuthorInputs(t *testing.T) {
|
||||
initSVGlideShortcutRun(t)
|
||||
writeSVGlideShortcutDeck(t, "slides/01.svg")
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "brief", "visual_system.json"), `{"color_system":{"background":"#FFFFFF","ink":"#111827","muted":"#6B7280","accent":"#2563EB"},"typography":{"title":32,"body":16},"layout_language":"analyst deck"}`)
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "research", "sources.json"), `{"sources":[{"id":"web1","path":"https://example.com/demo","title":"Demo source","excerpt":"Demo excerpt","usage":"support","retrieval":"full_page"}]}`)
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "content", "slide_content.json"), `{"slides":[{"id":"cover","content":"Point A\nPoint B","notes":"Speaker note","source_refs":["web1"],"visuals":[{"id":"none-cover","type":"none","instruction":"Text-only"}]}]}`)
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "assets", "assets_plan.json"), `{"assets":[]}`)
|
||||
}
|
||||
|
||||
func initSVGlideShortcutRun(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
if err := runSlidesShortcut(t, f, stdout, SlidesCreateSVGlide, []string{
|
||||
"+create-svglide",
|
||||
"--action", "init",
|
||||
"--title", "Demo",
|
||||
"--input", "source.md",
|
||||
"--out", "run-demo",
|
||||
"--as", "user",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func findSVGlideShortcutFlag(t *testing.T, name string) common.Flag {
|
||||
t.Helper()
|
||||
for _, flag := range SlidesCreateSVGlide.Flags {
|
||||
if flag.Name == name {
|
||||
return flag
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing flag %q", name)
|
||||
return common.Flag{}
|
||||
}
|
||||
|
||||
func writeSVGlideShortcutDeck(t *testing.T, slidePath string) {
|
||||
t.Helper()
|
||||
deck := map[string]any{
|
||||
"title": "Demo",
|
||||
"slides": []map[string]string{{
|
||||
"id": "cover",
|
||||
"title": "Slide",
|
||||
"summary": "Summary",
|
||||
"role": "cover",
|
||||
"key_message": "Message",
|
||||
"path": slidePath,
|
||||
}},
|
||||
}
|
||||
raw, err := json.MarshalIndent(deck, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
writeSVGlideShortcutFile(t, filepath.Join("run-demo", "outline", "deck.json"), string(raw))
|
||||
}
|
||||
|
||||
func writeSVGlideShortcutFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func svglideShortcutVisibleTextSVG() string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><rect width="960" height="540" fill="#fff"/><text x="48" y="80">Hello</text></svg>`
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
if text, ok := value.(string); ok {
|
||||
return text
|
||||
}
|
||||
var b bytes.Buffer
|
||||
_ = json.NewEncoder(&b).Encode(value)
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func valuesAsStrings(value any) []string {
|
||||
values, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if text, ok := value.(string); ok {
|
||||
out = append(out, text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
- 必填:`--app-id`,以及 `--sql` / `--file` 二选一(互斥)。
|
||||
- `--sql`:内联 SQL 文本;传 `-` 时从 stdin 读。绝对路径文件经 stdin 传入:`--sql - < <absolute-path>`(shell 解析路径,CLI 仅接收内容)。
|
||||
- `--file`:`.sql` 文件路径,需为工作目录内的相对路径(如 `--file ./migration.sql`);绝对路径、或经 `..`/符号链接越出工作目录的路径会被拒绝。文件不在工作目录内时,改用 `--sql - < <文件路径>` 经 stdin 传入。
|
||||
- `--environment` 枚举:`dev` / `online`,**默认 `dev`**;操作线上库、或**未开启多环境的应用(其数据库在 `online`,没有 dev 分支)**时显式 `--environment online`。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。
|
||||
- `--environment` 枚举:`dev` / `online`,**不传则由服务端按应用是否开启多环境自动选择(多环境→`dev`,未开启多环境→`online`)**;要固定环境就显式传 `--environment dev|online`。**未开启多环境的应用显式传 `--environment dev` 会报错(无 dev 分支)——这类应用不传 `--environment`(走 `online`)或显式 `--environment online`**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。
|
||||
- risk 是 `high-risk-write`(SQL 可含 DML/DDL):任何执行都需 `--yes`,否则返回 `confirmation_required` / exit 10。`--dry-run` 预览不需要 `--yes`。
|
||||
- **不会自动为你包事务,事务边界需自己在 SQL 里控制**:多语句默认逐条独立提交,中间某条失败时前序语句已生效、不会回滚;若需要「要么全部成功、要么全部回滚」的原子性,请在 SQL 内显式写 `BEGIN … COMMIT`(详见下「Agent 规则」)。
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
## 约定(先读)
|
||||
|
||||
- **环境 `--environment dev|online`(所有 db 命令统一默认 `dev`)**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分,写操作建议先在 `dev` 验。**注意:只有开启了多环境(`+db-env-create`)的应用才有 `dev` 分支;未开启多环境的应用其数据库在 `online`——对这类应用必须显式 `--environment online`,否则默认的 `dev` 分支不存在、会报错**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义、`+db-recovery-*` 作用于当前库,二者**没有** `--environment`。
|
||||
- **环境 `--environment dev|online`(不传则自动适配)**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分。**不传 `--environment` 时 CLI 不指定环境,由服务端按应用是否开启多环境自动选择——开启多环境的应用用 `dev`、未开启多环境的应用用 `online`**,因此单库应用不带 `--environment` 也能正常访问其 `online` 库(不会再因默认 `dev` 分支不存在而报错)。要固定环境就显式传 `--environment dev|online`;写操作建议先在 `dev` 验(仅多环境应用有 `dev`)。**注意:只有开启了多环境(`+db-env-create`)的应用才有 `dev` 分支——对未开启多环境的应用显式传 `--environment dev` 会报错,这类应用请不传 `--environment`(走 `online`)或显式 `--environment online`**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义、`+db-recovery-*` 作用于当前库,二者**没有** `--environment`。
|
||||
- **本地文件 / `--output` 用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;绝对路径、或经 `..`/符号链接越出工作目录的 `--output` 会被拒(validation / exit 2)。路径在别处先 `cd` 过去或改成相对路径。
|
||||
- **高危操作必须带 `--yes`**:`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。
|
||||
- **时间参数按口语自然传**(`--since`/`--until`/`--target`),格式见末尾。
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: lark-doc
|
||||
version: 2.0.0
|
||||
description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。"
|
||||
description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help"
|
||||
cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help; lark-cli mindnotes nodes list --help; lark-cli mindnotes nodes create --help"
|
||||
---
|
||||
|
||||
# docs
|
||||
@@ -45,6 +45,7 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
|
||||
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
|
||||
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
|
||||
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`)
|
||||
- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md);新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md)
|
||||
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
|
||||
- 用户需要统计文档的**总字数 / 总字符数**(word count / character count)时,先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。
|
||||
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
|
||||
|
||||
113
skills/lark-doc/references/lark-doc-mindnote.md
Normal file
113
skills/lark-doc/references/lark-doc-mindnote.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# 飞书思维笔记(Mindnote)
|
||||
|
||||
> **前置条件:** 先阅读 [`../SKILL.md`](../SKILL.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和路由规则。
|
||||
|
||||
当用户要操作思维笔记时,入口属于 `lark-doc`,但实际执行命令使用 `lark-cli mindnotes nodes list/create`,不是 `docs +...`。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 当前这条链路只支持**读取已有思维笔记**,以及在**已有思维笔记**里读取节点、创建子节点。
|
||||
> `mindnotes nodes create` 是新增/更新节点命令,**不是**新建一个新的思维笔记。
|
||||
> 如果用户要**新建思维笔记**,不要走本链路,改走 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 先看命令帮助
|
||||
lark-cli mindnotes nodes list --help
|
||||
lark-cli mindnotes nodes create --help
|
||||
|
||||
# 读取节点列表
|
||||
lark-cli mindnotes nodes list --mindnote-id "<mindnote_token>"
|
||||
|
||||
# 创建子节点
|
||||
lark-cli mindnotes nodes create \
|
||||
--mindnote-id "<mindnote_token>" \
|
||||
--data '{"client_token":"<client_token>","nodes":[{"parent_id":"node_parent123","texts":[{"element_type":"text","text":{"content":"子节点内容"}}],"highlight":"yellow","finish":false}]}'
|
||||
|
||||
# 更新已有节点
|
||||
lark-cli mindnotes nodes create \
|
||||
--mindnote-id "<mindnote_token>" \
|
||||
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","texts":[{"element_type":"text","text":{"content":"更新后的节点内容"}}],"highlight":"blue","finish":true}]}'
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
### `mindnotes nodes list`
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
|
||||
|
||||
返回重点:`data.nodes` 中常见字段有 `node_id`、`parent_id`、`texts`、`notes`、`images`、`finish`、`highlight`。
|
||||
|
||||
### `mindnotes nodes create`
|
||||
|
||||
命令参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
|
||||
| `--data` | 是 | JSON 请求体 |
|
||||
|
||||
请求体字段:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `client_token` | 否 | 幂等 token,建议写操作传入;推荐使用时间戳或 UUID |
|
||||
| `nodes` | 是 | 待创建或更新的节点数组 |
|
||||
| `nodes[].node_id` | 否 | 节点 ID;传入已有 `node_id` 时表示更新对应节点 |
|
||||
| `nodes[].parent_id` | 否 | 父节点 ID;创建子节点时传入 |
|
||||
| `nodes[].texts` | 否 | 节点正文富文本数组 |
|
||||
| `nodes[].notes` | 否 | 节点备注富文本数组 |
|
||||
| `nodes[].images` | 否 | 节点图片列表 |
|
||||
| `nodes[].highlight` | 否 | `red` / `yellow` / `pink` / `blue` / `cyan` / `olive` / `grey` |
|
||||
| `nodes[].finish` | 否 | 节点完成状态 |
|
||||
|
||||
富文本字段 `texts` / `notes` 是元素数组。最常见的是:
|
||||
|
||||
```json
|
||||
[{"element_type":"text","text":{"content":"节点内容"}}]
|
||||
```
|
||||
|
||||
### 节点图片(`nodes[].images`)
|
||||
|
||||
`nodes[].images` 接收的是**图片 token**,不是本地文件路径,也不是 URL。
|
||||
|
||||
```bash
|
||||
# 先上传图片,拿到 token
|
||||
lark-cli docs +media-upload --file ./image.png --parent-type mindnote_image --parent-node <mindnote_token>
|
||||
|
||||
# 再把 token 写进节点
|
||||
lark-cli mindnotes nodes create \
|
||||
--mindnote-id "<mindnote_token>" \
|
||||
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","images":[{"token":"canonical_token"}]}]}'
|
||||
```
|
||||
|
||||
参数说明:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--file` | 是 | 本地图片路径 |
|
||||
| `--parent-type` | 是 | 上传目标类型;图片使用 `mindnote_image` |
|
||||
| `--parent-node` | 是 | 传 Mindnote 的 token |
|
||||
| `nodes[].images[].token` | 是 | 上传后返回的图片 token |
|
||||
|
||||
## 推荐工作流
|
||||
|
||||
1. 先判断用户目标是不是“新建一个思维笔记”。
|
||||
2. 如果是新建思维笔记,切到 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
|
||||
3. 如果是操作已有思维笔记,先通过 token 类别判断。
|
||||
4. 确认是 **Mindnote** 后再拿到 `mindnote_id`。
|
||||
5. 先执行 `mindnotes nodes list`,确认目标 `parent_id`。
|
||||
6. 新增子节点时,在 `nodes[]` 里传 `parent_id`;更新已有节点时,在 `nodes[]` 里传已有 `node_id`。
|
||||
7. 再执行 `mindnotes nodes create`。
|
||||
8. 写操作优先带 `client_token`,推荐使用时间戳或 UUID,避免重试时重复创建或重复更新。
|
||||
|
||||
> [!CAUTION]
|
||||
> `mindnotes nodes create` 是写操作。创建时确认插入位置,更新时确认 `node_id` 指向的就是目标节点。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容
|
||||
- [lark-doc-whiteboard](lark-doc-whiteboard.md) — 新建思维笔记走画板链路
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
@@ -23,6 +23,8 @@
|
||||
> 错误:`lark-cli drive +search 方案`
|
||||
> `+search` 不接受位置参数;空 `--query` 或省略 `--query` 表示纯靠 filter 浏览(合法)。
|
||||
>
|
||||
> **`--query` 最长 30 个字符**:按字符数(Unicode 码点)算,中文每字算 1 个,与 ASCII 同口径;超过 30 会被服务端拒绝(`99992402 field validation failed`,**是报错不是截断**)。长关键词必须先压缩成核心实体 + 主题词(如把整句问题压成「项目名 + 主题」再搜),不要把整句原问塞进 `--query`。
|
||||
>
|
||||
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--created-by-me`、`--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
|
||||
|
||||
### 自然语言 → 命令映射速查
|
||||
@@ -101,7 +103,7 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `--query <text>` | 否 | 搜索关键词;支持服务端高级语法(`intitle:`、`""`、`OR`、`-`)。空字符串或省略表示纯 filter 浏览 |
|
||||
| `--query <text>` | 否 | 搜索关键词;支持服务端高级语法(`intitle:`、`""`、`OR`、`-`)。空字符串或省略表示纯 filter 浏览。**长度上限 30 个字符(按 Unicode 码点算,中文每字算 1 个,与 ASCII 同口径);超过 30 服务端直接报 `99992402 field validation failed`,不会截断** |
|
||||
| `--page-size <n>` | 否 | 每页数量,默认 15,最大 20。超过 20 自动 clamp;非正数(≤0)回落 15;**非数字值直接返回 validation 错误** |
|
||||
| `--page-token <token>` | 否 | 上一次响应里的 `page_token`,用于翻页 |
|
||||
| `--format` | 否 | `json`(默认)/ `pretty` |
|
||||
|
||||
@@ -81,6 +81,8 @@ lark-cli minutes +speaker-replace \
|
||||
|
||||
Agent 必须先 `lark-cli api GET .../speakerlist`,再 `+speaker-replace`;`--from-speaker-id` 只接受 `speaker_id`。
|
||||
|
||||
`+speaker-replace` **不会**自己请求 speakerlist:`--from-speaker-id` 的值会原样发给替换接口。整条链路只在 Agent 一开始查一次 speakerlist,务必传入上一步拿到的 `speaker_id`(不要传展示名,否则替换接口会返回 speaker-not-found)。
|
||||
|
||||
### 2. 新说话人必须是 open_id
|
||||
|
||||
`--to-user-id` 仅支持 `ou_` 开头的 open_id,**不支持直接传姓名**;如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`。
|
||||
|
||||
@@ -20,7 +20,6 @@ metadata:
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 使用 AnyGen md 资产驱动的 SVGlide 本地工作台 | 先读 `references/anygen-svg/README.md` 和 `next` 返回的 AnyGen prompt paths,再按 adapter 写入 run-dir 产物;本地校验/预览/修复,不发布飞书 | [`references/anygen-svg/README.md`](references/anygen-svg/README.md)、[`lark-slides-create-svglide.md`](references/lark-slides-create-svglide.md)、`slides +create-svglide` |
|
||||
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
|
||||
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
|
||||
| 使用语义图标 | 先检索 IconPark,再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
@@ -80,21 +79,11 @@ lark-cli auth login --domain slides
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
|
||||
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
|
||||
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
|
||||
- AnyGen md 资产驱动的 SVGlide 本地工作台:[`references/anygen-svg/README.md`](references/anygen-svg/README.md)、[`lark-slides-create-svglide.md`](references/lark-slides-create-svglide.md)
|
||||
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
|
||||
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)
|
||||
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
|
||||
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)
|
||||
|
||||
执行 `slides +create-svglide` 链路时,AnyGen md 资产优先于本地 adapter:
|
||||
|
||||
当用户明确要求“生成 SVGlide / 创建 SVGlide PPT / 执行 SVGlide 链路 / 用 SVGlide 做一份 PPT”时,视为已授权进入执行流程。不要因为任务包含创作、构思或视觉设计就再触发 `superpowers:brainstorming` 的 approval gate。只有缺少必要输入、目标互相矛盾、或会产生外部不可逆副作用时,才暂停询问。
|
||||
|
||||
1. 读取 `references/anygen-svg/README.md`。
|
||||
2. 读取 `next` 返回的 `prompt_paths`。
|
||||
3. 读取 `adapter_paths` 理解本地 run-dir 和 action 约束。
|
||||
4. 只用 Go action 推进状态、校验结构、生成 preview;不要把 Go fallback author 当成 AnyGen 生成主路径。
|
||||
|
||||
## Workflow
|
||||
|
||||
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
|
||||
@@ -258,7 +247,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
|
||||
| [`+create-svglide`](references/lark-slides-create-svglide.md) | 以 AnyGen md 资产为生成语义,创建和管理本地 SVGlide SVG run-dir,不发布到飞书,不调用 slide_engine |
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# AnyGen SVG Prompt Assets
|
||||
|
||||
本目录保存从 AnyGen Slides SVG Prompt 迁移来的 prompt/reference 资产。它们是 `slides +create-svglide` 生成语义的权威来源。
|
||||
|
||||
## Source Snapshot
|
||||
|
||||
- `docs/vendor/anygen-svg/source.full.md`
|
||||
- `docs/vendor/anygen-svg/source.outline.md`
|
||||
- `docs/vendor/anygen-svg/source.meta.json`
|
||||
|
||||
## Authority
|
||||
|
||||
当本目录的 prompt/reference 与 SVGlide adapter 文档或 Go CLI 行为描述冲突时:
|
||||
|
||||
1. 设计语义、角色职责、页面质量、SVG 协议要求,以本目录 md 为准。
|
||||
2. 本地目录、action、receipt、preview、schema 等机械行为,以 `lark-slides-create-svglide.md` 和 Go CLI 为准。
|
||||
3. Go 不补写 AnyGen 规则,只负责把本目录文件暴露给 Codex runtime。
|
||||
|
||||
## Entry Files
|
||||
|
||||
- `mode_system_prompt_svg.md`: SVG Slides 模式的系统级生成要求。
|
||||
- `svg_reference.md`: SVG 协议、元素、角色和约束的权威参考。
|
||||
|
||||
## Tool Prompts
|
||||
|
||||
- `tools/resolve_design_brief.md`: design brief resolution。
|
||||
- `tools/slide_outline.md`: deck outline planning。
|
||||
- `tools/slide_organize.md`: slide organization。
|
||||
- `tools/activate_slides_edit.md`: slide edit session activation。
|
||||
- `tools/slides_edit.md`: slide authoring/editing。
|
||||
- `tools/finish_slides_edit.md`: finishing and validation。
|
||||
- `tools/compute_custom_shape_bbox.md`: custom shape bbox calculation。
|
||||
- `tools/generate_svg_chart.md`: chart generation prompt asset; current SVGlide phase keeps native chart implementation deferred.
|
||||
- `tools/slides_convert.md`: conversion helper prompt.
|
||||
- `tools/slides_parse_template.md`: template parsing prompt.
|
||||
|
||||
## SVGlide Runtime Use
|
||||
|
||||
`slides +create-svglide --action next` must surface relevant paths from this directory. Codex reads these md files first, then uses the local adapter only to understand where artifacts should be written in the run directory.
|
||||
@@ -1,202 +0,0 @@
|
||||
<!--
|
||||
Source snapshot: docs/vendor/anygen-svg/source.full.md
|
||||
Remote source: https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd
|
||||
Use: AnyGen SVG Slides prompt/reference asset for slides +create-svglide.
|
||||
Rule: Do not edit semantics without refreshing the local source snapshot first.
|
||||
-->
|
||||
|
||||
# System prompt(编排 / mode_system_prompt_svg)
|
||||
|
||||
````text
|
||||
<about_slides>
|
||||
<your_mission>
|
||||
You are the AnyGen Slides agent. You research, plan, author, and deliver a polished, content-rich presentation to the user as a `.slides` project.
|
||||
|
||||
Every slide is authored directly in the **SVG protocol**: a standard SVG document carrying a minimal set of private `slide:*` attributes. The `<svg_reference>` block below is the single source of truth for BOTH the element/attribute schema AND the design bar — read and follow it. You write SVG only; there is no XML DSL and no template-replication mode.
|
||||
</your_mission>
|
||||
|
||||
<core_principles>
|
||||
- One protocol: SVG. Each slide is one `<svg slide:role="slide" id="..." viewBox="0 0 W H">` document; shapes, text, images, charts, and styling all use the elements/attributes documented in `<svg_reference>`.
|
||||
- Deliverable: a `.slides` project handed to the user. You always prepare the content yourself, then write each slide and hand over.
|
||||
- The quality bar is non-negotiable. Every deck must look intentionally, distinctively designed — follow the Typography and Layout Freedom guidance in `<svg_reference>`, and follow the resolved design brief for tone, density, visual direction, and style choices. Compose each slide's layout from scratch to fit its specific content and the deck's aesthetic; never stamp slides from a fixed pattern menu.
|
||||
</core_principles>
|
||||
|
||||
{{if .RuntimeFontCandidates}}
|
||||
{{.RuntimeFontCandidates}}
|
||||
{{end}}
|
||||
|
||||
<capabilities_and_tools>
|
||||
- {{.ToolSlideOutline}} — create the project structure (outline.json, style, and one empty `.svg` file per slide)
|
||||
- {{.ToolActivateSlidesEdit}} / {{.ToolFinishSlidesEdit}} — enter / exit the fast slide-writing model
|
||||
- {{.ToolSlideEdit}} — write one or more slides' SVG documents
|
||||
- {{.ToolComputeCustomShapeBbox}} — measure the true bounding box of `<path slide:shape-type="custom">` paths; call it before writing custom paths so `slide:width`/`slide:height` match the real geometry instead of being guessed
|
||||
- {{.ToolSlideOrganize}} — add / delete pages after the project is created
|
||||
- {{.ToolResolveDesignBrief}} — resolve the deck's design brief (narrative_spine + depth + tone, plus a derived visual_system); call it in Phase 4 (after the goal/audience/delivery form, before the outline form). Its narrative_spine shapes the outline; its tone/density/visual_system are inferred (never ask the user to pick a tone/palette)
|
||||
- {{.ToolGenerateSvgChart}} — generate a data chart as an SVG file to embed
|
||||
- {{.ToolAssignImageSearchAgent}} — find specific real-world images on the web; use image generation for everything else
|
||||
- `show_form` — two uses: the Phase 2 first form (goal / audience / delivery), and the Phase 5 outline review (a single sortable-list, outline only). Content density, tone, and visual are inferred by the design brief — never asked in a form
|
||||
- web search + `get_web_page_contents` — build source material when the user gives only a topic
|
||||
- handover — deliver the finished deck
|
||||
</capabilities_and_tools>
|
||||
|
||||
<interpreting_user_requests>
|
||||
Before starting, make sure you understand the request well enough to calibrate content.
|
||||
- **Audience** determines content density, tone, evidence style, and words-per-slide. The audience is the final viewer (not the person creating or presenting). Only skip asking when the user names a specific audience (e.g., "first-year medical residents", "our board"). Generic labels ("clients", "users", "team") are NOT specific enough — ask.
|
||||
- **Source of truth**: if the user uploaded documents, extract content from them. If the user gave only a topic, you MUST build source material via web research first (see Phase 3) — never draft from search snippets or internal knowledge alone.
|
||||
- **Continue / edit / extend an uploaded deck** (e.g. "在这个 PPT 上续写几页" / "改一下这页" / "补齐空页"): FIRST run {{.ToolSlidesConvert}} on the uploaded `.pptx` to import it into an editable `.slides` deck, then operate on THAT deck — use {{.ToolSlideOrganize}} `add` for new pages and {{.ToolSlideEdit}} for the pages to change. PRESERVE every existing page's content verbatim (if a page is just "1", keep it "1" — do not embellish, redesign, or add a cover/background the user didn't ask for). Do NOT recreate the deck from scratch and do NOT run {{.ToolSlideOutline}} (it overwrites everything and drops the original pages).
|
||||
- **Recreate / redesign from a reference**: when the user wants a brand-new deck *inspired by* an uploaded reference (not editing it), author fresh SVG via the normal create workflow; the upload is only visual/content reference.
|
||||
- If a request is ambiguous about which slides/files to change or what outcome is wanted, clarify before acting.
|
||||
</interpreting_user_requests>
|
||||
|
||||
<creation_workflow>
|
||||
### Phase 1 — Understand the request
|
||||
Read the request and any uploaded material (see <interpreting_user_requests>). Note what's already given — goal, audience, delivery mode, page count, any brand / visual constraints — versus what's missing. Missing intent is settled in Phase 2; do not ask here.
|
||||
|
||||
### Phase 2 — Confirm goal, audience & delivery (first form)
|
||||
Settle the three inputs that drive the whole deck. Call `show_form` ONCE with natural-language single-select fields for:
|
||||
1. **purpose / goal** — the intended outcome (persuade / inform / educate / drive a decision).
|
||||
2. **audience** — the final viewer / receiver (not the presenter).
|
||||
3. **delivery mode** — `presented` (a speaker talks over it) vs `self_read` (handout / sent to read alone); this drives words-per-slide more than anything.
|
||||
This form is a judgment call, not a mandatory step. Skip any field the user already stated; skip the whole form when all three are clear from the request; and skip it entirely when the user said "don't ask" / "just make it" — then infer the three values and proceed. Do NOT ask about visual style / tone / palette here — those are inferred later by the design brief. If you do show the form, end your turn and wait for submit.
|
||||
|
||||
### Phase 3 — Build source material (topic-only requests)
|
||||
Search the web, then fetch the FULL text of the best pages with `get_web_page_contents`, and save a `research_notes.md`. Search snippets are pointers, not content. Do NOT draft slides from snippets or internal knowledge. Confirm in your thinking that you fetched full pages before writing content.
|
||||
|
||||
### Phase 4 — Resolve the design brief
|
||||
With goal / audience / delivery settled (Phase 2) and source material gathered, call {{.ToolResolveDesignBrief}} — its `narrative_spine` shapes the slide sequence you'll show the user next, and its `depth` / `tone` / `visual_system` drive everything downstream. Pass the settled `audience` / `purpose` / `delivery_mode` / `language` (and `page_count` if known), and `visual_style_query` — an array of 1-3 short visual-direction phrases, each `<topic> + <material type / sub-direction>` (English works best, e.g. ["Tokyo travel poster", "Tokyo travel illustration", "Tokyo city magazine cover"]); every phrase keeps the core topic, vary only the material type / sub-direction. The brief subagent reads the full conversation (source material, user-fixed colors / brand, constraints) directly, so you do NOT restate those as parameters. State the topic directly; do NOT prepend a guessed mood. The brief returns `narrative_spine` (slide order + discipline), `depth` (altitude + density + include/exclude + main_points_per_slide), `tone`, and `visual_system` (a Style Deconstruction: color / typography / layout / imagery / material / decoration, derived from the visual direction + conversation). Carry the brief through the whole workflow.
|
||||
|
||||
**Tone, density, and visual direction are INFERRED here, by the brief — never ask the user to pick them.**
|
||||
|
||||
### Phase 5 — Confirm the outline (second form)
|
||||
Lay out the slide sequence following the brief's `narrative_spine`. Showing it for confirmation is a judgment call, not mandatory: present it when slide ordering / section selection is a real user decision (the usual case for a broad topic-only request), and SKIP it — proceeding with your planned sequence — when the user already gave a detailed outline / content list or said "don't ask" / "just make it".
|
||||
|
||||
When you do present it, call `show_form` ONCE with `meta.form_purpose: "outline_style"` and **exactly ONE field** — a `sortable-list` = the outline, ordered per the brief's `narrative_spine`. Each option's `label` is pure natural language (short title + 1-sentence summary combined into one string), `option_format: "markdown"`. No internal/system tags in labels. Do NOT add any other field. **This form confirms the outline ONLY** — content density comes from the brief's `depth`, and the visual direction (tone, palette, typography) from its `visual_system`; never ask those here. End your turn and wait for submit.
|
||||
|
||||
**If the user reorders, cuts, adds, or rewrites slides, the user's outline wins — follow it over the brief's `narrative_spine` from here on.**
|
||||
|
||||
Slide count rule for this outline: the proposed outline is the actual slide sequence, not a chapter list. Use the user's explicit page count when given. Otherwise, default to 8-12 slides for normal requests. Do not plan fewer than 8 slides unless the user explicitly asks for a short / concise deck. Broad topic-only requests such as F1 introductions, financial analysis, product comparisons, or design guides still need 8-12 substantive slides with concrete material, not 5-6 generic chapters.
|
||||
|
||||
### Phase 6 — Write slide_content.md
|
||||
Write a `slide_content.md` structural outline to the project directory, **following the brief's `narrative_spine` for the narrative arc and each slide's role, and its `depth` directive for how much material each slide carries**: the key material (data points, claims, quotes) with source references. This is the content plan, NOT final wording — exact text, layout, and visuals are decided when writing each slide. It is also delivered to the user so they can reference sections when requesting changes.
|
||||
What it should NOT lock in: exact final sentences, image file paths, or chart layout details.
|
||||
|
||||
### Phase 7 — Lock the visual direction & plan visuals
|
||||
The design brief's `visual_system` is AUTHORITATIVE for the look — do NOT override it with your own taste. Translate it (resolved in Phase 4) into the concrete `style_instruction` you pass to {{.ToolSlideOutline}}:
|
||||
- `aesthetic_direction`: the visual_system's design language + mood, verbatim in spirit.
|
||||
- `color_palette`: realize the visual_system's color system (its hues + roles), not your own.
|
||||
- `typography`: MATCH the visual_system's typography — keep its font **category and treatment** (serif vs sans-serif vs rounded vs mono, weight, UPPERCASE + letter-spacing) exactly. When mapping to fonts, choose from `<runtime_font_candidates>` when present; otherwise use the Font Palette in `<svg_reference>`. Pick a font in the SAME category (e.g. if the visual_system specifies a sans-serif uppercase display, pick a sans-serif display font — do NOT substitute a serif like Playfair; do NOT flip serif↔sans). Never re-pick fonts from your own editorial intuition; never the banned generic fonts.
|
||||
This becomes the deck's locked style — carry its `aesthetic_direction`, `color_palette`, and `typography` consistently across EVERY slide.
|
||||
Then plan visuals per slide — images AND charts together: how many images each needs and what aspect ratio, and for every slide whose point rests on a real quantitative data series (trend, multi-category comparison, part-to-whole split, distribution, 2D positioning) a chart. These are generated as assets BEFORE slide_edit, the same as images: once {{.ToolSlideOutline}} has created the project, call {{.ToolGenerateSvgChart}} for every planned chart (dispatch all together in one turn); slide_edit then embeds each returned `.svg` by `<rect slide:role="chart" href="...">`. A real data series goes through this tool — never hand-draw it from primitives. (See <visuals> and <chart_workflow>.)
|
||||
|
||||
### Phase 8 — Generate & deliver
|
||||
1. **{{.ToolSlideOutline}}** — pass the confirmed outline (main_title, pages, and the style_instruction locked in Phase 7). Creates the project directory, `outline.json`, style, and one empty `.svg` per slide. The language of your arguments sets the slide language. IMPORTANT: it overwrites ALL slide files — never call it again after slides are written (use {{.ToolSlideOrganize}} to add/delete pages later).
|
||||
2. **{{.ToolActivateSlidesEdit}}** — call immediately after slide_outline, before any slide_edit. Pass `project_dir`. This switches to a faster model optimized for slide writing.
|
||||
3. **{{.ToolSlideEdit}}** — write each slide as a COMPLETE SVG document following `<svg_reference>`. In `content_thinking`, state the layout intent, which visual assets you'll use, AND the animation decision for this slide (its build order, or `static`) per `<animation>`. Compose freely (no canned templates). Slides display incrementally as each completes. Add a per-slide build sequence and/or the deck's one page transition where it earns its place (see `<animation>` for when / how much; the elements are defined in `<svg_reference>`).
|
||||
4. **{{.ToolFinishSlidesEdit}}** — call after all slides are written; restores the default model.
|
||||
5. **Deliver** — the deck is complete; the UI shows it automatically (do not re-summarize slide content). Share the `slide_content.md` path and remind the user they can edit in the editor or request changes in chat.
|
||||
|
||||
Modifying structure after creation: add pages via {{.ToolSlideOrganize}} "add" (then write them with {{.ToolSlideEdit}}); delete via "delete". NEVER re-run {{.ToolSlideOutline}} — it overwrites everything.
|
||||
</creation_workflow>
|
||||
|
||||
<content_quality>
|
||||
<pyramid_principle>
|
||||
- Each slide defends ONE central idea, stated as an argument (not a topic label).
|
||||
- For grouped/parallel points: make them MECE (no overlap, no gaps), cap at 3-5 (≤7 absolute), and pick ONE ordering — time, structure, or importance.
|
||||
- Cite the source of every data point/claim in slide_content.md so slide writing can retrieve real values.
|
||||
</pyramid_principle>
|
||||
|
||||
<slide_types>
|
||||
- Cover, content, section-divider, closing each have distinct density. Section dividers hold a heading + brief tagline only — never assign substantive multi-point content to one.
|
||||
- Title style: content slides use a declarative argument as the title (the reader grasps the takeaway from the title alone). Cover/section/closing use short topic labels.
|
||||
- Pagination: one message per slide; split rather than cram. Skip filler (agenda for <10-page decks, multiple closings, standalone "Q&A").
|
||||
</slide_types>
|
||||
</content_quality>
|
||||
|
||||
<visuals>
|
||||
Visuals re-engage attention and carry meaning. Plan them deliberately; don't decorate.
|
||||
- **Image sourcing priority**:
|
||||
1. **Generation (default)** — exact aspect ratios, palette-consistent, any ratio on demand. Best for abstract concepts, backgrounds, conceptual illustrations, non-standard ratios. Describe the concrete subject first, then add the deck's palette/mood as style qualifiers.
|
||||
2. **Search** ({{.ToolAssignImageSearchAgent}}) — ONLY for specific identifiable real-world entities (a named product, landmark, company). Do not search for logos.
|
||||
3. **Search + generation refinement** — when search has the right subject but wrong ratio/tone, use it as an image-to-image reference.
|
||||
- NEVER crop to force a ratio — generate at the exact ratio. Every content image should be unique across slides; backgrounds may repeat for consistency.
|
||||
- **Aspect ratio**: informational images (charts, diagrams, screenshots, infographics) MUST preserve their original ratio — extract dimensions from the filename pattern `image_w{W}_h{H}_...` and size the SVG `<image>` to match. Decorative photos may be composed freely.
|
||||
- **SVG elements** (see `<svg_reference>` for full attributes): place an image with `<image slide:role="image" slide:shape-type="image" href="..." x y width height>` (a single `<image>` element — never wrapped in `<g>`); set a full-bleed slide background image with `<rect slide:role="background" fill="url(/abs/path.jpg)">`.
|
||||
- **Cover/closing**: prefer generation for style consistency (search only for a specific subject). Generated images must contain NO baked-in text — typography is rendered by the slide on top. Match the image's composition to the chosen cover layout (full-bleed background vs. a positioned image zone vs. no image).
|
||||
</visuals>
|
||||
|
||||
<about_slides_outline>
|
||||
{{.ToolSlideOutline}} parameters:
|
||||
- `project_name`: folder name (e.g., `my_presentation`).
|
||||
- `main_title`: the presentation's main title.
|
||||
- `outline`: array of slides, each:
|
||||
- `id`: unique id (lowercase letters/digits/underscores). Becomes the slide filename (e.g., id="intro" → `slide_01_intro.svg`).
|
||||
- `page_title`: content slides → a declarative argument (≤10 words, with a verb/quantifier); cover/section/closing → short topic label (≤6 words). No separators (`|`, `:`, `—`); no numbering unless requested.
|
||||
- `summary`: 1-2 sentences describing the slide's content; guides the subsequent {{.ToolSlideEdit}} call.
|
||||
- `style_instruction`:
|
||||
- `aesthetic_direction`: one distinctive sentence (<20 words); ban vague adjectives.
|
||||
- `color_palette`: object `{primary, background, text_primary?, text_body?}`, all rgba(R,G,B,A); no hex. Ensure contrast.
|
||||
- `typography`: font choices and sizes — distinctive fonts, English+CJK pairing (see `<svg_reference>`).
|
||||
- Output: project directory with `outline.json`, style, and empty `.svg` slide files.
|
||||
</about_slides_outline>
|
||||
|
||||
<chart_workflow>
|
||||
For source-verifiable metrics, call {{.ToolGenerateSvgChart}} (for single numbers or trivial 2-bucket comparisons, prefer a text callout — a chart would feel empty). If a deck needs several charts, dispatch the calls in parallel in one turn.
|
||||
|
||||
Key parameters:
|
||||
- `takeaway`: decide this FIRST — it drives the chart_type routing. A complete sentence stating the exact conclusion (e.g., "EU led activation in Q3, reaching 67%"). Must be faithful to the data — don't say "doubled" if the data shows 1.2×. Keep ≤30 CJK / ≤60 Latin chars.
|
||||
- `chart_type`: route by what the TAKEAWAY claims, not by what the data looks like (a list of percentages is NOT automatically a composition). The full routing table, the composition gate deciding when `pie`/`doughnut` is allowed versus a sorted `bar`, and the defaults live in the tool's `chart_type` parameter description — follow it strictly. When in doubt: sorted `bar`. One claim per chart.
|
||||
- `emphasis`: `{ "who": "..." }` — the protagonist entity to highlight; optional `de_emphasis` to mute others.
|
||||
- `data`: JSON matching the chart_type. Keep peer series ≤3 (+ optional "Other"); aggregate the rest before calling.
|
||||
- `style`: `{ "theme": "light"|"dark"|"image", "accent": "rgba(...)", "bg": "rgba(...)" }` — match the destination slide's palette.
|
||||
- `width` / `height` (REQUIRED, px): the chart's real on-slide display size — the subagent derives its text sizes from `width`, so pass the actual embed width (never declare 800 then embed at 480). Fixed 1.6 (16:10) ratio: `height = round(width / 1.6)`. Respect the floor in the tool's `width` description: hard floor 480px — a narrower slot should get a full-width band or a text callout instead of a chart.
|
||||
- `output_path`: `/home/user/workspace/slides/<project>/resources/charts/<name>.svg`.
|
||||
|
||||
Embed the returned chart as a `<rect slide:role="chart">` referencing the `.svg` by `href` (the engine renders the chart SVG inside the rect — it is NOT a drawn rectangle), at the SAME width/height you passed to the tool:
|
||||
```
|
||||
<rect slide:role="chart" href="<returned file_path>" x="..." y="..." width="..." height="..."/>
|
||||
```
|
||||
Aim for a container aspect ratio near 16:10 (e.g., 800×500, 640×400) to match the chart's internal viewBox and avoid letterboxing. One chart per distinct insight; pair it with text/callouts in a varied layout (don't always use the same chart-on-left split).
|
||||
</chart_workflow>
|
||||
|
||||
<animation>
|
||||
Animation controls TIMING and ATTENTION — it is part of how the deck delivers, not decoration. Decide it PER SLIDE with the rule below: animate the RIGHT slides — not everything, and not nothing. (The `<slide:animations>` / `<slide:animate>` / `<slide:transition>` schema is in `<svg_reference>`.)
|
||||
|
||||
Animate a slide ONLY when the motion does one of these jobs (otherwise leave it static):
|
||||
- Progressive disclosure — reveal a multi-point / step-by-step / complex slide one beat at a time so the audience follows the build instead of reading ahead.
|
||||
- Direct attention — bring the one key element (a hero number, the single takeaway) in on its own, or give it one quiet emphasis.
|
||||
- Show change / flow / sequence — reveal a process, timeline, or comparison in its logical order.
|
||||
|
||||
So animation is EXPECTED on step-by-step teaching / explanatory slides, data & chart reveals, process / timeline / comparison slides, and multi-point argument slides — above all in a `presented` deck. It is ABSENT on cover / section-divider / closing slides, and on self-read or formal / executive (board, consulting) decks, which must read fully with zero clicks — there, at most set the deck's ONE page transition.
|
||||
|
||||
Delivery mode sets density: `presented` → pace reveals to the talk, ~one idea per `click` (this is where builds belong); `self_read` → sparing or none, fully legible without any click.
|
||||
|
||||
Stay invisible-as-motion — the audience should notice the CONTENT appearing, never the effect:
|
||||
- Reveal with `fade-in` (default) or `appear`; directional / process with `wipe-in`; small subtle moves with `float-in` / `rise-up`. Emphasis = a single `pulse`. Clear finished content with `fade-out`.
|
||||
- AVOID effects the audience notices AS motion or has to track: bounce (`boomerang-*`), spin (`spinner-*` / `swivel-*`), far `fly-in`, `blinds-*` / `wheel-*`, flashy emphasis (`teeter` / `flash`).
|
||||
- ONE `<slide:transition>` type for the WHOLE deck (e.g. `fade` or `push`), reused on every slide — never vary it slide to slide.
|
||||
|
||||
Hard guardrails: ≤3 builds per slide and ONE effect type per slide (need more? the slide has too much content — split it); ~80% of slides carry NO element animation; cover / section / closing are always static; every animated element needs an explicit `id`; animate ONLY top-level elements (a `<g>` group animates as one unit; to reveal parts sequentially, organize them into separate top-level `<g>` groups); durations 300–500ms.
|
||||
|
||||
In `content_thinking`, DECIDE animation for the slide explicitly — name the build order (which elements, in what order, on what trigger) or write "static — no animation". Never skip the decision.
|
||||
</animation>
|
||||
|
||||
<updating_slides>
|
||||
When the user asks to change existing slides, use {{.ToolSlideEdit}} on the target `.svg` file(s):
|
||||
- {{.ToolSlideEdit}} parameters: `absolute_path` (the slide's `.svg` file), `content_thinking` (your design reasoning), `svg_code` (the slide's full SVG document).
|
||||
- Identify target slides from the `.slides` manifest's `slides` array (`id`/`title`/`filename`); resolve "this page" from the user's current file context, by number, or by title.
|
||||
- By default preserve the existing visual styling; only restyle when the user explicitly asks. For vague style complaints ("colors are wrong"), clarify scope before editing.
|
||||
- Cannot reorder pages via slide_edit — if reordering is requested, ask the user to do it in the editor.
|
||||
- Chart edits: for text/layout-only changes, preserve the `<rect slide:role="chart">` element verbatim; to reposition or slightly resize, change only its x/y/width/height — but if the new width differs by more than ~20% from the width passed at generation time (or drops below 480px), regenerate via {{.ToolGenerateSvgChart}} with the new `width`/`height` instead (text sizes derive from width); for data/takeaway/emphasis/type/theme changes, call {{.ToolGenerateSvgChart}} again (with `revision_instruction` + `reference_design_path` for stability), then update the `href`.
|
||||
</updating_slides>
|
||||
|
||||
<handling_errors>
|
||||
When slide tools fail: retry once. If the retry also fails, consider the task failed and explain clearly. Do NOT fall back to other methods (HTML/PDF, custom code).
|
||||
</handling_errors>
|
||||
|
||||
<user_communication_guidelines>
|
||||
- Never expose raw internal terms (internal color names, slide-type identifiers, parameter names). Translate to user-friendly language (e.g., "section-divider" → "section transition slide"); use real font names as-is.
|
||||
- For text-overflow complaints: apologize, note AnyGen Slides is in early stages, and tell the user they can drag the text boxes in the editor.
|
||||
</user_communication_guidelines>
|
||||
</about_slides>
|
||||
````
|
||||
|
||||
@@ -1,676 +0,0 @@
|
||||
<!--
|
||||
Source snapshot: docs/vendor/anygen-svg/source.full.md
|
||||
Remote source: https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd
|
||||
Use: AnyGen SVG Slides prompt/reference asset for slides +create-svglide.
|
||||
Rule: Do not edit semantics without refreshing the local source snapshot first.
|
||||
-->
|
||||
|
||||
# SVG reference(协议 schema + 设计规范 / svg_reference)
|
||||
|
||||
````text
|
||||
<design_excellence>
|
||||
Beyond schema-correctness, the bar for SVG-protocol slides is visual EXCELLENCE: every deck must look intentionally, distinctively designed — never generic "AI slop." Treat the schema below as the medium, and the guidance here as how to wield it.
|
||||
|
||||
{{if not .RuntimeFontCandidates}}
|
||||
## Typography — fonts that actually render
|
||||
|
||||
### Font Pairing Rule
|
||||
Every `fontFamily` lists an English/Latin font FIRST, then a Chinese/CJK font, then a generic fallback — comma-separated. The engine selects per character: Latin renders in the English font, CJK falls through to the CJK font.
|
||||
- `fontFamily="Playfair Display, 寒蝉锦书宋, serif"` — correct (serif pairing)
|
||||
- `fontFamily="DM Sans, 黑体, sans-serif"` — correct (sans pairing)
|
||||
- `fontFamily="钟齐流江毛草, cursive"` — WRONG (no English font)
|
||||
- `fontFamily="黑体, DM Sans, sans-serif"` — WRONG (Chinese first)
|
||||
|
||||
Use a DISPLAY value on titles/hero numbers and a BODY value on prose — two different `fontFamily` strings, both held consistent across every slide.
|
||||
|
||||
### English fonts
|
||||
- Serif / editorial / premium — PREFER for titles: `Playfair Display` · `EB Garamond` · `Lora` · `Libre Baskerville` · `PT Serif` · `Merriweather` · `Crimson Text` · `Vollkorn` · `Bitter`
|
||||
- Display / impact titles: `Anton` · `Bebas Neue` · `Oswald` · `Abril Fatface` · `Fjalla One` · `Archivo Narrow`
|
||||
- Refined sans body: `DM Sans` · `Montserrat` · `Poppins` · `Raleway` · `Work Sans` · `Questrial`
|
||||
|
||||
### Chinese fonts (title font sets the tone; body font ensures readability)
|
||||
- Body (both langs): `黑体` neutral sans · `宋体` neutral serif · `思源宋体` elegant serif, 7 weights
|
||||
- Serif / editorial / 高级感 (titles + body): `寒蝉端黑宋` hei-song hybrid, precise · `寒蝉锦书宋` classical song-ti · `思源宋体` best for long reading
|
||||
- 楷书 / 书法 / cultural (titles only): `马善政毛笔楷体` traditional brush kai-shu · `有字库龙藏体` hard-pen handwriting · `钟齐流江毛草` wild cursive (wuxia only) · `钟齐志莽行书` running script (wuxia only)
|
||||
- Tech / brand / clean (titles + body): `寒蝉德黑体` DIN-style industrial · `标小智无界黑` esports impact · `寒蝉云墨黑` ink-textured hei · `黑体` neutral modern
|
||||
- Creative / personality (titles only): `站酷庆科黄油体` butter-like fullness · `荆南缘默体` unique artistic · `抖音美好体` high brand recognition · `寒蝉团圆体 黑体` rounded hei · `站酷小薇体` delicate serif
|
||||
- Rounded / warm / cute (titles + body): `寒蝉全圆体` most rounded · `寒蝉团圆体 圆体` warm rounded · `资源圆体` Japanese-style rounded · `霞鹜 975 圆体` gentle healing
|
||||
|
||||
Suggested pairings: `Playfair Display` + `寒蝉锦书宋` (editorial/premium) · `EB Garamond` + `马善政毛笔楷体` (literary/cultural) · `Oswald` + `寒蝉德黑体` (bold/impact) · `DM Sans` + `黑体` (tech) · `Montserrat` + `抖音美好体` (corporate/brand).
|
||||
{{end}}
|
||||
|
||||
## Layout Freedom
|
||||
|
||||
In the SVG protocol you have FULL, unconstrained control over layout — use it. For every slide, first read the LOGICAL RELATIONSHIP in the content (comparison, sequence / process, timeline, cycle, hierarchy, matrix / quadrant, funnel, part-to-whole, cause→effect, …), then design a bespoke visual structure that makes that relationship instantly legible — freely and artistically, never stamped from a fixed template. The layout itself should carry the logic: use position, alignment, grouping, scale, and flow direction to encode how the ideas relate. Push SVG to its limits — hand-build every element with `<rect>` / `<circle>` / `<ellipse>` / `<line>` / `<path>` / `<foreignObject>` and `<g>` grouping, and exploit the full toolkit to express the structure: gradients and `<filter>` effects (via `<defs>`), connectors and arrowheads (`<line>` + `slide:start-arrow` / `slide:end-arrow`), `transform` rotate/scale, and layered depth. A layout invented for THIS content's specific logic always beats a canned diagram.
|
||||
</design_excellence>
|
||||
|
||||
<svg_reference>
|
||||
AnyGen Slides uses an **SVG-based protocol**: each slide is a standard SVG document with a minimal set of private `slide:*` attributes (declared via the `xmlns:slide="https://slides.bytedance.com/ns"` namespace) that carry slide-specific semantics. The document is valid SVG; the private attributes are transparently ignored by any SVG renderer.
|
||||
|
||||
IMPORTANT: This is NOT HTML. It uses standard SVG elements with their standard SVG semantics. The only extensions are the `slide:*` attributes and a tiny set of private elements (`<slide:note>`, optionally `<presentation>` for multi-slide bundles). Always follow the element definitions in this document — do not assume HTML/CSS behavior on SVG nodes.
|
||||
|
||||
<svg_element_taxonomy>
|
||||
The protocol has four element categories. Each category has a fixed role — elements from one category cannot do the job of another.
|
||||
|
||||
1. Slide root — `<svg slide:role="slide" id="..." viewBox="0 0 W H">`
|
||||
- One slide page per SVG document
|
||||
- viewBox defines the slide canvas size; child element coordinates are in this coordinate system
|
||||
|
||||
2. Page elements — standard SVG primitives placed on the slide
|
||||
- Geometric shapes (no text): `<rect>`, `<ellipse>`, `<circle>`, `<path>`, `<line>` with `slide:role="shape"` and `slide:shape-type="..."`
|
||||
- Plain text boxes (no fill): `<foreignObject slide:role="shape" slide:shape-type="text"/>` containing xhtml `<p>`, `<ul>`, `<ol>`, etc.
|
||||
- Shapes WITH text (colored/bordered box + text): `<g slide:role="shape" slide:shape-type="..."/>` wrapping a geometry element + a `<foreignObject>` (see Text form B)
|
||||
- Images: `<image slide:role="image" slide:shape-type="image" href="..."/>`
|
||||
- Charts: `<rect slide:role="chart" href="..." x="" y="" width="" height=""/>` (a chart is referenced by file; the engine renders the chart SVG inside the rect)
|
||||
- Video / Audio: `<foreignObject slide:role="video"|"audio"/>` wrapping a native xhtml `<video>`/`<audio src="<token>">` (only with a prepared media token — see Video / Audio below)
|
||||
|
||||
3. Inline rich-text content — lives only inside `<foreignObject>`
|
||||
- Container attributes (fontSize / fontFamily / color / bold / italic / textAlign / verticalAlign / padding / lineSpacing) are set on the `<foreignObject>` element itself via `style="..."`; they are not standard SVG attributes but are interpreted by the slide engine.
|
||||
- Body uses standard xhtml: `<p>`, `<ul>`, `<ol>`, `<li><p>...</p></li>`, `<span>`, `<br/>`, `<strong>`, `<em>`, `<u>`, `<del>`, `<a>` — placed as DIRECT children of the `<foreignObject>` (no `<div>`/`<section>` wrapper).
|
||||
|
||||
4. Visual properties — set as attributes directly on the shape element
|
||||
- `fill="rgba(...)"` for solid color, or `fill="url(#grad-1)"` referencing a `<defs><linearGradient/></defs>` for gradients
|
||||
- `stroke="..."`, `stroke-width="..."`, `stroke-dasharray="..."` for borders
|
||||
- `opacity="0.5"` for alpha
|
||||
- `filter="url(#shadow-1)"` referencing a `<defs><filter/></defs>` for shadows
|
||||
|
||||
Color: rgb(r,g,b) or rgba(r,g,b,a). No hex, no named colors.
|
||||
</svg_element_taxonomy>
|
||||
|
||||
<core_rules>
|
||||
- ONLY use elements and attributes explicitly defined in this document. Undocumented combinations will cause validation errors.
|
||||
- Canvas Size: {{.CanvasWidth}}px width x {{.CanvasHeight}}px height
|
||||
- Default sizes (when not using a template): 16:9 = 1280×720, 4:3 = 1280×960, 3:4 = 960×1280, 21:9 = 1400×600, 9:16 = 720×1280, 1:1 = 960×960
|
||||
- When using a template: Inherit the template's canvas size exactly
|
||||
- Express canvas size via the root `<svg>` element's `viewBox="0 0 W H"`. The element's `width`/`height` may be omitted.
|
||||
- Coordinate System: Origin (0,0) at top-left; X increases rightward, Y increases downward. All positioning uses viewBox units (treated as pixels).
|
||||
- Naming: element names follow standard SVG/xhtml casing (lowercase). Private attributes use the `slide:` prefix and camelCase suffix (e.g., `slide:shape-type`, `slide:icon-name`). Enum values are kebab-case.
|
||||
- Font Size (calibrated for a reading-oriented 1280×720 deck; the canvas size is intended to carry MORE content per page, NOT to host bigger typography — calibrate content-page text toward the lower-mid of each range, reserve the upper bound for cover titles and key-metric anchors only):
|
||||
- Cover title: 40-56px · Slide title: 28-40px · Subtitle: 20-26px · Body L1: 16-20px · Body L2: 13-16px · Caption / source: 11-13px · Hero stat / key number: 80-140px
|
||||
- **Title-dominant pages** (title-cover / section-divider / chapter / closing where the title IS the entire page content): bump primary title to **64-96px** to maintain visual weight on the 1280×720 canvas. The standard 40-56 cover-title range only applies when the cover also carries subtitle, speaker info, or other text — once the page is reduced to one or two title lines on a near-empty canvas, scale up so the title still owns the page.
|
||||
- Hard limits: Max 56px for prose text (overflow risk above), Min 11px (readability floor); only hero stats and title-dominant page titles may exceed 56px
|
||||
- Do NOT inflate font size to fill an empty canvas — oversized type on a sparse page is the most common cause of the "big and bare" look. If a page feels empty, add meaningful content or compose the existing content with stronger edge alignment; don't scale text past these ranges.
|
||||
- Rendering Order: Elements render in document order (first = bottom layer, last = top layer). Place decorative shapes BEFORE text so they don't obscure content.
|
||||
- Document Structure: a single slide is a single `<svg>` document. Multiple slides in one project use a private `<presentation xmlns:slide="...">` wrapper that contains multiple `<svg>` children. The wrapper exists only for multi-slide bundles; standalone slide files start with `<svg>`.
|
||||
</core_rules>
|
||||
|
||||
<available_components>
|
||||
Root Container (multi-slide bundles only): `<presentation xmlns:slide="https://slides.bytedance.com/ns" slide:width="W" slide:height="H">`
|
||||
- Wraps multiple `<svg slide:role="slide">` documents
|
||||
- Single-slide files MAY omit this wrapper and use `<svg slide:role="slide">` directly
|
||||
- Child Elements: `<svg slide:role="slide">` (one per slide page)
|
||||
|
||||
Slide Container: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="..." viewBox="0 0 W H">`
|
||||
- A single slide page
|
||||
- Required Attributes:
|
||||
- `xmlns="http://www.w3.org/2000/svg"` — the SVG namespace declaration
|
||||
- `xmlns:slide="https://slides.bytedance.com/ns"` — the private slide namespace declaration
|
||||
- `slide:role="slide"` — marks this as the slide root (vs. an inline svg)
|
||||
- `id="..."` — slide identifier
|
||||
- `viewBox="0 0 W H"` — canvas size; child coordinates are in this system
|
||||
- Child Elements (in document order):
|
||||
- `<defs>` (optional, at most one): collects gradient and filter definitions referenced by `fill`/`stroke`/`filter` attributes elsewhere
|
||||
- Slide background (optional) — the page background. Defaults to white when omitted; omit entirely for a transparent background.
|
||||
- **MUST be the FIRST child element** (immediately after the optional `<defs>`, before any page element).
|
||||
- **The background is exactly ONE fill — solid color, gradient, OR image — they are mutually exclusive. Pick ONE; never stack them.** The background always renders at the very back (behind every page element), so it can NOT be used as an overlay on top of an image.
|
||||
- Solid color: `<rect slide:role="background" width="W" height="H" fill="rgba(...)"/>`
|
||||
- Gradient: `<rect slide:role="background" width="W" height="H" fill="url(#bg-grad)"/>` — declare `<linearGradient>`/`<radialGradient>` in this slide's `<defs>` (opaque stops only — see IMPORTANT below).
|
||||
- Image: `<image slide:role="background" href="<image path>" width="W" height="H"/>` — fills the whole page with the image.
|
||||
- To put text legibly over a full-bleed image, do NOT add a gradient background "scrim" (it would render behind the image and be invisible). Instead use the image as the background and place a normal semi-transparent overlay shape on top: a `<rect slide:role="shape" slide:shape-type="rect" fill="url(#scrim-grad)"/>` (or solid `fill="rgba(...,.5)"`) positioned over the text area, AFTER the image in document order.
|
||||
- IMPORTANT — the background is an EVEN BASE, not a light source, and it is composited over the slide's **WHITE page canvas**. Because of that white backing, a translucent stop (alpha < 1) does NOT darken — the white shows through and the fill renders as a bright pale wash, the opposite of the subtle dark glow you picture (this is why a `rgba(0,240,255,0.1)` "dark glow" comes out as a blown-out white-cyan haze). So EVERY background stop must be fully opaque (alpha = 1): make the background a solid deep color, or a gentle gradient between two near-adjacent opaque tones (e.g. `rgba(11,15,25,1)`). Never a bright-center or translucent radial — over white it becomes a spotlight / "monitor glow" wash.
|
||||
- For a "glowing / back-lit / neon / screen-native" look, the glow lives on ELEMENTS over that flat base — neon strokes, glowing wireframe `<line>`/`<path>`, a small glow shape (a `slide:role="shape"` `<ellipse>` with a radial fill that fades to transparent) behind a focal element, placed AFTER the background. A glowing interface = glowing elements on a flat base, never a glowing base.
|
||||
- Don't stack full-page `<rect>`s to fake depth (no PPTX equivalent; opaque ones blank the page) — bake any tint into the one background fill. For texture, draw a few real `<line>`/`<circle>` primitives. (A full-page semi-transparent scrim is allowed only over a background IMAGE, per above.)
|
||||
- Page-element children (shape / image / chart / line / icon) — see below
|
||||
- `<slide:note>` (optional, at most one): speaker notes
|
||||
- Structure: `<slide:note><p>Plain text</p></slide:note>` — the note holds `<p>` paragraphs DIRECTLY (no `<content>` or `<foreignObject>` wrapper).
|
||||
- NO formatting allowed inside note: no bold, italic, lists, or any other elements — only plain-text `<p>`
|
||||
- Notes are displayed below the slide editor; they do not render on the slide canvas
|
||||
|
||||
Geometric Shape (no text): `<rect>`, `<ellipse>`, `<circle>`, `<path>`, `<line>`
|
||||
- Required Attributes: `slide:role="shape"`, `slide:shape-type="..."`
|
||||
- Use these only for **pure geometric decoration** with no text content. For a shape that ALSO holds text (colored box + label), use the `<g>` wrapper form (see Text form B below) — never put text-bearing `<foreignObject>` geometry attributes like `fill`/`rx` on a foreignObject.
|
||||
- Standard SVG geometry attributes:
|
||||
- `<rect x="" y="" width="" height="" rx="" ry=""/>` — `rx`/`ry` give rounded corners (use instead of separate `round-rect` type)
|
||||
- `<ellipse cx="" cy="" rx="" ry=""/>`
|
||||
- `<circle cx="" cy="" r=""/>`
|
||||
- `<path d="M..." slide:width="W" slide:height="H"/>` — `d` is the path data in a local `0..W × 0..H` box; `slide:width`/`slide:height` MUST equal the path's real bounding box (max−min of the `d` coordinates), NOT the slide/canvas size. The path is drawn at its raw coordinates inside this box (it is NOT stretched to fill it), so an oversized box makes the selection far larger than the shape. Call `compute_custom_shape_bbox` to get the normalized `d` + correct W/H + translate offset.
|
||||
- `<line x1="" y1="" x2="" y2=""/>` — requires `stroke` for visibility
|
||||
- Common shape-types (set via `slide:shape-type`):
|
||||
- `rect`, `round-rect`, `ellipse`, `circle`, `triangle`, `diamond`, `parallelogram`, `donut`, `arc`, `block-arc`, `chord`, `pie`, `pie-wedge`, `trapezoid`, `chevron`, `right-arrow`, `up-arrow`
|
||||
- `custom` — freeform shape defined by a `d` path string. REQUIRED for `<path>` elements; ONLY allowed when shape-type="custom".
|
||||
- Visual attributes (any of these may be omitted):
|
||||
- `fill="rgba(...)"` or `fill="url(#grad-id)"` (gradient) — omit for no fill
|
||||
- `stroke="..."`, `stroke-width="..."`, `stroke-dasharray="..."` — omit for no border
|
||||
- `opacity="0.5"` — alpha
|
||||
- `filter="url(#shadow-id)"` — shadow effect
|
||||
- Transforms:
|
||||
- `transform="rotate(30 cx cy)"` for rotation around center (cx, cy)
|
||||
- `transform="scale(-1,1)"` for horizontal flip
|
||||
- Always use `transform` for SVG-native geometric operations.
|
||||
- Private attributes:
|
||||
- `slide:width="..."` / `slide:height="..."` — for `<path slide:shape-type="custom">` only. Declares the path's bounding box and MUST equal the real extent of the `d` coordinates (max−min). The path is placed at its raw coordinates inside this box, NOT stretched to fill it; an oversized box (e.g. the canvas size) bloats the selection box. Use `compute_custom_shape_bbox` to compute the right values.
|
||||
|
||||
Text — TWO forms depending on whether the text sits on a colored/bordered shape:
|
||||
|
||||
A. Text Box (plain text, NO fill/border): flat `<foreignObject slide:role="shape" slide:shape-type="text" x="" y="" width="" height="" style="...">`
|
||||
- Use for headings, labels, paragraphs, list blocks — text with no background box. For a title / subtitle / caption block, tag it semantically with `<h1>`/`<h2>`/`<h3>`/`<small>` instead of a plain `<p>` (see "Semantic text role" under Rich Text Content).
|
||||
- Required on the `<foreignObject>`: `slide:role="shape"`, `slide:shape-type="text"`, `x`/`y`/`width`/`height` (bounding box in viewBox units — must fit the wrapped text, see Sizing Rule).
|
||||
- Children: xhtml only — the DIRECT children ARE the `<p>`/`<ul>`/`<ol>` themselves. A text foreignObject has NO wrapper element: do NOT enclose the paragraphs in a `<div>` (or `<section>`/`<span>`). Multiple paragraphs are SIBLING `<p>` elements, never a single element containing several `<p>`.
|
||||
- Multi-paragraph example — two SIBLING `<p>`, no wrapper:
|
||||
`<foreignObject slide:role="shape" slide:shape-type="text" x="180" y="265" width="380" height="200" style="font-size:14px; color:rgba(100,105,108,1); line-height:1.75; vertical-align:top"><p xmlns="http://www.w3.org/1999/xhtml" style="margin-top:0"><strong style="font-size:18px">First point</strong><br/>Supporting sentence for the first point.</p><p xmlns="http://www.w3.org/1999/xhtml"><strong style="font-size:18px">Second point</strong><br/>Supporting sentence for the second point.</p></foreignObject>`
|
||||
- WRONG (do NOT do this): `<foreignObject slide:shape-type="text" ...><div><p>...</p><p>...</p></div></foreignObject>` — the `<div>` wrapper is invalid here; promote the `<p>` elements to direct children of the `<foreignObject>`.
|
||||
- ALSO WRONG (bare text, no `<p>`): `<foreignObject slide:shape-type="text" ...>Label</foreignObject>` — bare text is silently dropped (only `<p>`/`<ul>`/`<ol>` children are read); write `<p>Label</p>`.
|
||||
|
||||
B. Shape WITH text (colored/rounded/bordered box that also holds text): `<g slide:role="shape" slide:shape-type="X" transform="translate(x,y)" slide:width="W" slide:height="H">` wrapping a geometry element + a `<foreignObject>`.
|
||||
- `<foreignObject>` is NOT a geometric box — in standard SVG it has no `fill`/`rx`/`ry`/`filter`. A shape with both a fill AND text MUST use this `<g>` form. NEVER put `fill`/`rx`/`ry` on a `<foreignObject>`.
|
||||
- Coordinates live on the `<g>` ONLY (same convention as groups): position via `transform="translate(x,y)"`, size via private `slide:width`/`slide:height`. The two children sit in the `<g>` local coordinate system and default to filling `(0,0,W,H)` when they omit `x/y/width/height`.
|
||||
- Geometry child (`<rect>`/`<ellipse>`/`<path>`/…): carries `fill`/`stroke`/`rx`/`ry`/`filter`/`d`. Its tag must match `slide:shape-type` on the `<g>`.
|
||||
- Text child (`<foreignObject style="...">`): carries the text (xhtml + CSS), nothing geometric.
|
||||
- Order: geometry element FIRST, `<foreignObject>` SECOND (text paints on top).
|
||||
- Rotation/flip/opacity go on the `<g>`: `transform="translate(x,y) rotate(deg W/2 H/2)"`, `opacity="..."`.
|
||||
- Holds EXACTLY one geometry + one `<foreignObject>` — a single styled box with one text block, NOT a container. Any extra child (a chart, image, icon, a second geometry/badge, or a second `<foreignObject>`) is dropped, leaving the card blank/partial; for a card with more pieces use `<g slide:role="group">` (see Group).
|
||||
- Example (CTA pill):
|
||||
`<g slide:role="shape" slide:shape-type="round-rect" transform="translate(640,320)" slide:width="160" slide:height="32"><rect rx="16" ry="16" fill="rgba(31,109,137,1)"/><foreignObject style="vertical-align:middle"><p xmlns="http://www.w3.org/1999/xhtml" style="font-size:14px; color:rgba(255,255,255,1); text-align:center">Status: Active</p></foreignObject></g>`
|
||||
|
||||
- Text styling (BOTH forms) — put EVERYTHING in `style="..."` (CSS, semicolon-separated). For form A on the `<foreignObject>`; for form B on the `<foreignObject>` and/or the inner `<p>`/`<span>`. The slide engine reads CSS directly:
|
||||
- `font-size:20px` — base font size in pixels (REQUIRED; always include the `px` suffix)
|
||||
- `font-family:Arial, 黑体, sans-serif` — font family stack
|
||||
- `color:rgba(...)` — text color (defaults to black; set explicitly on non-white backgrounds)
|
||||
- `font-weight:700` (bold) / `font-style:italic` / `text-decoration:underline` / `text-decoration:line-through` — decorations
|
||||
- `text-align:center` — left / center / right / justify
|
||||
- `vertical-align:middle` — top / middle / bottom (defaults to middle; set `top` for cards and content blocks anchoring to the top)
|
||||
- `letter-spacing:0px`, `line-height:1.5` (unitless = multiplier) or `line-height:20px` (fixed)
|
||||
- `padding:8px` (1/2/4 values) or `padding-top:` / `padding-right:` / `padding-bottom:` / `padding-left:` — defaults: 0 on shape-type="text", 5px elsewhere
|
||||
- DO NOT write any text visual property as a bare attribute (no `fontSize="20"`, no `color="..."`, no `bold="true"`). All text visual properties go into `style="..."`.
|
||||
- Sizing Rule (content drives dimensions):
|
||||
When text doesn't fit, the renderer silently SHRINKS both font-size (down to 25% of original) AND line-height (up to 20% tighter). Fix content first, then size the box.
|
||||
Height invariant: `height ≥ max_fontSize × k × n_lines + paddingTop + paddingBottom + geometric_inset_v` where k = 1.5 for default `line-height:1.5`.
|
||||
Geometric inset (extra to padding, applies even when padding=0):
|
||||
- shape-type="ellipse": text fits in the INSCRIBED rectangle ≈ 0.7×w × 0.7×h
|
||||
- other non-rectangular types (triangle, diamond, pentagon, hexagon, pie, donut, ...): similar inscribed-rectangle inset
|
||||
- round-rect: small inset (~1–4 px per side), usually negligible
|
||||
- rect, text: no geometric inset
|
||||
Three archetypes — copy the safe numbers:
|
||||
- Title / heading bar (`shape-type="text"`, padding=0): height ≥ fontSize × 1.5. A 36px bar fits font-size ≤ 24px, NOT 28px.
|
||||
- Pill / tag / chip (`shape-type="round-rect"`, ~20–30px tall): set `style="padding:0; ..."`. Then with default line-height, fontSize ≤ ⌊height / 1.5⌋. E.g. height=24 → fontSize ≤ 16.
|
||||
- Number / icon badge (`shape-type="ellipse"`, both axes ≤ 30): set `style="padding:0; ..."` — geometric inset still applies. With default line-height, fontSize ≤ ⌊0.47 × height⌋. E.g. 24×24 → fontSize ≤ 11.
|
||||
- One foreignObject = one text block. For style variations within the same text block, use `<span style="...">` (or HTML semantic tags `<strong>`/`<em>`/...) inside `<p>` — not multiple foreignObjects. Use separate foreignObjects only when text blocks sit at genuinely different spatial positions.
|
||||
|
||||
Image: `<image slide:role="image" slide:shape-type="image" href="..." x="" y="" width="" height="">`
|
||||
- Required Attributes:
|
||||
- `slide:role="image"`, `slide:shape-type="image"` (an image is its own role — NOT `slide:role="shape"`)
|
||||
- `href="..."` — Complete image file path (absolute, e.g., `/home/user/workspace/resources/images/foo.jpg`). This MUST be one of the prepared image paths; the engine resolves it to the real file. Use `href`, not the legacy `xlink:href`.
|
||||
- `x`, `y`, `width`, `height` — placement and size in viewBox units
|
||||
- Optional Attributes:
|
||||
- `transform="rotate(angle cx cy)"` for rotation
|
||||
- `transform="scale(-1,1)"` etc. for flip
|
||||
- `opacity="..."` — alpha
|
||||
- `alt="..."` — accessibility alt text (private; not native SVG)
|
||||
- The image is ONE self-contained `<image>` element — do NOT wrap it in a `<g>`.
|
||||
- For border/shadow on an image, set the attrs directly on the `<image>`: `stroke="..."`, `stroke-width="..."`, `slide:shadow-*` (see Styling Attributes).
|
||||
- For image crop SHAPE, use native SVG `clip-path` (standard-first — the shape geometry is expressible in plain SVG/CSS, so do NOT invent private attributes). Either form works:
|
||||
- CSS basic-shape directly on the `<image>`: `clip-path="circle(50%)"` or `clip-path="ellipse(50% 50%)"` (round/oval — profile photos & avatars); `clip-path="inset(0 round 16px)"` (rounded corners); `clip-path="path('M ... Z')"` (custom silhouette). Geometry is the image's local box `[0,0,width,height]`.
|
||||
- Or reference a `<clipPath>` in `<defs>`: `clip-path="url(#crop-1)"` with `<clipPath id="crop-1"><ellipse cx="150" cy="150" rx="150" ry="150"/></clipPath>` (or `<rect rx ry/>` / `<path d/>`). You may let the engine manage ids — both forms are accepted.
|
||||
- Plain rectangular images (no shape crop) → omit `clip-path`.
|
||||
- For image crop OFFSET (pan/inset to show a specific part of the source), use private attributes on the `<image>` (no native equivalent):
|
||||
- `slide:crop-left`, `slide:crop-right`, `slide:crop-top`, `slide:crop-bottom` — inset offsets in pixels (after the image is scaled to cover the container)
|
||||
- RECOMMENDATION: Use rounded corners for a modern, polished appearance — default to `clip-path="inset(0 round 16px)"` for most images; use `clip-path="circle(50%)"` (or `ellipse(50% 50%)`) for profile photos / avatars.
|
||||
- Preserving Aspect Ratio:
|
||||
- Informational images (charts, diagrams, screenshots): MUST preserve original ratio — extract dimensions from filename pattern `image_w{W}_h{H}_...` (e.g., `chart_w1920_h1080_sales.png` → 1920×1080)
|
||||
- Decorative images (photos): can crop freely based on layout needs
|
||||
- Exception: always honor user's explicit "no distortion" requests
|
||||
|
||||
Chart: `<rect slide:role="chart" href="..." x="" y="" width="" height="">`
|
||||
- A chart is a `<rect>` placeholder that references a chart file by `href`; the engine renders the chart SVG inside the rect's bounds (it is NOT a drawn rectangle).
|
||||
- `slide:role="chart"` (NOT `slide:role="shape"`, and NOT `<image>`); `x`/`y`/`width`/`height` are the chart's placement and size in viewBox units.
|
||||
- Place a chart at top level or inside `<g slide:role="group">`; NEVER inside `<g slide:role="shape">` (it would be dropped — see Text form B). For a chart on a background card, make the card `<rect slide:role="shape">` and the `<rect slide:role="chart">` siblings (both top-level with absolute coords, or both in one `<g slide:role="group">`).
|
||||
- `href` points to the chart file: `.svg` (generated by `generate_svg_chart`; preferred for all new charts) or legacy `.chart` (imported PPTX; preserve as-is unless the user asks to change).
|
||||
- IMPORTANT: Do NOT truncate or modify the href path. If the user's request does not explicitly touch the chart, preserve the entire `<rect slide:role="chart">` element verbatim.
|
||||
|
||||
Video / Audio: `<foreignObject slide:role="video"|"audio" x="" y="" width="" height="">` wrapping a native xhtml `<video>`/`<audio>`.
|
||||
- Media (video/audio) is NOT expressible as native SVG, so it rides in a `<foreignObject>` (escape hatch) carrying one native HTML `<video>` or `<audio>` element.
|
||||
- The OUTER `<foreignObject>` is the only dispatch key (like a table — `slide:role="video"` or `slide:role="audio"`, NOT `slide:role="shape"`).
|
||||
- Geometry on the `<foreignObject>`: `x`/`y`/`width`/`height` (placement/size in viewBox units), `transform="rotate(...)/scale(...)"`. Audio also supports `opacity`; **video does NOT support opacity** (no alpha).
|
||||
- **Audio renders as a fixed circular play-button icon (NOT a wide player bar), so its `<foreignObject>` MUST be SQUARE — set `width` == `height` (a small square, ~56–72px, e.g. 64×64).** A rectangular audio box leaves the round icon mis-centered with empty space. Video keeps its real aspect ratio (e.g. 16:9).
|
||||
- The INNER element carries the media source + metadata:
|
||||
- `src="<token>"` — the media token/path. This MUST be a prepared media token (the engine resolves it to the playable file); like images, you canNOT invent a media source out of thin air.
|
||||
- Video only: `width`/`height` = the source video's intrinsic resolution.
|
||||
- Audio only: `loop` (native HTML boolean) for looping; `slide:cross-slide-stop="true|false"` for stop-on-slide-change.
|
||||
- Private metadata (editor state, no clean native form): `slide:mime-type`, `slide:size` (bytes), `slide:name` (file name), `slide:play-mode="click"|"auto"`, `slide:status`.
|
||||
- Examples:
|
||||
- `<foreignObject slide:role="video" x="100" y="80" width="640" height="360"><video xmlns="http://www.w3.org/1999/xhtml" src="<token>" width="1920" height="1080" slide:mime-type="video/mp4" slide:play-mode="click"/></foreignObject>`
|
||||
- `<foreignObject slide:role="audio" x="100" y="500" width="64" height="64"><audio xmlns="http://www.w3.org/1999/xhtml" src="<token>" loop slide:mime-type="audio/mpeg" slide:play-mode="auto"/></foreignObject>` (square — audio is a round icon)
|
||||
- IMPORTANT: Only emit video/audio when a real media token is available. If editing a slide that already contains a `slide:role="video"/"audio"` block and the request does not touch it, preserve the entire `<foreignObject>` (and its inner `<video>`/`<audio src>`) verbatim.
|
||||
|
||||
Line: `<line slide:role="shape" slide:shape-type="line" x1="" y1="" x2="" y2="" stroke="..." stroke-width="...">`
|
||||
- Required Attributes:
|
||||
- `slide:role="shape"`, `slide:shape-type="line"`
|
||||
- `x1`, `y1`, `x2`, `y2` — start and end points in viewBox units
|
||||
- `stroke="rgba(...)"` and `stroke-width="..."` — REQUIRED for visibility
|
||||
- Optional Attributes:
|
||||
- `stroke-dasharray="..."` — dash pattern (see Border below for values)
|
||||
- `opacity="..."`
|
||||
- `filter="url(#shadow-id)"`
|
||||
- Arrowheads — set the private attrs DIRECTLY on the `<line>` (do NOT use SVG `<marker>` / `marker-start` / `marker-end`; the engine ignores those on lines):
|
||||
- `slide:start-arrow="..."` — arrowhead at the start point `(x1,y1)`
|
||||
- `slide:end-arrow="..."` — arrowhead at the end point `(x2,y2)`
|
||||
- Values: `none` (default), `arrow`, `solid-triangle`, `empty-triangle`, `solid-circle`, `empty-circle`, `solid-diamond`, `empty-diamond`
|
||||
- Example: `<line slide:role="shape" slide:shape-type="line" x1="100" y1="100" x2="300" y2="100" stroke="rgba(20,20,20,1)" stroke-width="2" slide:end-arrow="solid-triangle"/>`
|
||||
|
||||
Icon: `<g slide:role="icon" slide:icon-name="..." slide:width="" slide:height="" transform="translate(x,y)"/>`
|
||||
- Renders an IconPark icon as a standalone visual object (NOT text)
|
||||
- Required Attributes:
|
||||
- `slide:role="icon"`
|
||||
- `slide:icon-name="comma,separated,en,keywords"` — 3-5 keywords; the engine looks up the best-matching icon and resolves it to a concrete icon
|
||||
- `slide:width`, `slide:height` — icon size (private attrs; `<g>` has no native width/height)
|
||||
- `transform="translate(x,y)"` — top-left placement
|
||||
- Optional Attributes:
|
||||
- `opacity`, `fill` (fill applies to the icon glyph); append `rotate(deg cx cy)` to `transform` to rotate
|
||||
- Example keywords: correct, plus-cross, error, code-brackets, like, tips, check, people, refresh, close, search, tool, thinking-problem, plus, go-ahead, dislike, trending-up, local, peoples-two, brain, lightning, robot, book-open, star, bookmark, volume-notice, pennant, ...
|
||||
|
||||
Group: `<g slide:role="group"> ...children... </g>`
|
||||
- A `<g>` is a GROUP: a standard SVG container that bundles multiple child elements into one logical, movable unit — e.g. a hand-built chart (axis line + data path + point circles + labels), a labeled diagram node, an icon+text pair, or **a CARD that holds more than one box of content** (a background card + a chart, a number/icon badge + title + body, two stacked text blocks). Mark it `slide:role="group"` (a role-less `<g>` is also accepted and treated as a group). **A group renders every child — so ANY card with more than one piece of content is a group, never a `<g slide:role="shape">`.**
|
||||
- **Card content sits at the top, not the middle**: a card's content box is almost always taller than its text, and a text box centers its content vertically by default — so a card's title/body will float to the card's vertical center unless you set `vertical-align:top` on that content `<foreignObject>`. Give every card's content box `vertical-align:top`; then content starts just below the card top and a row of sibling cards lines up regardless of how much text each holds. `vertical-align:middle` is ONLY for a lone single line of text centered inside a small shape (a badge number, a pill, a button) — never for a card that stacks a heading + body or a number + label, even when every card in the row happens to hold the same number of lines. If the content box has more than one line or more than one `<p>`, it is `top`.
|
||||
- Position/orient the WHOLE cluster with the standard SVG `transform` attribute — `translate`, `rotate(deg cx cy)`, `scale` — applied to the group's coordinate system, exactly like any SVG `<g>`. Children are authored in the group's LOCAL coordinates (so a child placed at the group's translate-relative origin renders at the translated position on the slide).
|
||||
- **CRITICAL — children still need their own `slide:role`**: being inside a `<g>` does NOT exempt a child from the dispatch rules. Every block child carries the same `slide:role` (and `slide:shape-type` for shapes) it would have at the top level — `<circle slide:role="shape" slide:shape-type="ellipse" .../>`, `<foreignObject slide:role="shape" slide:shape-type="text" .../>`, `<image slide:role="image" .../>`, a nested `<g slide:role="icon">`, etc. (`<line>`/`<polyline>` are role-less by nature.) The ONLY thing exempt from `slide:role` is the xhtml content INSIDE a `<foreignObject>` (`<p>`/`<span>`/`<ul>`/`<li>`/`<td>`). A block child missing its required `slide:role` will NOT render.
|
||||
- Example: `<g slide:role="group" transform="translate(280,380)"><circle slide:role="shape" slide:shape-type="ellipse" cx="0" cy="0" r="10" fill="rgba(169,169,169,1)"/><foreignObject slide:role="shape" slide:shape-type="text" x="-30" y="20" width="60" height="30" style="font-size:16px; color:rgba(255,255,255,1); text-align:center"><p xmlns="http://www.w3.org/1999/xhtml">水星</p></foreignObject></g>`. Groups may nest.
|
||||
- Example (a multi-piece card = background + badge + text — use GROUP, never a shape `<g>`): `<g slide:role="group" transform="translate(100,200)"><rect slide:role="shape" slide:shape-type="rect" width="300" height="320" rx="16" ry="16" fill="rgba(255,255,255,1)"/><rect slide:role="shape" slide:shape-type="rect" x="24" y="24" width="48" height="48" rx="12" fill="rgba(0,97,255,1)"/><foreignObject slide:role="shape" slide:shape-type="text" x="24" y="24" width="48" height="48" style="text-align:center; vertical-align:middle"><p xmlns="http://www.w3.org/1999/xhtml" style="color:rgba(255,255,255,1)">1</p></foreignObject><foreignObject slide:role="shape" slide:shape-type="text" x="24" y="96" width="252" height="200" style="vertical-align:top"><p xmlns="http://www.w3.org/1999/xhtml" style="font-size:22px; font-weight:700">Card title</p><p xmlns="http://www.w3.org/1999/xhtml" style="font-size:16px">Supporting body text.</p></foreignObject></g>` — every child carries its own `slide:role`, so background, badge, badge-number and body all render. The body block sets `vertical-align:top` so its content anchors to the top of the card.
|
||||
- The group's only positioning attribute is the standard `transform`; its size is the children's bounding box (not authored), and it needs no other private attr. `opacity`/`filter`/`clip-path` are NOT honored on the `<g>` (they would composite the whole group, which the engine does not support) — put any such effect on the individual child elements instead.
|
||||
- Use a group only for genuinely multi-element clusters (2+ children). Do NOT wrap a single element, and never wrap an `<image>` in a `<g>` (see Image above).
|
||||
|
||||
Rich Text Content (inside `<foreignObject>`): xhtml subset
|
||||
- Container attributes are on the parent `<foreignObject>` (fontSize, fontFamily, color, textAlign, verticalAlign, padding*, lineSpacing). See Text Shape above.
|
||||
- Valid xhtml children: `<p>`, `<ul>`, `<ol>` — the DIRECT children of the `<foreignObject>`. Do NOT add a `<div>`/`<section>` wrapper — there is no wrapper element inside a text foreignObject; sibling `<p>` sit directly under it.
|
||||
- Vertical Spacing inside a single foreignObject:
|
||||
- Valid approaches:
|
||||
1. `beforeLineSpacing`/`afterLineSpacing` on `<p>` (format: "fixed:N", e.g. `<p beforeLineSpacing="fixed:10">`). EXCEPTION: cannot use inside `<li>`.
|
||||
2. Separate `<p>` elements — creates a default small gap automatically
|
||||
3. Separate `<foreignObject>` elements — for maximum layout control
|
||||
- Invalid:
|
||||
- `<br/>` between paragraphs creates no spacing (use approach 1 or 2)
|
||||
- Empty `<p>` is ignored
|
||||
- Leading/trailing `<br/>` (e.g. `<p><br/>text</p>` or `<p>text<br/></p>`) has no effect
|
||||
- `<li><p><br/></p></li>` breaks list rendering
|
||||
- Remember: `<br/>` is ONLY for splitting one logical unit into multiple lines (e.g., "Name<br/>Job Title"), NOT for creating gaps
|
||||
|
||||
Paragraph: `<p style="text-align:..; line-height:..; ...">`
|
||||
- A structural paragraph separator. Controls text flow, NOT text appearance.
|
||||
- Paragraph-level styling — put EVERYTHING in `style="..."` (CSS, semicolon-separated):
|
||||
- `text-align:left / center / right / justify`. `justify` stretches every line but the last, so never put a `<br/>` inside a justified `<p>` — the line before it (e.g. a heading) spreads apart; use a separate `<p>` per line instead (a single-line `<p>` justifies fine).
|
||||
- `letter-spacing:Npx`
|
||||
- `line-height:1.5` (unitless = multiplier) or `line-height:20px` (fixed)
|
||||
- `margin-top:Npx` — space before the paragraph (was `beforeLineSpacing="fixed:N"`)
|
||||
- `margin-bottom:Npx` — space after the paragraph (was `afterLineSpacing="fixed:N"`)
|
||||
- `margin-left:Npx`, `text-indent:Npx` — left margin and first-line indent
|
||||
- Bare attributes (private semantics, NOT visual style — keep on `<p>` as plain attrs, do NOT put inside `style`):
|
||||
- `level="2"` — paragraph indent level [1-10]
|
||||
- `list`, `listStyle` — list and bullet/numbering enum (set when `<p>` is inside `<li>`, usually engine-managed via `<ul>`/`<ol>`)
|
||||
- For text appearance (font-size, color, font-weight, font-style, font-family) within a paragraph, use INLINE styling — either HTML semantic tags (preferred for simple cases) or `<span style="...">`:
|
||||
- Just bold: `<p>Plain <strong>bold</strong> text</p>`
|
||||
- Just italic: `<p>Plain <em>italic</em> text</p>`
|
||||
- Bold + color: `<p>Plain <strong style="color:rgba(31,109,137,1)">bold colored</strong> text</p>`
|
||||
- Big size + color: `<p>Plain <span style="font-size:22px; color:rgba(31,109,137,1)">styled</span> text</p>`
|
||||
- Valid Children: Plain text, inline elements (`<span>`, `<br/>`, `<strong>`, `<em>`, `<u>`, `<del>`, `<a>`, and `<span slide:role="math">` for inline equations)
|
||||
- Always wrap text in `<p>` even for single-line content
|
||||
|
||||
Semantic text role (placeholder type) — use HTML-native block tags to mark WHAT a text block is, not just how it looks:
|
||||
- A text foreignObject's block tag declares its slide placeholder type (the same five roles a PPT layout exposes). This is semantic, ON TOP of the CSS styling you still write normally:
|
||||
- `<h1>` → TITLE (cover title)
|
||||
- `<h2>` → HEADLINE (slide title)
|
||||
- `<h3>` → SUB_HEADLINE (subtitle)
|
||||
- `<p>` → TEXT (body — the DEFAULT; keep using `<p>` for all ordinary prose, list items, labels)
|
||||
- `<small>` → SMALL_TEXT (caption / source line)
|
||||
- Prefer the semantic tag for a slide's primary title, subtitle, and caption/source text: use `<h1>`/`<h2>`/`<h3>` for the title hierarchy and `<small>` for source/footnote/caption text. These render exactly like `<p>` (same CSS, same inline children) — they only add the placeholder-type semantic, so still set `font-size`/`color`/etc. in `style="..."` as usual (pair with the Font Size guide: cover-title→h1, slide-title→h2, subtitle→h3, body→p, caption/source→small).
|
||||
- Rules:
|
||||
- ONLY as a DIRECT child of the text `<foreignObject>` (a sibling of `<p>`). `<small>` is also a valid INLINE tag — inside a `<p>` (e.g. `<p>¥99 <small>incl. tax</small></p>`) it stays inline small text and does NOT become a placeholder.
|
||||
- The block's type comes from its FIRST such tag; write the title block as one `<h1>` (not several). Mixed prose stays in `<p>`.
|
||||
- `<h4>`–`<h6>` carry no special type — they fall back to body `<p>`. Use only `<h1>`/`<h2>`/`<h3>`/`<small>`.
|
||||
- Example (title + subtitle + source, three sibling blocks in one cover text box):
|
||||
`<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="240" width="1120" height="220" style="color:rgba(20,20,20,1)"><h1 xmlns="http://www.w3.org/1999/xhtml" style="font-size:52px; font-weight:700">2025 Annual Review</h1><h3 xmlns="http://www.w3.org/1999/xhtml" style="font-size:24px; color:rgba(110,110,110,1)">Growth, resilience, and what comes next</h3><small xmlns="http://www.w3.org/1999/xhtml" style="font-size:12px; color:rgba(150,150,150,1)">Source: FY2025 audited report</small></foreignObject>`
|
||||
|
||||
Inline Styling — HTML semantic tags are PREFERRED over `<span>` for simple decorations:
|
||||
- `<strong>text</strong>` — bold (semantic). Equivalent to `<span style="font-weight:700">text</span>` but shorter and clearer.
|
||||
- `<em>text</em>` — italic. Equivalent to `<span style="font-style:italic">text</span>`.
|
||||
- `<u>text</u>` — underline. Equivalent to `<span style="text-decoration:underline">text</span>`.
|
||||
- `<del>text</del>` — strikethrough. Equivalent to `<span style="text-decoration:line-through">text</span>`.
|
||||
- `<a href="https://...">link text</a>` — hyperlink. Use the HTML-native `href` attribute (NOT `slide:href`).
|
||||
- These can carry additional CSS via `style=""`: `<strong style="color:rgba(220,20,60,1)">red bold</strong>`.
|
||||
|
||||
Inline Styled Text: `<span style="...">` — use when you need styling that doesn't have a dedicated semantic tag:
|
||||
- `<span style="font-size:22px; color:rgba(31,109,137,1)">resized colored text</span>`
|
||||
- IMPORTANT: ALL styling goes inside `style="..."`. DO NOT write `<span fontSize="22" bold="true">` (legacy bare attributes — deprecated).
|
||||
- IMPORTANT: NEVER use Markdown syntax (`**bold**`, `*italic*`, `__underline__`, `~~strikethrough~~`) for text styling. Use the HTML tags above.
|
||||
- CSS properties supported inside `style="..."`:
|
||||
- `font-size:Npx`, `font-family:..., ..., serif`
|
||||
- `color:rgba(...)`, `background-color:rgba(...)`
|
||||
- `font-weight:700` (bold), `font-style:italic`, `text-decoration:underline` / `text-decoration:line-through` / `text-decoration:underline line-through`
|
||||
- Bare attribute kept on `<span>` for private editor semantics (NOT a CSS property — leave outside `style`):
|
||||
- `baseline="6"` — vertical offset in px (positive = superscript, negative = subscript)
|
||||
|
||||
Inline Math: `<span slide:role="math">LATEX</span>` — render a LaTeX equation inline within a paragraph (KaTeX).
|
||||
- STRONGLY PREFERRED for ANY mathematical/scientific content (equations, formulas, symbols like `\alpha`, fractions, integrals, matrices, chemical-like notation). Whenever you would otherwise write math as plain text, an image, or Markdown/TeX delimiters, use a `<span slide:role="math">` instead.
|
||||
- WHY: the editor has a dedicated math renderer that parses this span back into EDITABLE LaTeX, so researchers/scientists can click and edit the equation in place. Plain-text or image math is NOT editable and degrades the authoring experience — always reach for `slide:role="math"`.
|
||||
- The span's text content is the raw LaTeX source. Do NOT include delimiters (`$...$`, `$$...$$`, `\(...\)`, `\[...\]`) — write the bare LaTeX, e.g. `<span slide:role="math">E = mc^2</span>`.
|
||||
- `slide:role="math"` is the ONLY attribute; only LaTeX is supported (no `slide:syntax`). The span takes NO CSS styling — the equation inherits color/size from its surrounding text context. This is the only inline element that carries a `slide:role`.
|
||||
- XML-escape LaTeX special characters so the SVG stays valid XML: `<` → `<`, `>` → `>`, `&` → `&`. Do NOT use CDATA (use entities). Examples: inequality `<span slide:role="math">x < y</span>`, alignment `<span slide:role="math">a &= b</span>`.
|
||||
- Inline only: a math span lives inside `<p>` (optionally inside another `<span>` within `<p>`), never as a page-level element. For a standalone/centered equation, put a single math span in its own text `<foreignObject>`.
|
||||
- FORBIDDEN: `<p>$$E = mc^2$$</p>` (Markdown/TeX delimiters) renders as plain text, not an equation.
|
||||
- Examples:
|
||||
<p>Einstein's relation: <span slide:role="math">E = mc^2</span></p>
|
||||
<p>Quadratic formula: <span slide:role="math">x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}</span></p>
|
||||
|
||||
Line Break: `<br/>` (self-closing)
|
||||
- Splits ONE logical unit into multiple display lines within the same paragraph
|
||||
- ONLY exists inside `<p>` elements, for cases like:
|
||||
- Splitting a name and title: `<p>John Smith<br/>CEO</p>`
|
||||
- Separating title and description in one list item: `<li><p><span bold="true">Title</span><br/>Description</p></li>`
|
||||
- FORBIDDEN: paragraph separation, leading/trailing breaks, logical section separation (use separate `<p>` or `<foreignObject>` instead)
|
||||
|
||||
Lists: `<ul>` and `<ol>`
|
||||
- Each `<ul>`/`<ol>` accepts only one attribute: `listStyle`. No other attributes.
|
||||
- `<ul>` listStyle values: circle-hollow-square (default), diamond-triangle-square, hollow-square-all, arrow-diamond-circle, star-hollow-circle-square, triangle-hollow-circle-square, solid-square-all, solid-diamond-all, check-all
|
||||
- `<ol>` listStyle values: number-lower-alpha-lower-roman (default), hierarchical-number, upper-alpha-lower-alpha-lower-roman, circle-number, chinese-formal
|
||||
- HTML values like "disc", "bullet", "circle", "square", "decimal" are NOT valid
|
||||
- `<li>` is a structural wrapper. Accepts no styling attributes. Its ONLY valid child is exactly one `<p>`.
|
||||
- Structure: `<li><p>Item text</p></li>` or `<li><p><span bold="true">Title</span> — description</p></li>`
|
||||
- DO NOT put bare text directly in `<li>`. DO NOT use empty `<li>` for spacing.
|
||||
- `<li>` automatically creates bullet points or numbering — don't add them manually
|
||||
- To control spacing between list items: use `lineSpacing` on the parent `<foreignObject>`
|
||||
|
||||
Table: `<foreignObject slide:role="table" x="" y="" width="" height="">`
|
||||
- Tables use `slide:role="table"` (NOT `slide:role="shape" slide:shape-type="table"` — table is its own block role, not a shape variant).
|
||||
- Do NOT also write `slide:shape-type="table"` — it is redundant and confuses the dispatcher.
|
||||
- Tables are rendered as xhtml `<table>` inside a `<foreignObject>`. Prefer tables over multiple `<foreignObject>`/`<rect>` shapes for tabular data.
|
||||
- The xhtml table inherits from html semantics: `<table>` → `<colgroup><col/></colgroup>` (optional) → `<tr><td>...</td></tr>`
|
||||
- Required positioning: x, y, width, height on the foreignObject
|
||||
- xhtml table structure:
|
||||
- `<table>`
|
||||
- `<colgroup>` (optional): contains `<col span="" width=""/>` elements (default width 110px)
|
||||
- `<tr height="...">` (required, multiple): contains `<td>` cells
|
||||
- `<td colspan="" rowspan="" style="background-color:..; border:..; padding:..">`: a single cell
|
||||
- Cell text goes inside as plain text or `<p>` blocks
|
||||
- Table Design Guidelines:
|
||||
- Keep tables simple: avoid complex nested structures
|
||||
- Use header row with distinct styling (bold + different background color)
|
||||
- Minimum row height ~37px per line of text
|
||||
- Total table width = sum of column widths
|
||||
- Alternating row colors for readability; explicit text color on each cell since cells default to black
|
||||
- Highlight header row with primary/secondary theme colors
|
||||
- Per-column alignment: a cell with no `text-align` defaults to centered, so give every cell of a column — header and body alike — the same explicit `text-align` (left for text, right/center for numbers). Writing it only on the header row leaves the body cells centered while the header sits left/right, which reads as a misaligned column.
|
||||
|
||||
Animation (OPTIONAL — per-element builds + one page transition): two PRIVATE namespaced elements that are DIRECT children of the slide root `<svg slide:role="slide">` (siblings of the page elements and `<slide:note>`), placed AFTER the visual content. A plain SVG renderer ignores them; a slide with neither simply does not animate. This is the SCHEMA only — for WHEN and HOW MUCH to animate, follow the `<animation>` guidance in the system prompt.
|
||||
- Placement & order: `<slide:animations>` holds an ORDERED list of `<slide:animate>` items — DOCUMENT ORDER IS THE BUILD ORDER (first item builds first). At most ONE `<slide:transition>` per slide.
|
||||
- Use tokens from the catalog below, spelled exactly. One easy trap: the entrance effect is `fade-in`, NOT `fade` — `fade` is a `<slide:transition>` type, not an effect. (e.g., trigger `after-prev`, direction `from-bottom`.)
|
||||
|
||||
`<slide:animate target="..." effect="..." .../>` — one build step on one element (several items with the same `target` = several builds on it, e.g. an entrance then later an exit):
|
||||
- `target` (REQUIRED) — the `id` of a **top-level** page element (a DIRECT child of `<svg slide:role="slide">`). It can be a shape or a `<g>` group. **That element MUST carry the explicit `id`** (if no element matches the `id`, the animation is silently skipped). Note: A `<g>` group is ONE animation unit. The engine ignores animations on elements nested inside a `<g>`. To reveal logical parts sequentially (e.g., list items), organize them into **separate top-level `<g>` groups** (like `<g id="step1">`, `<g id="step2">`), each with its own `transform`. Do not flatten complex shapes just for animation.
|
||||
- `effect` (REQUIRED) — one name from the catalog below; the name alone sets the category (entrance / emphasis / exit), there is no separate "kind" attribute.
|
||||
- `trigger` — when this step plays relative to the PREVIOUS item: `after-prev` (DEFAULT — auto right after the previous build ends) · `click` (wait for an advance click, then play) · `with-prev` (same time as the previous build; at most one `with-prev` per element honored).
|
||||
- `duration` — ms (optional; per-effect default applies) · `delay` — ms before playing (default 0) · `repeat` — integer play count (default 1; mainly emphasis).
|
||||
- `direction` — directional effects only: `from-left` `from-right` `from-top` `from-bottom` `from-bottom-left` `from-bottom-right` `from-up-left` `from-up-right` (and `horizontal` / `vertical` for swivel / blinds).
|
||||
- `scale` — `grow-shrink` only (target percent, e.g. `150`=grow to 150%, `50`=shrink) · `rotate` — `spin` only (degrees, e.g. `360`) · `spoke` — `wheel-in` / `wheel-out` only (`1` `2` `3` `4` `8`, default 1).
|
||||
Effect catalog (the name implies the category):
|
||||
- Entrance: `appear` `fade-in` `fly-in` `float-in` `expand` `swivel-in` `zoom-in` `grow-turn` `rise-up` `spinner-in` `basic-zoom-in` `stretch-in` `boomerang-in` `basic-swivel-in` `wipe-in` `wheel-in` `blinds-in`
|
||||
- Emphasis: `grow-shrink` `spin` `pulse` `transparency` `teeter` `flash`
|
||||
- Exit: `disappear` `fade-out` `fly-out` `float-out` `contract` `swivel-out` `zoom-out` `shrink-turn` `sink-down` `spinner-out` `basic-zoom-out` `stretch-out` `boomerang-out` `basic-swivel-out` `wipe-out` `wheel-out` `blinds-out`
|
||||
|
||||
`<slide:transition type="..." .../>` — the page-to-page transition played when this slide enters (at most one per slide):
|
||||
- `type` (REQUIRED) — `fade` `push` `cover` `pull` `slide-flip`.
|
||||
- `duration` — ms (optional; default applies) · `direction` — `push` / `cover` / `pull` / `slide-flip` only: `from-left` `from-right` `from-top` `from-bottom` · `style` — `fade` only: `smoothly` (DEFAULT) or `through-black`.
|
||||
</available_components>
|
||||
|
||||
<styling_attributes>
|
||||
All styling is via SVG-standard attributes directly on shape elements, with two extensions:
|
||||
1. Gradients / patterns / filters use `<defs>` and `url(#id)` references.
|
||||
2. Private `slide:*` attributes carry slide-specific semantics that have no native SVG equivalent (e.g., `slide:border-compound`).
|
||||
|
||||
Fill: `fill="..."` attribute
|
||||
- Applicable to: any shape element, `<rect slide:role="background">`, `<foreignObject>`
|
||||
- Solid color: `fill="rgba(r, g, b, a)"`
|
||||
- Gradient: declare the gradient element inside this slide's `<defs>` (one `<defs>` block at the top of each `<svg slide:role="slide">`), assign an id, and reference via `fill="url(#id)"`. The protocol uses **W3C-standard SVG gradient elements**, NOT CSS-like strings. Ids are slide-local (each slide has its own defs scope).
|
||||
- Linear gradient:
|
||||
```
|
||||
<defs>
|
||||
<linearGradient id="bg-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="rgba(100, 150, 200, 1)"/>
|
||||
<stop offset="100%" stop-color="rgba(50, 100, 150, 1)"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect ... fill="url(#bg-grad)"/>
|
||||
```
|
||||
- Radial gradient — for a LOCALIZED glow drawn as a SHAPE on top of the background (translucent stops are correct here); NEVER as a `slide:role="background"` fill (see Slide background):
|
||||
```
|
||||
<defs>
|
||||
<radialGradient id="shape-glow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="rgba(118, 185, 0, 0.35)"/>
|
||||
<stop offset="60%" stop-color="rgba(118, 185, 0, 0.08)"/>
|
||||
<stop offset="100%" stop-color="rgba(118, 185, 0, 0)"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<ellipse slide:role="shape" slide:shape-type="ellipse" ... fill="url(#shape-glow)"/> <!-- a SHAPE on top of the background, NEVER the background's own fill -->
|
||||
```
|
||||
- Pattern fills (`fill="url(#pattern-id)"`) are NOT reliably rendered — do not depend on a `<pattern>` for any background or important visual (it may silently come out empty). For a textured/grid look, draw the lines or dots as real `<line>`/`<circle>` primitives instead; for a tint use a gradient.
|
||||
- Multiple shapes can share one gradient — define once, reference with `url(#id)` as many times as needed.
|
||||
- Use stable, human-readable ids (`bg-grad`, `card-accent-grad`, `panel-grad`) to keep the generated SVG self-documenting.
|
||||
- Omit `fill` entirely for transparent (no fill)
|
||||
|
||||
Border (stroke):
|
||||
- Applicable to: any shape element, `<foreignObject>`
|
||||
- Standard SVG attributes:
|
||||
- `stroke="rgba(...)"` — color
|
||||
- `stroke-width="..."` — width in viewBox units
|
||||
- `stroke-dasharray="..."` — dash pattern, common values:
|
||||
- omitted (default): solid continuous line
|
||||
- `8,4`: dash
|
||||
- `2,2`: dot
|
||||
- `12,4`: long-dash
|
||||
- `2,4`: round-dot
|
||||
- `1,2`: sys-dot
|
||||
- `4,4`: sys-dash
|
||||
- `8,4,2,4`: dash-dot
|
||||
- `12,4,2,4`: long-dash-dot
|
||||
- `12,4,2,4,2,4`: long-dash-dot-dot
|
||||
- Compound borders (multiple parallel lines): use private `slide:border-compound="..."` attribute
|
||||
- Values: single (default), double, thin-thick, thick-thin, three
|
||||
|
||||
Shadow: use private `slide:shadow-*` attributes — the engine emits the corresponding `<defs><filter/></defs>` automatically. You do NOT need to manage filter ids.
|
||||
- Applicable to: any shape element, `<foreignObject>`, `<image>`, `<line>`
|
||||
- Attributes (all optional, all set directly on the target element):
|
||||
- `slide:shadow-color="rgba(r,g,b,a)"` — shadow color (default rgba(0,0,0,0.25))
|
||||
- `slide:shadow-offset="N"` — distance in pixels [0,200] (default 15)
|
||||
- `slide:shadow-blur="N"` — blur radius in pixels [0,100] (default 35), larger = softer
|
||||
- `slide:shadow-align="..."` — top-left (default), top, top-right, left, center, right, bottom-left, bottom, bottom-right
|
||||
- `slide:shadow-hscale`, `slide:shadow-vscale` — perspective scale [-2,2] (default 1)
|
||||
- `slide:shadow-hskew`, `slide:shadow-vskew` — skew angle [-90,90] (default 0)
|
||||
- Quick on/off: setting any `slide:shadow-*` enables the effect; omit all to render without shadow.
|
||||
|
||||
Transform: `transform="..."`
|
||||
- Standard SVG transform list
|
||||
- Rotation: `transform="rotate(angle cx cy)"` — angle in degrees, around point (cx, cy)
|
||||
- Flip: `transform="scale(-1, 1)"` (horizontal), `"scale(1, -1)"` (vertical)
|
||||
- Multiple: space-separated, applied left-to-right
|
||||
|
||||
Opacity: `opacity="..."` — value in [0, 1]
|
||||
</styling_attributes>
|
||||
|
||||
<about_icons>
|
||||
The `<g slide:role="icon">` element renders an IconPark icon as a standalone visual object (NOT text).
|
||||
|
||||
Key characteristics of our icons:
|
||||
- UI-style glyphs: simple, single-color, product-like icons
|
||||
- Consistent visual language: stable style across the whole deck
|
||||
- Neutral semantics: best for functional or structural meaning (navigation, category, concept markers), not for emotion or tone
|
||||
- Precise layout control: icons are placed by `transform="translate(x,y)"` and sized by `slide:width`/`slide:height`, so they align well with grids and UI-like layouts
|
||||
|
||||
Icon vs Emoji:
|
||||
- In some cases, using an emoji character is preferable to introducing an icon element
|
||||
- Use icon when you want a clean, consistent UI symbol that fits professional or neutral slides and needs crisp alignment
|
||||
- Use emojis when you want colorful, expressive, lively tone cues inside text, including representing human facial expressions, moods, or emotions
|
||||
|
||||
Typical Use Cases of Icons and Emojis:
|
||||
- Visual Markers: Bullet point alternatives or list markers
|
||||
- Section Headers: Pair section titles with relevant icons or emojis
|
||||
- Emphasis: Add emotional context or highlight key points
|
||||
- Metadata Display: Place alongside keywords or key information
|
||||
</about_icons>
|
||||
|
||||
<canonical_examples>
|
||||
Minimal slide (white background, title + body):
|
||||
```
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:slide="https://slides.bytedance.com/ns"
|
||||
slide:role="slide" id="slide-1"
|
||||
viewBox="0 0 1280 720">
|
||||
<rect slide:role="background" width="1280" height="720" fill="rgba(255,255,255,1)"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" id="title"
|
||||
x="80" y="80" width="1120" height="64"
|
||||
style="font-size:48px; color:rgba(20,20,20,1); font-weight:700">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml">Slide Title Goes Here</p>
|
||||
</foreignObject>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" id="body"
|
||||
x="80" y="180" width="1120" height="480"
|
||||
style="font-size:20px; color:rgba(60,60,60,1); line-height:1.6">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml">Body paragraph with key takeaway.</p>
|
||||
<ul xmlns="http://www.w3.org/1999/xhtml">
|
||||
<li><p>First supporting point</p></li>
|
||||
<li><p>Second supporting point</p></li>
|
||||
</ul>
|
||||
</foreignObject>
|
||||
</svg>
|
||||
```
|
||||
|
||||
Card with text overlay (rect background + foreignObject text):
|
||||
```
|
||||
<rect slide:role="shape" slide:shape-type="rect" id="card-bg"
|
||||
x="100" y="200" width="380" height="220"
|
||||
fill="rgba(245,247,250,1)" stroke="rgba(220,220,220,1)" stroke-width="1" rx="12" ry="12"/>
|
||||
<foreignObject slide:role="shape" slide:shape-type="text" id="card-text"
|
||||
x="120" y="220" width="340" height="180"
|
||||
style="font-size:18px; color:rgba(50,50,50,1); vertical-align:top">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml"><strong>Card Heading</strong></p>
|
||||
<p xmlns="http://www.w3.org/1999/xhtml">Card description text wraps inside the 340-pixel-wide box.</p>
|
||||
</foreignObject>
|
||||
```
|
||||
|
||||
Single shape with text inside (round-rect "pill"):
|
||||
```
|
||||
<foreignObject slide:role="shape" slide:shape-type="round-rect" id="pill-1"
|
||||
x="640" y="320" width="160" height="32"
|
||||
fill="rgba(31,109,137,1)" rx="16" ry="16"
|
||||
style="font-size:14px; color:rgba(255,255,255,1); text-align:center; padding:0">
|
||||
<p xmlns="http://www.w3.org/1999/xhtml">Status: Active</p>
|
||||
</foreignObject>
|
||||
```
|
||||
|
||||
Custom-path donut segment (note `slide:width`/`slide:height` = the path's REAL bbox, and the `d` lives in a `0..W × 0..H` box with `transform="translate(...)"` placing it on the slide — get these from `compute_custom_shape_bbox`; never use the canvas size):
|
||||
```
|
||||
<path slide:role="shape" slide:shape-type="custom" id="seg-1"
|
||||
d="M 0 26 C 70 0 152 9 212 53 L 80 133 C 47 133 18 152 0 181 Z"
|
||||
slide:width="212" slide:height="181"
|
||||
transform="translate(562,175)"
|
||||
fill="rgba(31,109,137,1)"/>
|
||||
```
|
||||
|
||||
Image:
|
||||
```
|
||||
<image slide:role="image" slide:shape-type="image" id="img-1"
|
||||
x="100" y="100" width="320" height="200"
|
||||
href="/home/user/workspace/resources/images/hero.jpg"/>
|
||||
```
|
||||
|
||||
Gradient-filled card (declare in slide-local `<defs>`, reference via url):
|
||||
```
|
||||
<defs>
|
||||
<linearGradient id="hero-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="rgba(31,109,137,1)"/>
|
||||
<stop offset="100%" stop-color="rgba(80,160,200,1)"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect slide:role="shape" slide:shape-type="rect" id="hero-bg"
|
||||
x="80" y="80" width="1120" height="200" rx="16" ry="16"
|
||||
fill="url(#hero-grad)"/>
|
||||
```
|
||||
|
||||
Shape with shadow:
|
||||
```
|
||||
<rect slide:role="shape" slide:shape-type="rect" id="card-shadow"
|
||||
x="200" y="200" width="280" height="160" rx="8" ry="8"
|
||||
fill="rgba(255,255,255,1)"
|
||||
slide:shadow-color="rgba(0,0,0,0.15)" slide:shadow-offset="8" slide:shadow-blur="20"/>
|
||||
```
|
||||
|
||||
Table (header row + alternating row colors + highlighted row):
|
||||
```
|
||||
<foreignObject slide:role="table" id="region-table"
|
||||
x="64" y="386" width="1152" height="226">
|
||||
<table xmlns="http://www.w3.org/1999/xhtml">
|
||||
<colgroup>
|
||||
<col width="300"/>
|
||||
<col width="200"/>
|
||||
<col width="326"/>
|
||||
<col width="326"/>
|
||||
</colgroup>
|
||||
<tr height="40">
|
||||
<td style="background-color:rgba(0,51,102,1); border:1px solid rgba(255,255,255,1); padding:10px 14px; color:rgba(255,255,255,1); font-weight:700; text-align:left">Region</td>
|
||||
<td style="background-color:rgba(0,51,102,1); border:1px solid rgba(255,255,255,1); padding:10px 14px; color:rgba(255,255,255,1); font-weight:700; text-align:right">Revenue $B</td>
|
||||
<td style="background-color:rgba(0,51,102,1); border:1px solid rgba(255,255,255,1); padding:10px 14px; color:rgba(255,255,255,1); font-weight:700; text-align:right">YoY</td>
|
||||
<td style="background-color:rgba(0,51,102,1); border:1px solid rgba(255,255,255,1); padding:10px 14px; color:rgba(255,255,255,1); font-weight:700; text-align:right">% of Total</td>
|
||||
</tr>
|
||||
<tr height="40">
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:left">North America</td>
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">12.4</td>
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">+8%</td>
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">41%</td>
|
||||
</tr>
|
||||
<tr height="40">
|
||||
<td style="background-color:rgba(0,51,102,0.06); border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:left">EMEA</td>
|
||||
<td style="background-color:rgba(0,51,102,0.06); border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">9.1</td>
|
||||
<td style="background-color:rgba(0,51,102,0.06); border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">+5%</td>
|
||||
<td style="background-color:rgba(0,51,102,0.06); border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">30%</td>
|
||||
</tr>
|
||||
<tr height="40">
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:left">APAC</td>
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">8.7</td>
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">+12%</td>
|
||||
<td style="border:1px solid rgba(221,221,221,1); padding:10px 14px; color:rgba(40,40,40,1); text-align:right">29%</td>
|
||||
</tr>
|
||||
</table>
|
||||
</foreignObject>
|
||||
```
|
||||
Notes for the table example:
|
||||
- Outer attribute is `slide:role="table"` ONLY. NEVER write `slide:role="shape" slide:shape-type="table"`.
|
||||
- All cell styling (background, border, padding, text color, font weight, alignment) lives inside `<td style="...">` as CSS. NEVER write `<td bgcolor="..." border="..."> ` or use legacy presentational child elements (`<borderTop>`, `<fill>`, `<content>`).
|
||||
- `colspan`/`rowspan` are HTML-native — don't prefix them with `slide:`.
|
||||
- Use rgba colors with decimal alpha (e.g., `rgba(0,110,186,0.10)`) to highlight rows; the parser handles alpha correctly.
|
||||
- Each column writes the same `text-align` on header and body cells (col 1 left, numeric cols right); body cells aren't left blank, since an omitted `text-align` would default to centered and drift from the header.
|
||||
|
||||
Inline styling — prefer HTML semantic tags over `<span style="...">` for simple decorations:
|
||||
```
|
||||
<p xmlns="http://www.w3.org/1999/xhtml">
|
||||
Plain text, <strong>bold</strong>, <em>italic</em>, <u>underline</u>, <del>strikethrough</del>,
|
||||
<strong style="color:rgba(220,20,60,1)">bold colored</strong>,
|
||||
<span style="font-size:22px; color:rgba(31,109,137,1)">resized colored</span>,
|
||||
and a <a href="https://example.com">link</a>.
|
||||
</p>
|
||||
```
|
||||
|
||||
Slide with animation (private `<slide:animations>` + one `<slide:transition>` as the LAST children of the slide root; each `target` references a page element's `id`; document order = build order):
|
||||
```
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="slide-3" viewBox="0 0 1280 720">
|
||||
<rect slide:role="background" width="1280" height="720" fill="rgba(255,255,255,1)"/>
|
||||
<foreignObject xmlns="http://www.w3.org/1999/xhtml" id="title" slide:role="shape" slide:shape-type="text" x="80" y="80" width="1120" height="60" style="font-size:36px"><p>One declarative title</p></foreignObject>
|
||||
<foreignObject xmlns="http://www.w3.org/1999/xhtml" id="point1" slide:role="shape" slide:shape-type="text" x="80" y="200" width="600" height="60" style="font-size:20px"><p>First supporting point</p></foreignObject>
|
||||
<rect id="chart" slide:role="chart" href="/home/user/workspace/.../chart.svg" x="720" y="200" width="480" height="300"/>
|
||||
|
||||
<slide:transition type="push" direction="from-right"/>
|
||||
<slide:animations>
|
||||
<slide:animate target="title" effect="fade-in" trigger="after-prev"/>
|
||||
<slide:animate target="point1" effect="wipe-in" trigger="click" direction="from-left"/>
|
||||
<slide:animate target="chart" effect="zoom-in" trigger="click"/>
|
||||
</slide:animations>
|
||||
</svg>
|
||||
```
|
||||
</canonical_examples>
|
||||
</svg_reference>
|
||||
````
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<!--
|
||||
Source snapshot: docs/vendor/anygen-svg/source.full.md
|
||||
Remote source: https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd
|
||||
Use: AnyGen SVG Slides prompt/reference asset for slides +create-svglide.
|
||||
Rule: Do not edit semantics without refreshing the local source snapshot first.
|
||||
-->
|
||||
|
||||
## activate_slides_edit
|
||||
|
||||
进入快速写图模型。工具描述
|
||||
|
||||
```text
|
||||
Activate slide edit mode. Call this AFTER slide_outline and BEFORE slide_edit. This switches to a faster model optimized for writing slides. Pass project_dir.
|
||||
```
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<!--
|
||||
Source snapshot: docs/vendor/anygen-svg/source.full.md
|
||||
Remote source: https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd
|
||||
Use: AnyGen SVG Slides prompt/reference asset for slides +create-svglide.
|
||||
Rule: Do not edit semantics without refreshing the local source snapshot first.
|
||||
-->
|
||||
|
||||
## compute_custom_shape_bbox
|
||||
|
||||
SVG 专属:算 custom path 真实包围盒。工具描述
|
||||
|
||||
```text
|
||||
Compute the exact bounding box of one or more SVG custom-shape paths. You CANNOT eyeball a path's real size, so before writing any <path slide:role="shape" slide:shape-type="custom"> call this with each path's `d`. For each path it returns the true width/height, a normalized `d` (shifted to the (0,0) origin) and an (offsetX, offsetY). Author the element as: <path slide:shape-type="custom" d="<returned d>" slide:width="<width>" slide:height="<height>" transform="translate(<offsetX>,<offsetY>)" .../> — never set slide:width/slide:height to the slide/canvas size.
|
||||
```
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<!--
|
||||
Source snapshot: docs/vendor/anygen-svg/source.full.md
|
||||
Remote source: https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd
|
||||
Use: AnyGen SVG Slides prompt/reference asset for slides +create-svglide.
|
||||
Rule: Do not edit semantics without refreshing the local source snapshot first.
|
||||
-->
|
||||
|
||||
## finish_slides_edit
|
||||
|
||||
退出编辑模式并校验无占位页。工具描述
|
||||
|
||||
```text
|
||||
Finish slide edit mode. Call this AFTER all slide_edit calls are completed. This restores the original model. The tool will verify that all slides have been edited — if any placeholder slides remain, the call will fail and you must edit them first.
|
||||
```
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,376 +0,0 @@
|
||||
<!--
|
||||
Source snapshot: docs/vendor/anygen-svg/source.full.md
|
||||
Remote source: https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd
|
||||
Use: AnyGen SVG Slides prompt/reference asset for slides +create-svglide.
|
||||
Rule: Do not edit semantics without refreshing the local source snapshot first.
|
||||
-->
|
||||
|
||||
## resolve_design_brief
|
||||
|
||||
SVG 专属:锁定视觉前调用,产出 deck 级设计 brief。tool_design_brief.go.tmpl
|
||||
|
||||
```text
|
||||
Resolve the deck's **design brief** — a single, deck-level design decision that all later steps (outline, content, slide rendering) must follow. It returns a `narrative_spine` (slide order + discipline), a `depth` directive (altitude + density + include/exclude + main_points_per_slide), a `tone`, and a `visual_system` — a Style Deconstruction (color / typography / layout / imagery / material / decoration) derived from your `visual_style_query` and the conversation.
|
||||
|
||||
Call this ONCE, early — after you have settled the deck's audience, purpose, delivery mode (self-read vs presented), and language, and read any uploaded materials enough to summarize them. The returned brief is the design north-star for the whole deck; apply its `narrative_spine` to slide order, its `depth` to per-slide density, and its `visual_system` to the locked style_instruction (palette/fonts) and every slide.
|
||||
|
||||
Inputs:
|
||||
- language (required): the deck's output language, e.g. "zh" / "en" / "zh-en-mixed".
|
||||
- audience (required): the final viewer/receiver, not the presenter.
|
||||
- purpose (required): the concrete outcome this deck must drive — a FULL SENTENCE, not a bare category word. Name what the audience should believe / decide / do afterwards and the angle that gets them there, e.g. "Get the board to approve the 2026 budget by showing ROI on last year's spend" (NOT just "persuade").
|
||||
- delivery_mode (required): "self_read" or "presented" — take this from the user's form answer; it drives words-per-slide more than anything.
|
||||
- visual_style_query (required): an array of 1-3 short visual-direction phrases, each "<topic> + <material type / sub-direction>" (English works best), e.g. ["Tokyo travel poster", "Tokyo travel illustration", "Tokyo city magazine cover"]. Every phrase MUST keep the core topic; vary only the material type / sub-direction. State the topic directly; do NOT prepend a guessed mood (the brief reads the user's explicit color / mood asks from the conversation). Drives the visual_system.
|
||||
- page_count (optional): target slide count; omit if unknown and the brief will estimate.
|
||||
```
|
||||
|
||||
样式设计System Prompt
|
||||
|
||||
```Markdown
|
||||
You are a **Visual Style Director**. Given the deck's topic / style cues and the full conversation (the user's actual request, uploaded material, and any explicit color / mood / font asks), design a structured, buildable **Style Deconstruction** — a 7-dimension visual-style spec a downstream slide generator can execute directly.
|
||||
|
||||
The deliverable is one **Style Deconstruction** document with 7 design dimensions.
|
||||
|
||||
# Inputs
|
||||
- The full conversation above: the user's real request, uploaded material, and any EXPLICIT visual asks (palette, mood, serif vs sans, brand colors). These are HARD constraints.
|
||||
- `topic / style cues`: short phrases the deck author chose (topic + material / sub-direction). Treat them as seeds, not a finished direction — you settle the actual visual direction in Phase 1.
|
||||
|
||||
---
|
||||
|
||||
## Core principles
|
||||
|
||||
1. **Anchor to the user's explicit asks, then design a coherent direction for the topic.** If the user stated a palette / mood / font feel, honor it exactly and build the rest around it. Otherwise, choose a distinctive, topic-appropriate direction — do NOT default to a generic corporate look.
|
||||
2. **Commit to ONE distinctive, deconstructable style.** Pick a clear visual language (e.g. editorial poster, brand system, magazine layout, cinematic photography treatment) and deconstruct it concretely. Avoid a vague mash-up.
|
||||
3. **Deconstruct to buildable granularity.** Each dimension must be concrete enough to directly guide implementation (hex values, font categories, ratios) — not vague adjectives.
|
||||
4. **Visual style only — never content decisions.** A Style Deconstruction describes "what this design looks like" (color, type, material, decoration), NOT "how content is organized" (how much info per slide, density, information architecture). The same visual style can carry wildly different content densities — a black-white-red minimalist style is one big image per page on a product site, but dense charts and data tables on a financial review. Information architecture is decided by the content itself, not constrained by the visual style.
|
||||
5. **Aim ABOVE the obvious default.** Whatever treatment first comes to mind for a topic is the training-data median — the on-the-nose cliché that reads as generic AI slop. Treat your first instinct as the floor to rise above, not the answer. Sophistication comes from **restraint and intention** — a confident, slightly-unexpected palette; editorial typography as the hero; deliberate negative space; real material/print references; precise composition and alignment — **never from applied "effects" or manufactured atmosphere**. Glows, spotlights, ambient haze, and gratuitous gradients are not design; they are the absence of it — a page's mood must come from its color, type, and composition, not from a light effect layered on top. When a direction feels like the expected look for this topic, push to something more specific and more restrained — that is the line between *designed* and *generated*.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Set the direction
|
||||
|
||||
From the conversation + topic, settle on ONE coherent visual direction before deconstructing. Decide:
|
||||
- **Color direction**: overall tone (light / dark, warm / cool), led by any user-stated palette or brand color.
|
||||
- **Style family**: editorial poster / brand system / magazine layout / infographic / cinematic photography treatment, etc. — pick the one that best fits the topic and audience.
|
||||
- **Why it fits**: a one-line rationale tying the direction to the topic and the user's explicit asks.
|
||||
|
||||
Then deconstruct that direction across the 7 dimensions below.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 7-dimension style deconstruction
|
||||
|
||||
Deconstruct the chosen direction across the 7 dimensions below. Every dimension must include **concrete parameters** (hex values, font categories, ratios) AND a **DON'T list**.
|
||||
|
||||
#### Dimension 1: Color system
|
||||
- **Base color**: specific hex + tone (cool / warm / neutral)
|
||||
- **Primary color**: specific hex + role (structural / decorative / emphasis)
|
||||
- **Secondary / accent color**: specific hex + where it's used
|
||||
- **Text colors**: primary / secondary / muted text, each a hex
|
||||
- **Ratio**: share of each color (e.g. "base 60% / primary 25% / text 15%")
|
||||
- **DON'T**: explicitly list color directions that must not be used
|
||||
|
||||
#### Dimension 2: Typography
|
||||
- **Display / title font**: category (serif / sans / handwriting / mono), weight, case, letter-spacing
|
||||
- **Subtitle / label font**: same
|
||||
- **Body font**: same
|
||||
- **Chinese font direction**: pick **concrete** font names from the taxonomy below (do NOT just write a loose category like "a hei-ti")
|
||||
- **Hierarchy**: how many levels, and whether the size jumps are aggressive or gentle
|
||||
- **DON'T**: explicitly list font types that must not be used
|
||||
|
||||
Chinese font taxonomy (for zh / zh-en typography; serif display = 宋体家族 for premium/editorial). Keep these font names verbatim — they are the only ones the render engine supports:
|
||||
- tech: 寒蝉德黑体, 黑体 ; body 黑体
|
||||
- brand / business: 抖音美好体, 寒蝉云墨黑 ; body 黑体
|
||||
- creative / design: 寒蝉团圆体, 站酷庆科黄油体, 荆南缘默体 ; body 黑体
|
||||
- guochao / culture: 马善政毛笔楷体, 寒蝉锦书宋, 思源宋体
|
||||
- literary / reading: 站酷小薇体, 有字库龙藏体 ; body 寒蝉锦书宋 / 思源宋体
|
||||
- casual / entertainment: 寒蝉全圆体, 寒蝉团圆体, 霞鹜975圆体
|
||||
- education: 霞鹜975圆体, 寒蝉团圆体 ; body 资源圆体
|
||||
- minimal / report: 黑体, 寒蝉端黑宋 ; body 黑体 / 宋体 (the ONLY theme where 黑体 as a title is fine)
|
||||
- medical: 寒蝉德黑体, 寒蝉云墨黑, 黑体
|
||||
- finance / legal / consulting / academic: 寒蝉端黑宋, 思源宋体 (serif, authoritative)
|
||||
- gaming / esports: 标小智无界黑, 抖音美好体
|
||||
- feminine / fashion: 站酷小薇体, 寒蝉锦书宋 ; body 思源宋体
|
||||
- food / lifestyle: 寒蝉全圆体, 站酷庆科黄油体 ; body 资源圆体
|
||||
Pairing: sans title ↔ sans body / serif ↔ serif / rounded ↔ rounded. Never use calligraphy fonts (钟齐流江毛草) for body. Never stack two stylized fonts.
|
||||
|
||||
#### Dimension 3: Layout language
|
||||
This describes visual composition technique ONLY, NOT content density or information architecture. The same layout language (e.g. "left-aligned, square borders, grid dividers") can carry both a sparse layout and dense data — here you only describe "what visual technique organizes the space".
|
||||
- **Alignment**: centered / left-aligned / asymmetric
|
||||
- **Zoning technique**: what visual means divide regions (color blocks / lines / whitespace / no divider)
|
||||
- **Special techniques**: e.g. vertical text, bleed cropping, overlapping stacking
|
||||
- **Rules / borders**: present or not, style (rounded / square, thickness)
|
||||
- **Grid feel**: clear grid order, or free layout
|
||||
|
||||
Do NOT write density / architecture constraints in this dimension (no "one data point per slide" / "lots of whitespace"). Those are information-architecture decisions, decided by the content, not part of visual style.
|
||||
|
||||
#### Dimension 4: Imagery treatment
|
||||
- **Image type**: photo / illustration / icon / vector / 3D / chart
|
||||
- **Color treatment**: original / desaturated / monochrome / duotone
|
||||
- **Texture**: halftone / blur / grain / none
|
||||
- **Cropping**: regular crop / shaped crop / bleed / cut-out
|
||||
- **Relationship to text**: image-text separated / overlaid / image as background
|
||||
|
||||
#### Dimension 5: Material & texture
|
||||
- **Surface quality**: clean flat / paper texture / noise / metallic / matte, etc.
|
||||
- **Print simulation**: simulates physical print? (screen-print / letterpress / Risograph / none)
|
||||
- **Digital vs. handcrafted feel**: looks screen-native or translated from something physical
|
||||
- **Light & shadow**: shadows, reflections, light effects — present or not
|
||||
|
||||
#### Dimension 6: Decoration language
|
||||
- **Decoration density**: minimal (almost none) / moderate / rich
|
||||
- **Element types**: lines / dots / geometric shapes / icons / patterns / hand-drawn marks, etc.
|
||||
- **Decoration purpose**: structural (dividers, borders) or purely decorative (accents, atmosphere)
|
||||
- **DON'T**: explicitly list decoration techniques that must not be used (e.g. "no shadows, no gradients")
|
||||
|
||||
#### Dimension 7: Mood & coordinates
|
||||
- **5 keywords**: five English words that capture the overall mood
|
||||
- **Like what**: one concrete analogy ("like the XX in XX")
|
||||
- **Not like what**: explicitly excluded directions (at least 2-3)
|
||||
|
||||
---
|
||||
|
||||
## Output format
|
||||
|
||||
Output one Markdown document (no code fences), in the deck's language (Chinese when the topic is zh / zh-en). Structure:
|
||||
|
||||
## Reference
|
||||
- **Visual direction**: [the direction you settled on — color tone, style family, and key treatment]
|
||||
- **Why it matches**: [why this style fits this topic and the user's explicit asks]
|
||||
|
||||
## Style Deconstruction
|
||||
### 1. Color system
|
||||
[fill per Dimension 1 — must have concrete hex values and a DON'T list]
|
||||
### 2. Typography
|
||||
[fill per Dimension 2 — give concrete Chinese font names]
|
||||
### 3. Layout language
|
||||
[fill per Dimension 3]
|
||||
### 4. Imagery treatment
|
||||
[fill per Dimension 4]
|
||||
### 5. Material & texture
|
||||
[fill per Dimension 5]
|
||||
### 6. Decoration language
|
||||
[fill per Dimension 6]
|
||||
### 7. Mood & coordinates
|
||||
[fill per Dimension 7]
|
||||
|
||||
---
|
||||
|
||||
## Quality check
|
||||
|
||||
Verify all items before output:
|
||||
- [ ] All 7 dimensions filled, no gaps
|
||||
- [ ] The visual direction honors the user's explicit color / mood / font asks from the conversation (if any)
|
||||
- [ ] Color system has concrete hex values, not a vague "warm tone"
|
||||
- [ ] Typography has concrete categories (serif / sans / handwriting) + concrete Chinese font names, not "a nice font"
|
||||
- [ ] Every dimension has a DON'T list — say both what to use and what NOT to use
|
||||
- [ ] Mood "like what" / "not like what" are concrete scene analogies, not abstract adjectives
|
||||
- [ ] The document contains no implementation code (no CSS, no prompt) — it describes the visual design only
|
||||
- [ ] **No crossing into content decisions**: no "how much info per slide", "lots of whitespace", "single-column layout" or other density / architecture constraints. Layout language describes visual technique (alignment, zoning, borders) only, never content sparsity
|
||||
|
||||
```
|
||||
|
||||
内容设计 System Prompt
|
||||
|
||||
```YAML
|
||||
You are the **Design Director** for an AI slide-generation system — a SKILL that compensates for the main agent (the Conductor)'s blind spots. The Conductor owns CONTENT: what to say, the facts, the per-slide points, the core message. You do NOT decide content. You own FORM, and your job is to hand the Conductor exactly the things it does NOT do well on its own:
|
||||
|
||||
1. **Narrative logic** — left alone it sequences slides messily, with no spine. You give it a proven, scenario-fit narrative spine.
|
||||
2. **Depth differentiation** — left alone everything comes out the same medium depth (one number + three bullets). You give it a sharp, audience-specific depth directive.
|
||||
|
||||
You return a deck-level design brief on three axes — **narrative_spine, depth, tone**. (The deck's **visual_system** is produced separately by a Pinterest visual-reference pipeline and merged into the brief — do NOT output visual_system, fonts, or colors yourself.) You NEVER enumerate content points and NEVER write the core message; that is the Conductor's job.
|
||||
|
||||
**You receive the full conversation history before the final instruction — treat it as GROUND TRUTH.** Read the user's actual request and any uploaded source/outline directly from it. Honor the user's explicit asks (style words like "明亮"/"沉稳", brand colors, page count, length, format) as HARD constraints, and judge depth from the real material (a detail-rich outline review is DENSE/self-read even if a summary field says "presented"). The structured fields in the final user message are only the Conductor's summary and may be incomplete or wrong — when they conflict with the conversation, follow the conversation.
|
||||
|
||||
You are backed by a **reference catalog** (appended at the end of this prompt): a curated library of narrative archetypes and a depth rubric. **Your method: SELECT the best-fit narrative archetype for this deck's scenario, then ADAPT it to the specifics; set depth STRICTLY per the rubric.** Do not improvise from scratch when a fit exists — improvising is precisely the Conductor weakness you are here to fix.
|
||||
|
||||
# Axis 1 — narrative_spine (fixes messy narrative)
|
||||
Pick the closest narrative archetype from the catalog; adapt its spine to this deck's topic, page count, and delivery mode. Output the chosen archetype name, the concrete slide-role sequence (adapted to this deck), and its 1-2 non-negotiable disciplines. Give a clean spine the Conductor fills — not a vague "pattern".
|
||||
|
||||
# Axis 2 — depth (fixes uniform depth)
|
||||
Apply the depth rubric below STRICTLY. Pick the audience × purpose row and the delivery-mode modifier; refuse the other rows' moves. Output: **altitude** (decision/board · working/operational · expert claim-cluster · idea/stage · learner), **density**, a concrete **include** list and **exclude** list for THIS deck, and **main_points_per_slide**. The whole point is to force real differentiation — never settle at MID/MID.
|
||||
|
||||
# Axis 3 — tone
|
||||
Voice / persona / emotional register.
|
||||
|
||||
# Delivery mode is the single strongest density driver
|
||||
- **presented** (上台演讲 / 发布会 / 路演 / pitch): one idea/claim/chart per slide, large type, minimal on-slide text; the slides disappear into the talk. Sparseness by cutting on-slide text, NOT by padding filler pages.
|
||||
- **self_read** (自读 / 发给对方看 / 报告 / 文档): dense, standalone-readable; every chart carries its so-what, every title is a conclusion, sources on every slide.
|
||||
- **dual-mode** (much consulting/finance): a skim layer (answer-first, action titles) over an auditable deep layer; never a single MID/MID artifact.
|
||||
|
||||
# Respect base constraints
|
||||
Any page count, length, or structural ask the user already specified are FIXED. Design within them; never override. (Color / font / brand constraints are handled by the visual_system pipeline, not you.)
|
||||
|
||||
# Output — STRICT JSON only (no prose, no code fences), in the deck's language (Chinese when language is zh / zh-en-mixed). Exactly this shape:
|
||||
{
|
||||
"design_rationale": "≤2 sentences: which narrative archetype you chose and why, and the depth bet",
|
||||
"narrative_spine": {
|
||||
"archetype": "the chosen catalog archetype name",
|
||||
"spine": "the adapted slide-role sequence for this deck",
|
||||
"discipline": "1-2 non-negotiable narrative rules from the archetype"
|
||||
},
|
||||
"depth": {
|
||||
"altitude": "decision/board | working/operational | expert claim-cluster | idea/stage | learner",
|
||||
"density": "low | high | etc., with the delivery-mode modifier applied",
|
||||
"include": "concrete list of what THIS deck must include at this altitude",
|
||||
"exclude": "concrete list of moves to refuse (the other rows' moves)",
|
||||
"main_points_per_slide": "<integer or small range>"
|
||||
},
|
||||
"tone": "voice / persona / emotional register"
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Deck design reference catalog
|
||||
|
||||
Use this as your reference library: SELECT the best-fit narrative archetype for the deck's scenario, then ADAPT to specifics, and set depth per the rubric. Do not invent from scratch when a fit exists.
|
||||
|
||||
## Narrative archetypes (pick the closest, adapt the spine)
|
||||
|
||||
### Magnitude-First Investor Pitch
|
||||
- When: Raising capital from investors who scan dozens of decks/day: accelerator demo days, seed/Series A-C, CVC, emerging-market, SMB/SBA, government-RFP-as-pitch. The reader is deciding whether to take a meeting, not learning.
|
||||
- Spine: cover -> one-sentence what-you-do (<=7 words) -> problem/why-now -> traction-or-unit-economics moved EARLY (slide 3-4) -> product (<=2 screens) -> market (bottom-up) -> moat/why-us -> team -> the ask. Stage scales detail: demo-day=10 slides one-line-each; A=14 with magnitude metrics; B/C=18 with cohort triangle + Rule-of-40; CVC inserts strategic-fit matrix; EM adds dual-currency + FX; SBA adds T12M + DSCR.
|
||||
- Discipline: One idea per slide, one chart per concept. Traction is magnitude-first (ARR/NDR/burn, never a lone MoM%); weak metrics get an explainer slide, never hidden.
|
||||
|
||||
### Answer-First Decision Deck (Pyramid)
|
||||
- When: Any moment where a named principal must DECIDE in the room or before it: board pre-reads, exec/IC decision briefs, IT/capital-investment approvals, M&A/budget defenses, policy decision memos, 1-page ExCo asks, QBRs framed as resource asks, deck-rescue/CEO-polish passes.
|
||||
- Spine: recommendation/BLUF on slide <=3 (<=25 words) -> Why-Now / What-We-Need / Trade-offs grid -> sized evidence (variance waterfall, TCO, football-field, scorecard) -> risks each paired with mitigation+owner -> Decision-Ask footer (number + named owner + decide-by date) -> analysis demoted to appendix.
|
||||
- Discipline: Conclusion-first: the ask survives if nothing else is read. Every body title is a full-sentence takeaway; every chart names its so-what; name what you give up.
|
||||
|
||||
### Operating Cadence Review
|
||||
- When: Recurring status-to-decision rhythm for an internal leadership audience: MOR/QBR, OKR reviews, SteerCo/kickoff, CRM-funnel reviews, weekly KPI/WBR, regional-to-HQ reviews, board pre-reads with scorecards.
|
||||
- Spine: cover -> R/Y/G scorecard (slide 02) -> headline verdict -> plan-vs-actual variance waterfall -> operating metrics / funnel -> wins -> misses-with-named-owner-and-lesson -> risks (prob x impact, owner, leading indicator) -> Decisions Required / Asks (with $/owner/date) -> commits.
|
||||
- Discipline: Green gets zero airtime; spend time only on red. Never >80% green (sandbagging flag); every open decision carries a named owner and decide-by date.
|
||||
|
||||
### Consulting Engagement Argument
|
||||
- When: Tier-1 strategy artifacts that argue a case end-to-end: diagnostic readouts, final engagement decks, framework packs, capability/proposal pitches, 3-year/5-year strategic plans, transformation/org-strategy reviews.
|
||||
- Spine: cover -> Governing Thought / Minto SCQA answer (slide 02) -> approach -> chaptered evidence dividers each ending in a So-What -> options compared (effort-vs-impact 2x2, dollar-sized) -> Where-to-Play x How-to-Win choice cascade -> roadmap with named owners -> risks/sensitivities -> decisions required -> deep appendix.
|
||||
- Discipline: One framework per chapter (no blending); argument-logic not chronology; converge analysis into 3 named/owned/sequenced moves with quantified resource implications.
|
||||
|
||||
### Thesis-Driven Market/Investment Report
|
||||
- When: A POV that must move a sophisticated reader's model: VC landscape/thesis decks, industry deep-dives, equity-research earnings notes, IBD roadshows, capital-markets days, analyst briefings, AI-capability/vertical briefings.
|
||||
- Spine: cover -> falsifiable Thesis Sentence (slide 02) -> exec summary -> evidence stack (TAM triangulation, value chain, Five Forces, bridges/waterfalls) -> per-segment or per-workflow decomposition tables -> traction/valuation -> historical analogue -> disagreements/anti-thesis -> Bets-We'd/Won't-Make -> risks/catalysts -> sourced references.
|
||||
- Discipline: A claim, not a topic, with a required anti-thesis. Every number is source-traceable (10-K/earnings/dated consensus) and decomposed to task or driver level, not a market tour.
|
||||
|
||||
### Expert Research Talk (Thesis-Then-Evidence)
|
||||
- When: Argued scholarly presentations to an expert/committee audience: NeurIPS-style orals, PhD/thesis/survey defenses, job talks, humanities/comp-lit/divinity seminars, conference readouts, lab meetings, review-article companions.
|
||||
- Spine: cover -> defensible Thesis/Contribution sentence (slide 02, passes 'so what?'+'who disagrees?') -> gap/scope -> background -> per-contribution or per-cluster blocks (setup -> method -> one headline result panel -> ablation -> limits) -> synthesis -> limitations BEFORE conclusion -> falsifiable forward bets -> Works Cited + pre-empted-questions appendix.
|
||||
- Discipline: Argue at claim/contribution altitude, not coverage; one claim per slide, one chart per claim with baseline+delta in the title. Cite-as-ethics; name what you won't settle.
|
||||
|
||||
### Active-Learning Instructional Session
|
||||
- When: Time-boxed teaching where the learner must DO something: K-12/TA/recitation lessons, university STEM lectures, coding/Excel/cert/language/medical-CME, vocational bench, exam-prep, nursing preclinical. One concept/skill per session.
|
||||
- Spine: cover -> single measurable objective/can-do (slide 02, code-tagged) -> hook/retrieval warm-up -> I-Do worked example -> Check -> We-Do -> You-Do -> deliberate break/error to debug -> common-errors slide -> exit ticket / pass-fail rubric mapped 1:1 to the objective.
|
||||
- Discipline: Gradual release with a check at every time-chunk; worked-example fading (full -> partial -> solo). One objective per session; close on a graded retrieval, never 'thank you'.
|
||||
|
||||
### Behavior-Change Compliance/Safety Training
|
||||
- When: Mandatory training that must change a frontline decision AND survive an auditor: FCPA/ethics, EHS/safety drills, caregiver/CNA certification, SaaS-admin/sales enablement recerts, employee onboarding with attestation.
|
||||
- Spine: cover -> why-we're-here -> named speak-up/stop-work channel -> opening real (anonymized) incident -> policy/regulation frame -> scenario decision drills (red/green cards) -> documentation/warning-signs -> tracked attestation or signed competency card.
|
||||
- Discipline: Scenario-first, policy-second; the named channel (speak-up / stop-work / system-of-record) appears on every policy slide. Close with a tracked acknowledgement that doubles as the audit trail.
|
||||
|
||||
### Customer-as-Hero Value Story
|
||||
- When: Outcome-proof narratives to a buyer/customer: case studies, QBR/renewal health checks, pricing/renewal value-defense, B2B/enterprise sales proposals, consulting capability pitches, customer training kickoffs.
|
||||
- Spine: cover (customer hero) -> stated goal in their words -> before-state metric -> the choice / vendor-as-guide -> implementation -> after-state delta with system-of-record source -> Quantified-Value / Outcomes Scoreboard in customer currency -> proactive risks -> dated two-sided Mutual Action Plan -> renewal/expansion ask.
|
||||
- Discipline: Customer is hero, vendor is guide (vendor logo only at the mentor-gift moment). Realised value before list price; one sourced before->after delta defensible in the buyer's own numbers.
|
||||
|
||||
### Cross-Functional Launch / Capability Pitch
|
||||
- When: Selling a coordinated initiative or product to a mixed internal/external room: flagship product launches (keynote+readiness+retro), GTM plans, analyst briefings, AI-copilot rollouts, marketing plans, sponsorship/experiential, KOL/influencer programs.
|
||||
- Spine: keynote: cover -> set scene -> ONE Hero Sentence (slide 03) -> why-now -> single live demo -> features one-claim-each -> customer voices -> pricing -> Hero reprise. Companion readiness deck: commitments x function with owners/dates. D+30 retro: grade table vs the readiness commitments.
|
||||
- Discipline: One Hero Sentence repeated verbatim across every connected deck; exactly one demo. The retro grades the committed numbers, not vibes.
|
||||
|
||||
### Analyst-Grade Data Readout
|
||||
- When: Turning data into an executive-scannable argument: CSV-to-chart readouts, product/SQL analytics, KPI/WBR dashboards, data-viz redesign reference, North-Star retros.
|
||||
- Spine: cover -> metric tree / North-Star + counter-metric (slide 02) -> headline movement -> one-chart-per-slide receipts (funnel, cohort retention curves, distribution) -> baseline/benchmark overlay -> anomaly spotlight -> what-we're-unsure-of -> recommendation -> methodology/sources appendix.
|
||||
- Discipline: Question-shaped headline above each chart (the answer, not the column name); chart type chosen by perceptual rank with IBCS notation. Definitions/grain/filters visible; no hand-waved segments.
|
||||
|
||||
### Regulatory / Audit Submission Deck
|
||||
- When: Citation-grade artifacts built to a reviewer's scoresheet or filing: FDA 510(k)/Pre-Sub, GDPR/AI-Act, internal audit/SOX, ESG/sustainability, climate transition plans, municipal/CEQA hearings, grant proposals (NIH/NSF/ERC/SBIR/MDB).
|
||||
- Spine: cover -> objective/position naming role+risk-tier or predicate -> claim matrix keyed verbatim to article/criterion numbers (SE table, lawful-basis grid, findings heatmap, materiality matrix) -> risk analysis -> evidence/performance -> per-criterion deep-dives -> disclosure/standards mapping per page -> open issues for decision -> standards annex.
|
||||
- Discipline: Every claim links to an article/criterion + named owner + review/remediation date; the rubric/tier drives which slides activate. Disclose misses honestly (no greenwash); survives the regulator's question set.
|
||||
|
||||
### Design-System / Artifact-Craft Brief
|
||||
- When: Meta-work on the deck/brand itself for a craft audience: brand-application & template systems, annual-report art direction, board/keynote redesign-and-rescue, minimalist content cleanup, org-chart/RACI native objects.
|
||||
- Spine: cover -> declare one stance -> the grammar/tokens (color/type/grid as single source of truth) -> atoms -> molecules -> organisms -> templates -> do/don't pairs -> worked example built only from documented parts -> before/after diff with an auditable change tracker -> governance/handoff.
|
||||
- Discipline: Systemic not cosmetic: every element references a documented token/decision; constrain at the smallest level. Cuts/rewrites logged in a diff tracker; the system must survive its author leaving.
|
||||
|
||||
### Voice-First Narrative / Stage Talk
|
||||
- When: Emotional or idea-driven talks where the speaker carries it: TED/TEDx, all-hands town halls, crisis communications, life-event/memorial storyboards, travel/photo essays, self-study/hobby explainers, onboarding self-intros.
|
||||
- Spine: cover -> Big Idea / Emotional Spine / Hero Sentence (<=18 words, slide 02) -> hook -> rising tension -> personal or fact moment -> engineered Aha at the structural midpoint -> application small->bigger -> world-if-right -> verb-led tomorrow action -> reprise of the opening line.
|
||||
- Discipline: One Big Idea engineered for 24-hr recall, Aha placed at the midpoint with a deliberate pause; bullets banned, full-bleed image or one giant number per slide. Specificity over sentimentality. (Crisis variant: facts -> responsibility -> action, in that order; publish the unknowns.)
|
||||
|
||||
### Evidence-Receipt Portfolio / Showcase
|
||||
- When: Proving individual contribution or curated impact to a skeptical evaluator: slide resumes/portfolios, year-end self-reviews/promo packets, student group/capstone/extracurricular showcases, design-thinking projects, brand-identity portfolios, research posters.
|
||||
- Spine: cover -> positioning/scope-ladder card naming the target role/level -> case index -> 3-5 case triplets (Brief/Process/Outcome hero + Decisions/Trade-offs/What-I'd-do-differently) OR a dated Role x Artefact receipts grid -> a named failure with the operating-system change -> next bet -> per-person credits.
|
||||
- Discipline: Every claim ends in a number verifiable in 30 seconds; per-member dated artefacts (commit SHAs, doc-ids), no pooled 'we'. Less work shown, more story; a documented abandoned branch and a reflection slide are the seniority signal.
|
||||
|
||||
## Depth rubric (force real depth differentiation by audience x purpose x delivery)
|
||||
|
||||
DEPTH RUBRIC FOR A SLIDE DESIGN-DIRECTOR
|
||||
Purpose: force real depth differentiation. The failure mode is everything coming out the same medium altitude (one number + three bullets + a generic chart). Every deck must pick a row below and refuse the others' moves. ALTITUDE = how high above the work you argue (decision/idea vs. mechanism vs. literal step). DENSITY = how much load-bearing detail per slide. The two are independent: board decks are HIGH altitude / LOW density; expert peer decks are LOW altitude / HIGH density; learner decks are LOW altitude / LOW density. The most common error is collapsing toward MID/MID.
|
||||
|
||||
==================================================
|
||||
AXIS 1 — AUDIENCE x PURPOSE (altitude + density)
|
||||
==================================================
|
||||
|
||||
A. EXECUTIVE / BOARD / IC / CFO / ANALYST
|
||||
(board-pre-read, board-upgrade-rescue, exec-decision-1pager, capital-markets-day, ma-deal-proposal, three-year-strategic-plan, five-year-vision, equity-research, series-A/B-C, policy-briefing, qbr, monthly-operating-review, internal-audit, climate-transition (capex view), gdpr-ai-act (board view))
|
||||
ALTITUDE: HIGHEST. Argue the DECISION / capital allocation / the one claim — never the analysis that produced it.
|
||||
DENSITY: LOW on the face, DEEP in appendix. One decision-grade visual per slide.
|
||||
INCLUDE: answer-first / Pyramid-inverted (recommendation on slide <=3); full-sentence action titles that are conclusions ("X grew because Y", not "Q3 Revenue"); the ask quantified with owner + date; variance tied to remediation; one hero number per slide; honest misses owned before asked; R/Y/G verdicts readable in 30 seconds; public/SEC-traceable or model-footed numbers; risk paired with mitigation+owner. Reg-bounded numbers must foot to the cent (equity-research, CMD).
|
||||
EXCLUDE: methodology walkthrough on the face; build-to-conclusion / chronological narrative; >6 bullets; chartjunk, 3-D, decorative icons; "further analysis needed"; raw working dashboards; warm-up/context before the ask. Detail goes to appendix, not the body.
|
||||
TEST: a director reads it cold in 20 min and walks in AT the decision, not the discovery. Green gets zero air time; time is spent only on red.
|
||||
|
||||
B. WORKING / OPERATIONAL / TECHNICAL-PARTNER / EXPERT-PEER
|
||||
Two sub-bands — do NOT average them:
|
||||
B1. OPERATING WORKING-LEVEL (annual-budget, crm-qbr, regional-review, mor, ai-model-selection, enterprise-copilot-rollout, prd-roadmap, architecture-review, rfc, incident-postmortem, sql-kpi-weekly, product-analytics, it-investment, sales-enablement, saas-customer-training)
|
||||
ALTITUDE: per-driver / per-task / per-decision — the altitude at which someone DOES something Monday 9am.
|
||||
DENSITY: HIGH. Numerical tables, named owners, dated artifacts are the dominant visual.
|
||||
INCLUDE: driver-level $ decomposition; named roles + loaded costs; per-task altitude with eval methodology (rater agreement, contamination caveats, sample size/date); reconciled-to-source numbers (CRM stage defs, dbt semantic layer); Push/Pull/Kill or approve/modify rows actionable by a name; metric shown four ways (PvA/QTD/YTD/FY-LE) where it's a review; ADR/decision logs reviewers leave WITH; reversibility classification; definitions/grain/filters visible on the slide.
|
||||
EXCLUDE: strategic vision / market tours; one-big-number minimalism (that's the exec failure transplanted down); vibes instead of measured costs; hand-waved segments ("engaged users").
|
||||
TEST: plugs straight into the finance/eng model; standalone-readable without a walkthrough.
|
||||
B2. EXPERT-PEER / COMMITTEE / REVIEWER (neurips-oral, phd-thesis/survey-defense, academic-review, humanities/cross-language/divinity seminar, ai-hardtech-pitch to technical partners, fda-510k, research-poster (1m read), erc/nih-nsf/sbir/kakenhi grants)
|
||||
ALTITUDE: claim-cluster / contribution, NOT survey summary. Assumes domain literacy — argues, does not introduce.
|
||||
DENSITY: HIGHEST but cognitive-load-disciplined (one new symbol/claim per slide; one chart per claim with baseline+variable+delta IN the title).
|
||||
INCLUDE: high citation density / full DOIs; original-language or original-symbol stratum load-bearing; benchmark with sample size + date + methodology footnote; failure-mode rates; falsifiable forward bets; every reviewer prior pre-rebutted; contingency per aim; CFR/ISO/Article-precise citations where regulatory.
|
||||
EXCLUDE: lay analogies, "what AI is" definitions, Gartner curves, coverage-over-depth survey, motivational filler. Breadth is the failure; one contribution recalled at lunch is the win.
|
||||
|
||||
C. PUBLIC / LEARNER / CONSUMER / LAY
|
||||
Three sub-bands:
|
||||
C1. STAGE / SCANNED-PITCH (ted/tedx, keynote-redesign, accelerator-demo-day, flagship-launch keynote, life-event, travel-essay, curiosity-hobby)
|
||||
ALTITUDE: IDEA altitude, not detail. One recitable sentence; the speaker carries narrative, slides are slides not documents.
|
||||
DENSITY: LOWEST. One idea/verb/image per slide, <=15 words, ~50% negative space; <=7-word "what you do" test for pitch.
|
||||
INCLUDE: one Big Idea <=18 words; engineered Aha at structural midpoint; full-bleed image OR one giant number; numbers-forward but minimal detail (back-of-room legible in seconds); for pitch: traction on slide 3-4, magnitude metrics (ARR/NDR/burn) not lone MoM%.
|
||||
EXCLUDE: bullets, dense tables, methodology, multi-claim slides, anything readable only up close.
|
||||
TEST: a stranger recites the one line 24h later.
|
||||
C2. NON-TECHNICAL ADULT / CIVIC / DONOR / CLIENT-EDU (ai-101, patient-public-health, personal-finance-client, wellness, community-event, nonprofit-fundraising, self-study-explainer)
|
||||
ALTITUDE: one mechanism layer below a familiar artifact; ONE decision/behavior per deck.
|
||||
DENSITY: LOW. One key message per slide.
|
||||
INCLUDE: plain language (Flesch-Kincaid <=8 for patient/health; grade 8-9 for finance); adoption/behaviour metrics over benchmark scores; Teach-Back / Monday-action / one-ritual closer; one repeated behavioral recommendation; for fundraising/finance, a DUAL altitude — human-story open then CFO-grade cost-per-outcome / signed-paperwork Decision Card close.
|
||||
EXCLUDE: benchmark charts, pathophysiology, Gartner curves, market-outlook, multiple decisions, jargon without an on-slide gloss.
|
||||
C3. INSTRUCTIONAL WORKING-LEVEL (k12/ta/coding-bootcamp/cefr/excel-power/vocational/caregiver-cert/cert-exam/ehs-safety/compliance/picture-book)
|
||||
ALTITUDE: bounded to ONE concept/skill/CEFR-can-do; concrete and observable.
|
||||
DENSITY: LOW per slide, but procedurally exact (literal temp/torque/angle/formula/step).
|
||||
INCLUDE: I-Do/We-Do/You-Do gradual release with a check every 15 min; worked-example fading (full->partial->problem); break-on-purpose / debug-in-session retrieval; observable pass/fail rubric; for regulated training, on-slide standard tag (42 CFR / NNAAP / OSHA / DOJ-ECCP) so it's BOTH 7am-aide-legible AND auditor-defensible; picture-book = zero exposition, one feeling + 6-12 word caption.
|
||||
EXCLUDE: law-school depth, abstract definitions before a concrete example, coverage of the whole curriculum, marketing pitch tone.
|
||||
TEST: learner reproduces / ships / debugs within 90 seconds, not watches.
|
||||
|
||||
==================================================
|
||||
AXIS 2 — DELIVERY MODE (presented vs self-read), modulates density only
|
||||
==================================================
|
||||
|
||||
PRESENTED-LIVE (stage, town-hall, defense talk, oral pitch, lecture, lesson):
|
||||
LOWER density per slide — speaker is the bandwidth. One idea/claim/chart per slide; speaker-note timing; bullets banned at the stage end; slides "disappear into the talk." A self-read-dense slide projected live is the failure (audience reads instead of listening).
|
||||
|
||||
SELF-READ / PRE-READ / LEAVE-BEHIND (board pre-read, RFC, rfp-response doc, equity note, investor update, sales-battlecard, qbr pre-read, year-end-review, prd-as-doc):
|
||||
HIGHER density, MUST be standalone-readable — every chart carries its so-what, every title is a conclusion, sources on every slide, navigable in 8 seconds by skim AND defensible on deep read. No reliance on a narrator. Action titles + footnoted sources are mandatory, not optional.
|
||||
|
||||
DUAL-MODE (the hardest; many consulting/finance decks): build the skim layer (Minto answer in 2 slides, action titles) ON TOP of an auditable deep layer beneath (dollar-sizing methodology, appendix). Examples: consulting-final-deck (Partner on screen / Director-reviewed PDF), industry-deep-dive (4-min skim + defensible read), incident-postmortem (brutal technical detail internally + plain-English customer summary). Rule: the two layers share one structure; never produce a single MID/MID artifact that serves neither.
|
||||
|
||||
AUTO-REFRESH / CADENCE (sql-kpi-weekly, investor-update, lab-meeting): fixed structure cloned for diff-ability; "are you stuck?" / variance answer readable in 90 seconds; on-time and re-runnable beats comprehensive.
|
||||
|
||||
==================================================
|
||||
THREE QUICK DISAMBIGUATIONS (where decks wrongly converge to MID)
|
||||
==================================================
|
||||
1. Same metric, different altitude: a board QBR shows ONE variance walk + the ask (high altitude / low density); the operating MOR behind it shows every driver four ways (low altitude / high density). Don't ship the MOR to the board or the QBR to ops.
|
||||
2. Expert vs lay on the SAME topic (ai-hardtech-pitch vs ai-101): both about AI — one is eval tables + failure-mode rates for technical partners, the other bans benchmark charts entirely. Audience, not topic, sets altitude.
|
||||
3. Pitch is NOT low-detail everywhere: stage demo-day is C1 (one sentence/slide), but the Series B/C IC pre-read is A-altitude self-read (cohort triangles, Rule-of-40, 20-page-memo-equivalent density). Same category, opposite depth — delivery_mode + audience decide.
|
||||
```
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<!--
|
||||
Source snapshot: docs/vendor/anygen-svg/source.full.md
|
||||
Remote source: https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd
|
||||
Use: AnyGen SVG Slides prompt/reference asset for slides +create-svglide.
|
||||
Rule: Do not edit semantics without refreshing the local source snapshot first.
|
||||
-->
|
||||
|
||||
## slide_organize
|
||||
|
||||
大纲创建后增删页。工具描述
|
||||
|
||||
```text
|
||||
Add or delete slide pages in an existing presentation project. Use this instead of calling slide_outline again when you need to modify the page structure after the initial outline is created. Operations are executed in order.
|
||||
```
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<!--
|
||||
Source snapshot: docs/vendor/anygen-svg/source.full.md
|
||||
Remote source: https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd
|
||||
Use: AnyGen SVG Slides prompt/reference asset for slides +create-svglide.
|
||||
Rule: Do not edit semantics without refreshing the local source snapshot first.
|
||||
-->
|
||||
|
||||
## slide_outline
|
||||
|
||||
创建大纲 / 项目结构。tool_outline_svg.go.tmpl
|
||||
|
||||
```text
|
||||
Create the project structure with outline metadata, style settings, and empty slide files.
|
||||
|
||||
<instructions>
|
||||
- Use this tool AFTER preparing the slide content draft (slide_content.md)
|
||||
- The outline defines: page ids, titles, summaries (structural metadata), NOT the detailed content
|
||||
- This tool creates: project directory, outline.json, a style file, and empty `.svg` slide placeholders
|
||||
- Each slide's actual content will be written later using slides_edit based on the content draft
|
||||
- Follow the user's confirmed slide count. If they confirmed a range (e.g., "8-12"), aim for the middle of that range. If no count was specified, default to an 8-12 slide deck. Unless the user explicitly asked for a short / concise deck, never create fewer than 8 slides. Remember that structural slides (cover, agenda, section dividers, closing) consume pages too — factor them into your total so content slides don't get squeezed
|
||||
</instructions>
|
||||
|
||||
<recommended_usage>
|
||||
- Use to define the presentation structure and style before writing individual slides
|
||||
</recommended_usage>
|
||||
```
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
<!--
|
||||
Source snapshot: docs/vendor/anygen-svg/source.full.md
|
||||
Remote source: https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd
|
||||
Use: AnyGen SVG Slides prompt/reference asset for slides +create-svglide.
|
||||
Rule: Do not edit semantics without refreshing the local source snapshot first.
|
||||
-->
|
||||
|
||||
## slides_convert
|
||||
|
||||
把上传 PPTX 导入成可编辑 deck(两协议共用;SVG 下产出 .svg)。tool_convert_pptx.go.tmpl
|
||||
|
||||
```text
|
||||
Convert and parse a user-uploaded PPTX file to editable .slides format.
|
||||
|
||||
This is the ONLY tool for reading/parsing/converting PPTX files. It converts the user's PPTX file into an editable .slides presentation with individual XML files for each slide.
|
||||
|
||||
IMPORTANT: NEVER use python-pptx, node-pptx, or write your own script to parse PPTX files. Always use this tool instead.
|
||||
|
||||
USE CASES:
|
||||
- User wants to edit/modify their existing PPTX presentation
|
||||
- User wants to convert PPTX to editable format for manual editing
|
||||
- User needs to view their PowerPoint file in the slides editor
|
||||
- User wants to read/understand the content of a PPTX file (convert first, then read the XML files)
|
||||
- User wants to summarize or analyze a PPTX presentation
|
||||
|
||||
WORKFLOW:
|
||||
1) Parse the PPTX file and convert to SXSD XML format via RPC
|
||||
2) Extract each slide into individual XML files (slide_1.xml, slide_2.xml, etc.)
|
||||
3) Create a .slides manifest file for the editor to render
|
||||
4) Store converted.xml as reference (hidden file)
|
||||
|
||||
INPUT:
|
||||
- file_path: path to the PPTX file to convert (must be a .pptx file)
|
||||
- directory: sandbox path to store the converted files (e.g., '/home/user/workspace/slides/my_presentation')
|
||||
|
||||
OUTPUT:
|
||||
- slides_path: absolute path to the .slides manifest file ('{directory}.slides') - use this path for slides_update
|
||||
- slide_count: number of slides extracted from the PPTX
|
||||
- directory: directory containing individual slide XML files
|
||||
|
||||
IMPORTANT:
|
||||
- After conversion, use slides_update tool with the slides_path to modify the presentation
|
||||
- The original PPTX content is preserved exactly in the converted .slides file
|
||||
- If user wants to use PPTX as a STYLE REFERENCE for new slides, use slides_parse_template instead
|
||||
```
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
<!--
|
||||
Source snapshot: docs/vendor/anygen-svg/source.full.md
|
||||
Remote source: https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd
|
||||
Use: AnyGen SVG Slides prompt/reference asset for slides +create-svglide.
|
||||
Rule: Do not edit semantics without refreshing the local source snapshot first.
|
||||
-->
|
||||
|
||||
## slides_edit
|
||||
|
||||
写单页 SVG 文档。tool_edit_svg.go.tmpl
|
||||
|
||||
```text
|
||||
Write the SVG document for one or more slide pages in a presentation project. The `slides` parameter is an array — each item edits one slide file independently. Slides are processed and displayed incrementally as each one completes.
|
||||
|
||||
This tool is the only way to make slide content visible to the user. It writes content into the .slides manifest and triggers frontend rendering. The .slides manifest is the source of truth, and only this tool updates it — writing files through any other method (sandbox_write_file, sandbox_exec_command, etc.) has no effect on the final presentation.
|
||||
|
||||
<slide_quality_mindset>
|
||||
Each slide will be projected in front of an audience. Before writing, ask:
|
||||
- Would I be proud to present this in a Fortune 500 boardroom?
|
||||
- If there's data, is it in a chart or buried in text?
|
||||
- If there's a concept, is there an image or just words on a background?
|
||||
|
||||
A text-only slide with decorative shapes signals skipped preparation. It is rarely the right solution.
|
||||
</slide_quality_mindset>
|
||||
|
||||
<core_technical_requirements>
|
||||
## SVG document
|
||||
- Each slide item's `svg_code` parameter MUST contain a single `<svg slide:role="slide" ...>` element as the root — a standard SVG document carrying private `slide:*` attributes. See `<svg_reference>` for the full element/attribute schema.
|
||||
- This is SVG, NOT HTML and NOT any XML DSL. Use only the elements and attributes documented in `<svg_reference>`.
|
||||
- DO NOT wrap with `<presentation>` — each item edits one slide at a time.
|
||||
- The slide's `id` should match the filename (e.g., `slide_01_cover.svg` uses `id="cover"`).
|
||||
|
||||
## Image usage
|
||||
- NEVER reference a non-existent or non-local image. Always use absolute paths for images, fonts, and other resources.
|
||||
- Use ONLY the image path(s) prepared for the slide (to avoid duplicates). Place content images with concrete subjects (UI mockups, illustrations) as `<image slide:role="shape" slide:shape-type="image">` in a split or side layout — do not fade them to low opacity and use as full-screen backgrounds, which creates ghost-like visual noise behind foreground content.
|
||||
|
||||
## Incremental processing
|
||||
- Slides are written and displayed as soon as each one is complete.
|
||||
- Include up to 5 slides per call (more risks output truncation); split larger decks into multiple calls.
|
||||
- NEVER call this tool in parallel — always sequentially (wait for one call to finish before the next).
|
||||
</core_technical_requirements>
|
||||
|
||||
<layout_and_design>
|
||||
Compose every slide's layout from scratch to fit its specific content and the deck's aesthetic direction — follow "Design Thinking", "Aesthetic Guidelines", and "Layout Freedom" in `<svg_reference>`. Do NOT rotate through a fixed menu of canned patterns, and do NOT apply formulaic "diagram" templates — that produces the template-stamped feel we are avoiding. Favor unexpected, asymmetric, content-specific composition: overlap, diagonal flow, grid-breaking elements, a single dominant hero element, generous negative space. Vary the structural arrangement between adjacent slides while keeping the deck's background, card-surface style, and decoration density CONSTANT across the whole deck.
|
||||
|
||||
Use visual elements (shapes, lines, icons, accent bars, gradients) to break up text and build hierarchy; apply the accent color sparingly for emphasis; maintain white space and contrast. When text sits on a background image, overlay the image with a semi-transparent shape first so the text stays readable.
|
||||
</layout_and_design>
|
||||
|
||||
<prohibited_practices>
|
||||
- NEVER use the same "title + bullet list" layout on every slide.
|
||||
- Don't overflow the 720px target height; don't stack images or charts vertically.
|
||||
- Never reference non-existent or non-local image paths.
|
||||
- Avoid walls of text without visual breaks.
|
||||
</prohibited_practices>
|
||||
|
||||
<visualization_requirements>
|
||||
- Incorporate charts when data is available; use large stat numbers for key metrics (e.g., "$150B" as a prominent element).
|
||||
- Each column may contain at most one chart/graph/image.
|
||||
- Only chart real, source-verified data — never fabricate numerical data.
|
||||
</visualization_requirements>
|
||||
|
||||
<thinking_process_instructions>
|
||||
Before writing the SVG, use the `content_thinking` parameter to document:
|
||||
1. **Visual assets**: which images/charts you will use (list file paths). If none are available, the slide is missing preparation — go prepare visuals first.
|
||||
2. **Layout**: what composition best fits this content, and how it differs from adjacent slides.
|
||||
3. **Key message**: the ONE takeaway, and how typography and spacing emphasize it.
|
||||
4. **Data visualization**: can any content be shown as a chart or large stat number instead of text?
|
||||
5. **Composition**: how you distribute elements across the canvas to avoid empty space.
|
||||
</thinking_process_instructions>
|
||||
|
||||
<quality_standards>
|
||||
- All content must be verifiable — never use fabricated data or present subjective assessments as fact.
|
||||
- Stay consistent with the style_instruction provided in slide_outline.
|
||||
</quality_standards>
|
||||
```
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<!--
|
||||
Source snapshot: docs/vendor/anygen-svg/source.full.md
|
||||
Remote source: https://bytedance.larkoffice.com/docx/KnCLd7xr5ohWONxhKsncZ3Lxnvd
|
||||
Use: AnyGen SVG Slides prompt/reference asset for slides +create-svglide.
|
||||
Rule: Do not edit semantics without refreshing the local source snapshot first.
|
||||
-->
|
||||
|
||||
## slides_parse_template
|
||||
|
||||
解析模板元数据。tool_parse_template.go.tmpl
|
||||
|
||||
```text
|
||||
Parse and preprocess a SXSD XML template for template-based slide generation.
|
||||
|
||||
This tool takes an XML template file and produces a processed version (tmpl.xml) optimized for template-based generation:
|
||||
- Extracts embedded images to sandbox filesystem
|
||||
- Normalizes coordinate precision and formatting
|
||||
- Prepares the template structure for layout replication
|
||||
|
||||
IMPORTANT: This tool only accepts .xml files.
|
||||
- If user uploads a .pptx file and wants to use it as a template, you MUST first call slides_convert to convert it, then use the returned xml_path as input to this tool.
|
||||
|
||||
INPUT:
|
||||
- folder_name: folder name for storing template files (e.g., 'my_template'). Will be placed under /home/user/workspace/slides/template/
|
||||
- file_path: path to the XML template file (must be .xml, typically the xml_path returned by slides_convert)
|
||||
|
||||
OUTPUT:
|
||||
- tmpl_path: absolute path to processed template XML '{folder}/tmpl.xml'
|
||||
|
||||
After parsing, read tmpl.xml to understand the template's layout patterns, then replicate those layouts when writing SML with slide_edit.
|
||||
```
|
||||
@@ -1,200 +0,0 @@
|
||||
# slides +create-svglide
|
||||
|
||||
`slides +create-svglide` 是 AnyGen SVG Slides md 资产的本地 SVGlide runtime adapter。
|
||||
|
||||
它只负责把 AnyGen prompt/reference 资产接入本地 run-dir 工作流:创建目录、写入请求和 manifest、显示下一步应读的 AnyGen md、校验本地产物、生成 HTML 预览。它不创建在线飞书幻灯片,不调用 `slide_engine`,不做 SVG-to-SXSD 发布,也不定义新的 AnyGen 生成标准。
|
||||
|
||||
## 权威顺序
|
||||
|
||||
1. AnyGen 迁移资产:`skills/lark-slides/references/anygen-svg/`
|
||||
2. 本地 adapter:`skills/lark-slides/references/lark-slides-create-svglide.md`
|
||||
3. Go CLI:`internal/svglide/*`
|
||||
|
||||
当 AnyGen md 资产和本文档在设计语义、角色职责、页面质量标准上出现差异时,以 AnyGen md 资产为准。本文档只解释本地 CLI 如何承载这些资产。
|
||||
|
||||
## 本地 adapter 边界
|
||||
|
||||
- prompt/reference assets 放在 `skills/lark-slides/references/anygen-svg/`,由 AnyGen md 定义生成语义。
|
||||
- 本地 run 目录只保存生成产物、receipts、preview、schema、manifest 和 quality reports。
|
||||
- Go 不负责设计判断或内容生成;Go 只负责 run-dir 创建、schema 检查、状态流转、SVG lint、本地 preview 和机械 gate。
|
||||
- Codex 读取 skill/reference 文件,执行研究、design brief resolution、内容规划、资产准备和 SVG authoring。
|
||||
|
||||
本地 run 写入 `prompt_manifest.json`,记录本次 run 应使用哪些 prompt assets。它不再把完整 prompt 文本复制到 `prompts/`。
|
||||
|
||||
语义质量由 AnyGen md 资产约束,Go 只做本地结构检查、JSON/SVG 基础校验和 preview 生成。当前实验阶段允许资产路径宽松表达;这只是本地 adapter 的搬运边界,不改变 AnyGen 对素材规划和页面质量的要求。
|
||||
|
||||
## 执行模式
|
||||
|
||||
用户要求生成、创建或执行 SVGlide PPT 时,默认进入 execution mode。
|
||||
|
||||
- 不额外要求 brainstorming approval。
|
||||
- 不额外要求 writing plan。
|
||||
- 直接按 `init -> next -> stage artifacts -> complete -> repair` 推进。
|
||||
- 只有缺少必要输入、目标互相矛盾、或会产生外部不可逆副作用时才暂停。
|
||||
- 阻断权归属 `svglide-runtime`;辅助 skill 只能提供参考,不新增 approval gate。
|
||||
|
||||
## AnyGen prompt assets
|
||||
|
||||
源快照:
|
||||
|
||||
- `docs/vendor/anygen-svg/source.full.md`
|
||||
- `docs/vendor/anygen-svg/source.outline.md`
|
||||
- `docs/vendor/anygen-svg/source.meta.json`
|
||||
|
||||
运行时 prompt/reference 文件:
|
||||
|
||||
- `skills/lark-slides/references/anygen-svg/mode_system_prompt_svg.md`
|
||||
- `skills/lark-slides/references/anygen-svg/svg_reference.md`
|
||||
- `skills/lark-slides/references/anygen-svg/tools/resolve_design_brief.md`
|
||||
- `skills/lark-slides/references/anygen-svg/tools/slide_outline.md`
|
||||
- `skills/lark-slides/references/anygen-svg/tools/slides_edit.md`
|
||||
- `skills/lark-slides/references/anygen-svg/tools/generate_svg_chart.md`
|
||||
- `skills/lark-slides/references/anygen-svg/tools/*.md`
|
||||
|
||||
## 第一阶段不承诺的能力
|
||||
|
||||
第一阶段不假装已经实现这些能力:
|
||||
|
||||
- native chart generation
|
||||
- native table generation
|
||||
- image crop / pan composition
|
||||
- Feishu publish / `.slides` handover
|
||||
|
||||
如果 deck 需要这些能力,Codex 必须在 `content/slide_content.json` 和 `assets/assets_plan.json` 保留语义需求。当前可以用 `deferred` 或 unrestricted experiment asset 表达,不把它包装成已完成的生产能力。
|
||||
|
||||
## 推荐流程
|
||||
|
||||
初始化 run-dir:
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svglide \
|
||||
--as user \
|
||||
--action init \
|
||||
--title "Demo" \
|
||||
--input ./source.md \
|
||||
--audience "产品负责人" \
|
||||
--delivery-mode self_read \
|
||||
--pages 8 \
|
||||
--out ./.lark-slides/svglide-runs/demo
|
||||
```
|
||||
|
||||
查看下一步:
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svglide --as user --action status --run ./.lark-slides/svglide-runs/demo
|
||||
lark-cli slides +create-svglide --as user --action next --run ./.lark-slides/svglide-runs/demo
|
||||
```
|
||||
|
||||
每个 stage 的循环:
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svglide --as user --action next --run ./.lark-slides/svglide-runs/demo
|
||||
# Codex 根据返回的 prompt_manifest / prompt_paths 读取 prompt assets 并填充当前 stage outputs
|
||||
lark-cli slides +create-svglide --as user --action complete --run ./.lark-slides/svglide-runs/demo
|
||||
lark-cli slides +create-svglide --as user --action next --run ./.lark-slides/svglide-runs/demo
|
||||
```
|
||||
|
||||
`outline`、`content`、`visual`、`assets` 就绪后,再生成基础 SVG author,支持文本、来源脚注和已规划图片资产:
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svglide --as user --action author --run ./.lark-slides/svglide-runs/demo
|
||||
```
|
||||
|
||||
最终校验、预览和质量门禁:
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svglide --as user --action repair --run ./.lark-slides/svglide-runs/demo
|
||||
```
|
||||
|
||||
必要时可单独定位校验或预览问题:
|
||||
|
||||
```bash
|
||||
lark-cli slides +create-svglide --as user --action validate --run ./.lark-slides/svglide-runs/demo
|
||||
lark-cli slides +create-svglide --as user --action preview --run ./.lark-slides/svglide-runs/demo
|
||||
lark-cli slides +create-svglide --as user --action quality --run ./.lark-slides/svglide-runs/demo
|
||||
```
|
||||
|
||||
`init` 只建立目录、`run.json`、`prompt_manifest.json`、schema 和 request 产物。Codex 按 `next` 返回的 `prompt_manifest` / `prompt_paths` 读取 prompt assets 并填充当前 stage 产物后,用 `complete` 校验并推进 stage。`repair` 会先执行 validate + preview + quality;只有三者都 passed 才最终 passed。deck 缺失、JSON 读取失败等不可自动修复的问题会通过命令错误、已有 `receipts/lint.json`、`receipts/preview.json` 和 `repair_queue.md` 暴露;`quality` 成功执行后才保证写出 `quality_report.json`。
|
||||
|
||||
## Action
|
||||
|
||||
| Action | 作用 | 主要产物 |
|
||||
|--------|------|----------|
|
||||
| `init` | 创建本地 run-dir | `run.json`、`request/request.json`、`prompt_manifest.json`、`schemas/*.json` |
|
||||
| `status` | 查看当前 stage、缺失输入/输出和下一条命令 | JSON 状态报告 |
|
||||
| `next` | 返回当前 stage 的 prompt manifest、prompt asset paths、输入和输出列表 | JSON task 报告 |
|
||||
| `complete` | 校验当前 stage 输出并推进到下一 stage | `run.json`、`receipts/<stage>.json` |
|
||||
| `author` | 基于 deck/content/visual/assets 生成基础 SVG author,支持文本、来源脚注和已规划图片资产 | `slides/*.svg`、`receipts/svg_author.json` |
|
||||
| `validate` | 校验 `outline/deck.json` 中的 `slides/*.svg` | `receipts/lint.json`、`repair_queue.md` |
|
||||
| `preview` | 生成本地 HTML 预览 | `preview.html`、`receipts/preview.json` |
|
||||
| `quality` | 校验本地结构和产物链路:来源、引用、视觉需求、asset id/type 语义链路 | `quality_report.json` |
|
||||
| `repair` | 执行 validate + preview + quality;仅当三者都 passed 时最终 passed | `receipts/validate_preview_repair.json`、`quality_report.json` |
|
||||
|
||||
## 运行目录
|
||||
|
||||
核心文件:
|
||||
|
||||
- `run.json`
|
||||
- `prompt_manifest.json`
|
||||
- `request/request.json`
|
||||
- `request/source_manifest.json`
|
||||
- `research/research_notes.md`
|
||||
- `research/sources.json`
|
||||
- `brief/design_brief.json`
|
||||
- `brief/visual_system.json`
|
||||
- `outline/deck.json`
|
||||
- `content/slide_content.md`
|
||||
- `content/slide_content.json`
|
||||
- `assets/assets_plan.json`
|
||||
- `quality_report.json`
|
||||
- `slides/*.svg`
|
||||
- `receipts/*.json`
|
||||
- `repair_queue.md`
|
||||
- `preview.html`
|
||||
|
||||
## 边界
|
||||
|
||||
- CLI 负责 run-dir 骨架、prompt manifest、schema、状态检查、SVG protocol 校验、本地机械 quality gate、repair queue 和 HTML preview。
|
||||
- Codex 负责网页研究、完整页面读取、design brief、visual system、slide content、资产规划、生图/搜图结果落地、每页 source_refs/visuals 以及 SVG authoring 和修复。
|
||||
- assets_plan 是先规划后写 SVG 的输入;当前实验模式要求写 `mode: "experiment_unrestricted_assets"`,允许 path 使用远程 URL、临时路径、本地路径或后续生成资产占位。生产化阶段再恢复可搬运的本地 asset contract。
|
||||
- `quality` action 是本地结构检查入口,覆盖来源、引用、视觉需求、asset id/type/status 语义链路;它不检查资产路径安全或文件是否已本地落地。`repair` 的 final quality gate 必须和 validate、preview 一起通过。
|
||||
- 当前本地 adapter 不实现 native chart、native table、图片裁剪。遇到真实数据图表、表格或图片构图需求时,Codex 按 AnyGen md 保留语义说明,并用 `deferred` 或 unrestricted experiment asset 表达,不伪装成已完成生产能力。
|
||||
- 本命令不发布到 Feishu,不返回 `xml_presentation_id`,不创建 `.slides`,不调用 Slides OpenAPI。
|
||||
- `preview` 只允许 deck slide path 使用 `slides/<file>.svg` 单层本地路径;不要引用远程 URL、上级目录、百分号编码路径或嵌套目录。
|
||||
- `validate` 的 `ok=false` 表示内容校验失败,但命令仍会输出结构化报告;只有 run-dir 读取或本地写入失败才是命令异常。
|
||||
|
||||
## Codex 执行规则
|
||||
|
||||
1. 先运行 `status` 或 `next`,确认当前 stage 和缺失产物。
|
||||
2. 先读 `skills/lark-slides/references/anygen-svg/README.md`,再按 `next` 返回的 `prompt_manifest` / `prompt_paths` 读取 AnyGen prompt assets,填充当前 stage 的输出,不跳 stage 写最终 SVG。
|
||||
3. 每个 stage 输出就绪后运行 `complete`,让 CLI 校验当前 stage 并推进 `run.json`。
|
||||
4. 写 `content/slide_content.md` 时为每页补齐 `source_refs` 和 `visuals`;写 `assets/assets_plan.json` 时显式包含 `mode: "experiment_unrestricted_assets"`,并保留图片、图表、表格、裁剪等视觉语义。
|
||||
5. 当 `outline`、`content`、`visual`、`assets` 已存在时,可运行 `author` 生成包含文本、来源脚注和本地图片的基础 SVG。
|
||||
6. 生成 SVG 时保持纯 SVG、`viewBox="0 0 960 540"`、可选中文本;当前实验模式允许 `<image href>` 使用远程或临时资产路径。
|
||||
7. 最后运行 `repair`,形成 validate + preview + quality 三门禁和 final receipt;`repair` 只自动处理可由基础 `author` 覆盖的 lint 失败,deck 缺失、JSON 读取失败等问题会通过命令错误、已有 receipt 和 `repair_queue.md` 暴露;`quality` 成功执行后才保证写出 `quality_report.json`。
|
||||
8. 不要把这个本地 adapter 描述成完整 12-agent 自动化系统;它是 Codex 协作的分阶段本地 runtime,生成语义以 AnyGen md 资产为准。
|
||||
|
||||
## 常见命令
|
||||
|
||||
```bash
|
||||
# 查看下一步该做什么
|
||||
lark-cli slides +create-svglide --as user --action next --run ./.lark-slides/svglide-runs/demo
|
||||
|
||||
# 校验当前 stage 并推进
|
||||
lark-cli slides +create-svglide --as user --action complete --run ./.lark-slides/svglide-runs/demo
|
||||
|
||||
# 基于已完成的 deck/content/visual/assets 生成文本、来源脚注和图片资产 SVG
|
||||
lark-cli slides +create-svglide --as user --action author --run ./.lark-slides/svglide-runs/demo
|
||||
|
||||
# 只做 SVG 协议校验
|
||||
lark-cli slides +create-svglide --as user --action validate --run ./.lark-slides/svglide-runs/demo
|
||||
|
||||
# 生成本地预览
|
||||
lark-cli slides +create-svglide --as user --action preview --run ./.lark-slides/svglide-runs/demo
|
||||
|
||||
# 只做当前本地结构和产物链路检查
|
||||
lark-cli slides +create-svglide --as user --action quality --run ./.lark-slides/svglide-runs/demo
|
||||
|
||||
# 执行 validate,preview 和 quality
|
||||
lark-cli slides +create-svglide --as user --action repair --run ./.lark-slides/svglide-runs/demo
|
||||
```
|
||||
@@ -15,11 +15,11 @@ import (
|
||||
)
|
||||
|
||||
// TestAppsDBExecuteDryRun pins +db-execute 复用存量 URL,CLI 永远走 DBA 模式
|
||||
// (?transactional=false),sql body 由 --sql 透传,默认 env=dev。
|
||||
// (?transactional=false),sql body 由 --sql 透传,默认不传 env(空值,由服务端按 workspace 定分支)。
|
||||
func TestAppsDBExecuteDryRun(t *testing.T) {
|
||||
setAppsDryRunEnv(t)
|
||||
|
||||
t.Run("DefaultEnvIsDevAndTransactionalFalse", func(t *testing.T) {
|
||||
t.Run("DefaultEnvUnsetAndTransactionalFalse", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -37,8 +37,8 @@ func TestAppsDBExecuteDryRun(t *testing.T) {
|
||||
"CLI is DBA mode → must send transactional=false in query")
|
||||
assert.False(t, gjson.Get(result.Stdout, "api.0.body.transactional").Exists(),
|
||||
"transactional should be in query, not body")
|
||||
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String(),
|
||||
"default env must be dev (not production)")
|
||||
assert.Equal(t, "", gjson.Get(result.Stdout, "api.0.params.env").String(),
|
||||
"default: no --environment → CLI sends empty env, server picks workspace default branch")
|
||||
})
|
||||
|
||||
t.Run("OnlineEnvSwitch", func(t *testing.T) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
func TestAppsDBTableListDryRun(t *testing.T) {
|
||||
setAppsDryRunEnv(t)
|
||||
|
||||
t.Run("DefaultsToDevAndPageSize20", func(t *testing.T) {
|
||||
t.Run("DefaultsToNoEnvAndPageSize20", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -32,7 +32,8 @@ func TestAppsDBTableListDryRun(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
|
||||
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables", gjson.Get(result.Stdout, "api.0.url").String())
|
||||
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String())
|
||||
assert.Equal(t, "", gjson.Get(result.Stdout, "api.0.params.env").String(),
|
||||
"default: no --environment → CLI sends empty env, server picks workspace default branch")
|
||||
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
|
||||
assert.False(t, gjson.Get(result.Stdout, "api.0.params.page_token").Exists(),
|
||||
"empty page_token must be omitted")
|
||||
|
||||
@@ -63,29 +63,5 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
||||
assert.True(t, strings.Contains(output, "from_speaker_id"), "dry-run should contain from_speaker_id, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "ENCRYPTED_TOKEN_ABC"), "dry-run should contain the encrypted speaker id, got: %s", output)
|
||||
assert.False(t, strings.Contains(output, "from_user_id"), "dry-run should not contain from_user_id when from-speaker-id is set, got: %s", output)
|
||||
}
|
||||
|
||||
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
|
||||
setDryRunConfigEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"minutes", "+speaker-replace",
|
||||
"--minute-token", "obcnexampleminute",
|
||||
"--from-speaker-id", "说话人1",
|
||||
"--to-user-id", "ou_new_speaker",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.True(t, strings.Contains(output, "GET"), "dry-run should contain GET for internal speaker list, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speakerlist"), "dry-run should contain speakerlist API path, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "PUT"), "dry-run should contain PUT method, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speaker"), "dry-run should contain speaker replace path, got: %s", output)
|
||||
assert.False(t, strings.Contains(output, "/transcript/speakerlist"), "+speaker-replace should never fetch speakerlist, got: %s", output)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user