M6: figcraft-cli connect publish publishes code connect mappings to a design file (#1643)

* M6: figcraft-cli connect publish publishes code connect mappings to a design file

figcraft-cli connect publish <file.figcraft> [--mappings figcraft.connect.json] [--dry-run] links each entry's component or set by name through codeConnect.import, warns about and skips links that don't fit, saves the file in place through the engine's file.save, and exits 1 when nothing links. Gap 16.

* M6: the generated man pages include connect and connect publish
This commit is contained in:
BFlat
2026-10-30 16:31:25 +07:00
committed by GitHub
parent 10c66678c3
commit 84e5278209
9 changed files with 144 additions and 13 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+39 -1
View File
@@ -41,6 +41,11 @@ pub enum Command {
#[command(subcommand)]
command: PluginCommand,
},
/// Publish a code repository's component mappings (code connect) to a design file
Connect {
#[command(subcommand)]
command: ConnectCommand,
},
Perf(PerfArgs),
}
@@ -228,6 +233,31 @@ pub struct PublishArgs {
pub server: String,
}
#[derive(Subcommand)]
pub enum ConnectCommand {
Publish(ConnectPublishArgs),
}
/// Link a design file's components to their code as a figcraft.connect.json says, and save it
///
/// The mappings are what Dev Mode's Export code connections writes, kept in the code repository:
/// each links a component (or component set) of that name to its code component for a language.
/// Entries that don't fit (an unknown component, a bad template) are reported and skipped.
#[derive(Args)]
pub struct ConnectPublishArgs {
/// The design file (.figcraft), saved in place
#[arg(value_name = "FILE")]
pub file: String,
/// The mappings
#[arg(long, value_name = "FILE", default_value = "figcraft.connect.json")]
pub mappings: String,
/// Report what would be linked without saving the file
#[arg(long)]
pub dry_run: bool,
#[command(flatten)]
pub roots: RootArgs,
}
/// Measure the hot paths on synthetic documents and print Markdown tables
#[derive(Args)]
pub struct PerfArgs {
@@ -326,7 +356,7 @@ mod tests {
use clap::CommandFactory;
use clap::error::ErrorKind;
use super::{Cli, Command, PluginCommand, parse};
use super::{Cli, Command, ConnectCommand, PluginCommand, parse};
fn try_parse(args: &[&str]) -> Result<Cli, clap::Error> {
parse(std::iter::once("figcraft-cli").chain(args.iter().copied()).map(Into::into).collect())
@@ -434,6 +464,14 @@ mod tests {
panic!("plugin")
};
assert_eq!((a.folder.as_str(), a.server.as_str()), ("dist/counter", "ws://h:47900/"));
let Command::Connect { command: ConnectCommand::Publish(a) } = ok(&["connect", "publish", "ui.figcraft"]) else { panic!("connect") };
assert_eq!((a.file.as_str(), a.mappings.as_str(), a.dry_run), ("ui.figcraft", "figcraft.connect.json", false));
let Command::Connect { command: ConnectCommand::Publish(a) } =
ok(&["connect", "publish", "ui.figcraft", "--mappings", "code/map.json", "--dry-run"])
else {
panic!("connect")
};
assert_eq!((a.mappings.as_str(), a.dry_run), ("code/map.json", true));
}
#[test]
+64
View File
@@ -459,6 +459,34 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
Ok(ExitCode::SUCCESS)
}
/// Link the components of `file` (a `.figcraft`) to their code as `mappings` (a
/// `figcraft.connect.json`) says, and save it in place unless `dry_run`. Prints what was linked;
/// entries that were skipped are warnings on stderr, and linking nothing fails (exit code 1).
pub fn connect_publish(file: &str, mappings: &str, dry_run: bool) -> CliResult<ExitCode> {
if ext(file) != "figcraft" {
return Err(CliError::Usage(format!("{file}: connect publish saves code links in a .figcraft file")));
}
let mut s = Session::empty();
s.execute("file.open", &json!({"path": file})).map_err(|e| CliError::Failed(format!("can't open {file}: {e}")))?;
let r = s.execute("codeConnect.import", &json!({"path": mappings})).map_err(|e| CliError::Failed(format!("{mappings}: {e}")))?;
for w in r["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str) {
eprintln!("warning: {w}");
}
let linked = r["linked"].as_u64().unwrap_or(0);
if linked == 0 {
eprintln!("error: no component of {file} matches a link in {mappings}: nothing published");
return Ok(ExitCode::from(1));
}
if dry_run {
println!("{linked} component link(s) from {mappings} would be published to {file} (dry run: not saved)");
return Ok(ExitCode::SUCCESS);
}
// The engine's save: written whole or not at all, with the file's version history.
s.execute("file.save", &json!({})).map_err(|e| CliError::Failed(format!("can't save {file}: {e}")))?;
println!("published {linked} component link(s) from {mappings} to {file}");
Ok(ExitCode::SUCCESS)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -557,6 +585,42 @@ mod tests {
std::fs::remove_dir_all(dir).unwrap();
}
/// `connect publish` links the file's components as the mappings say and saves the file in
/// place; a dry run saves nothing, and mappings that link nothing fail.
#[test]
fn connect_publish_links_components_and_saves_the_file() {
let dir = temp_dir("connect");
let file = dir.join("ui.figcraft");
let file = file.to_str().unwrap();
let mut s = Session::new();
let frame = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Card"})).unwrap();
s.execute("component.create", &json!({"ids": [frame["id"]]})).unwrap();
s.execute("file.saveAs", &json!({"path": file})).unwrap();
let mappings = dir.join("figcraft.connect.json");
let link =
|component: &str| json!({"component": component, "language": "react", "importPath": "@acme/ui", "name": "Card", "template": "<Card />"});
std::fs::write(&mappings, json!({"version": 1, "links": [link("Card"), link("Nope")]}).to_string()).unwrap();
let mappings = mappings.to_str().unwrap();
let links = || {
let mut s = Session::empty();
s.execute("file.open", &json!({"path": file})).unwrap();
let doc = s.doc().unwrap();
doc.nodes().filter(|n| n.name == "Card").map(|n| n.code_links.len()).sum::<usize>()
};
assert_eq!(connect_publish(file, mappings, true).unwrap(), ExitCode::SUCCESS);
assert_eq!(links(), 0, "a dry run saves nothing");
assert_eq!(connect_publish(file, mappings, false).unwrap(), ExitCode::SUCCESS, "an unknown component is only a warning");
assert_eq!(links(), 1, "Card is linked to its code in the saved file");
let none = dir.join("none.json");
std::fs::write(&none, json!({"version": 1, "links": [link("Nope")]}).to_string()).unwrap();
assert_eq!(connect_publish(file, none.to_str().unwrap(), false).unwrap(), ExitCode::from(1));
assert!(matches!(connect_publish(dir.join("ui.svg").to_str().unwrap(), mappings, false), Err(CliError::Usage(_))));
assert!(matches!(connect_publish(file, dir.join("missing.json").to_str().unwrap(), false), Err(CliError::Failed(_))));
std::fs::remove_dir_all(dir).unwrap();
}
/// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step
/// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it
/// as "no parent" and the layer landed on the page — with every step reporting success.
+3 -2
View File
@@ -1,6 +1,6 @@
//! `figcraft-cli`: FigCraft without a window. Inspect, export, convert and render documents, run
//! command scripts, list commands, serve MCP on stdio, publish plugins to a server's plugin
//! directory and measure performance.
//! directory, publish code connect mappings to a design file and measure performance.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod args;
@@ -10,7 +10,7 @@ mod perf;
use std::io::Write;
use std::process::ExitCode;
use args::{Cli, Command, PluginCommand, RootArgs};
use args::{Cli, Command, ConnectCommand, PluginCommand, RootArgs};
use serde_json::{Value, json};
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
@@ -93,6 +93,7 @@ fn run(cli: Cli) -> CliResult<ExitCode> {
// `--attach` conflicts with the roots: the app runs the commands, only its own roots can confine them.
Command::Mcp(a) => mcp(a.attach.as_deref(), roots_of(&a.roots)?),
Command::Plugin { command: PluginCommand::Publish(a) } => publish_plugin(&a.folder, &a.server),
Command::Connect { command: ConnectCommand::Publish(a) } => confined(&a.roots, || files::connect_publish(&a.file, &a.mappings, a.dry_run)),
Command::Perf(a) => {
let sizes: Vec<usize> = a.layers.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
let instances: Vec<usize> = a.instances.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect();
+4 -1
View File
@@ -99,6 +99,8 @@ fn man_pages_cover_every_subcommand_without_synthetic_help_pages() -> std::io::R
pages,
[
"figcraft-cli-commands.1",
"figcraft-cli-connect-publish.1",
"figcraft-cli-connect.1",
"figcraft-cli-convert.1",
"figcraft-cli-export.1",
"figcraft-cli-info.1",
@@ -122,6 +124,7 @@ fn man_pages_cover_every_subcommand_without_synthetic_help_pages() -> std::io::R
("figcraft-cli-export.1", "--format"),
("figcraft-cli-export.1", "--automation-read-root"),
("figcraft-cli-plugin-publish.1", "--server"),
("figcraft-cli-connect-publish.1", "--dry-run"),
] {
let text = fs::read_to_string(dir.join(page))?.replace("\\-", "-");
assert!(text.contains(option), "{page} does not contain {option}");
@@ -142,7 +145,7 @@ fn generation_creates_the_output_directory_and_can_run_again() -> std::io::Resul
fs::write(&page, "old page")?;
generator::generate(&output)?;
assert_eq!(fs::read(page)?, first);
assert_eq!(fs::read_dir(output)?.count(), 16);
assert_eq!(fs::read_dir(output)?.count(), 18);
Ok(())
}
+19 -1
View File
@@ -1,7 +1,7 @@
# figcraft-cli
FigCraft without a window: inspect, export, convert and render documents, run command scripts, list
commands, serve MCP and measure performance. Build it with
commands, serve MCP, publish plugins and code connect mappings, and measure performance. Build it with
`cargo build --release -p figcraft-cli` (binary `target/release/figcraft-cli`).
The CLI follows [RFC-0012](https://github.com/storytold/craft-contrib/blob/main/rfcs/0012-cli-conventions.md),
@@ -295,6 +295,24 @@ size and SHA-256. The server's key comes from the address or, better, from the
lists); it is never printed. The bundle is checked before it is sent and again by the server. See
[plugins.md](plugins.md#plugin-directories).
### `connect publish <file.figcraft> [--mappings FILE] [--dry-run]`
Publishes a code repository's component mappings to a design file: each link in the mappings file
(default `figcraft.connect.json` in the current folder, the file Dev Mode's **Export code
connections** and `codeConnect.export` write) connects every component or component set of that
name to its code component for a language, replacing its link for that language, and the file is
saved in place, written whole or not at all, with its version history. Links that don't fit (no
component of that name, a bad template or language) are printed as warnings and skipped; if nothing
is linked, nothing is saved and the exit code is 1. `--dry-run` reports what would be linked
without saving. Run it from the repository, for example in CI after the mappings change:
```sh
figcraft-cli connect publish ../design/ui.figcraft --mappings figcraft.connect.json
```
It takes the automation roots like the other file subcommands. See
[dev-mode-parity.md](dev-mode-parity.md) for code connect itself.
### `perf [--layers N[,N...]] [--instances N[,N...]]`
Builds synthetic documents of each size (default `1000,10000` layers: screens with an auto layout
+2 -1
View File
@@ -18,7 +18,7 @@ repository, third-party integrations. Remaining: 1–2 h.
| Ready for dev list, statuses, "Changed" after edits | done | the list filters Ready for dev, Changed, Completed or any, and badges changed ones (`inspect.readyForDev {status: changed}`, `changed` per item); only sections and frames at the top of a page or section take a status (others are refused) |
| Focus view (one frame, previous / next) | done | |
| Compare changes (before/after side by side or overlaid, property diffs) | done | against the snapshot taken when a frame was first changed after it was marked ready, or any saved version (`dev.compare {version}`; the view's Compare with menu); a frame that isn't ready for dev compares with versions |
| Code connect (map components to repository code) | done | per language (React, Leptos, Vue, SwiftUI, Compose, ArkUI, Flutter): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import; generated layer trees bring the import lines of the connected components they call (also in definitions, slots and swapped-in instances) once each at the top, per language. No CLI publishing mappings from the code repository |
| Code connect (map components to repository code) | done | per language (React, Leptos, Vue, SwiftUI, Compose, ArkUI, Flutter): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import; generated layer trees bring the import lines of the connected components they call (also in definitions, slots and swapped-in instances) once each at the top, per language. `figcraft-cli connect publish` publishes a repository's mappings to a design file (`--dry-run` to check them first) |
| Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab, with their panels (callbacks can only read); no third-party integrations |
| Measurements (redlines on hover, Alt in the design) | done | distances between the selection and the hovered layer, its parent or a guide, auto layout padding and gaps (Shift+Alt); measurements kept on the canvas (`devMode.addMeasurement` between two layers, along a side or to the layer around it; stored on the layer, saved, synced, undoable) drawn in Dev Mode as labelled redlines that follow the layers, listed, placed and removed in the Inspect tab; a measurement whose other layer was deleted isn't drawn until an undo brings it back |
| VS Code extension | out of scope | FigCraft's MCP server and control channel already serve design data, code and connected snippets to agents and editors |
@@ -28,6 +28,7 @@ repository, third-party integrations. Remaining: 1–2 h.
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | Gap 16: `figcraft-cli connect publish <file.figcraft> [--mappings figcraft.connect.json] [--dry-run]` publishes a code repository's mappings to a design file (as the incumbent's Code Connect CLI publishes a repository's mappings): it links each entry's component or set by name, warns about and skips links that don't fit, saves the file in place through the engine's save (or not, with `--dry-run`) and fails when nothing links. Dev Mode breadth (~98%) and ready (~78%) unchanged at this rounding; hours unchanged |
| 2026-10-26 | minor | Class names from layer names transliterate non-ASCII scripts (#1235): `class_name` runs names through `deunicode` (new dependency: pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`; before, every one of them came out `layer-`); symbols-only names take the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. ASCII-only names are unchanged. `html_codegen_tests` updated (its diacritics expectation improves from `layer-1-n-code-name` to `layer-1-unicode-name`) and extended. Percentages and hours unchanged |
| 2026-10-24 | minor | Dev Mode refuses design edits from files dropped on the canvas (a dropped document still opens as a file of its own, and the drop overlay shows the Dev Mode note for other files), from the Pages list's add, rename, reorder, duplicate and delete, and from the Assets panel's place, drag, Alt-drop swap, style, Hide when publishing, Update, and adding, removing and swapping libraries. Percentages and hours unchanged |
| 2026-10-24 | minor | Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Breadth 97% → 98%, ready 76% → 78%, 2–4 h → 1–2 h |
+9 -5
View File
@@ -29,7 +29,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| 13 | Vector tail: none left (boolean hit testing on the result landed 2026-10-11) | Feature | — | [target-app-parity](target-app-parity.md) |
| 14 | Variables: extended collections checked on real libraries (the tail landed 2026-10-12) | Feature | 0–1 | [components-parity](components-parity.md) |
| 15 | Prototyping: media triggers and actions; controllers on macOS, Linux and FreeBSD (controller buttons on key/gamepad triggers, widgets in prototypes landed) | Feature | 2–5 | [prototyping-parity](prototyping-parity.md) |
| 16 | Dev Mode: publishing mappings from the code repository; the editor extension is left to the MCP server (measurements kept on the canvas and a read-only layers panel landed 2026-10-24) | Feature | 1–2 | [dev-mode-parity](dev-mode-parity.md) |
| 16 | Dev Mode: renaming the file in Dev Mode to check; the editor extension is left to the MCP server (publishing mappings from the code repository landed 2026-10-30) | Feature | 1–2 | [dev-mode-parity](dev-mode-parity.md) |
| 17 | Branching: text runs merged whole, branches only their author sees in a live session (branches in live sessions, export settings and guides item by item, and history entries for creation and updates landed 2026-10-24) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) |
| 18 | Display P3 on screen: blocked upstream (egui-wgpu picks an 8-bit sRGB surface); gamut warning and soft-proof sRGB landed, as did several windows | Hardware | 4–8 | [hardware-parity](hardware-parity.md) |
| 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) |
@@ -447,10 +447,13 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
- **Landed (2026-10-24):** files dropped on the canvas, the Pages list and the Assets panel no
longer edit the design in Dev Mode (a dropped document still opens as a file of its own, and
the Pages list only opens pages).
- **Missing:** publishing mappings from the code repository with a CLI. An editor extension is out
of scope: the MCP server already serves design data, code and the connected snippets to agents
and editors. The file name field in the left panel still renames the file in Dev Mode
(`file.rename`); whether Figma allows that in Dev Mode needs checking before it changes.
- **Landed (2026-10-30):** `figcraft-cli connect publish <file.figcraft> [--mappings
figcraft.connect.json] [--dry-run]` publishes a code repository's mappings to a design file, so
code owners keep them with the code and publish them from CI ([cli.md](cli.md)).
- **Missing:** the file name field in the left panel still renames the file in Dev Mode
(`file.rename`); whether Figma allows that in Dev Mode needs checking before it changes. An
editor extension is out of scope: the MCP server already serves design data, code and the
connected snippets to agents and editors.
- **Estimate:** 1–2 h.
### 17. Branching and merging
@@ -562,6 +565,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | Gap 16: `figcraft-cli connect publish` publishes a repository's `figcraft.connect.json` to a design file (links by component name, warnings for links that don't fit, `--dry-run`, exit 1 when nothing links). The CLI part leaves the missing list; the Dev Mode file-rename check remains; estimate unchanged. Tests: `files::tests::connect_publish_links_components_and_saves_the_file`, the CLI argument tests |
| 2026-10-29 | minor | Colour picker review follow-up: opening the first picker preserves a text undo interaction; placement follows the rendered sidebar; opening controls track their widget ids while moving; linked selection-colour names toggle closed; gradient variable-picker presses keep the parent open. Regression tests cover each case; estimates unchanged. |
| 2026-10-29 | minor | Colour pickers dismiss on presses outside their card, including the sidebar, and open 8 pt beside the sidebar aligned to their control (lifted to fit the screen); switching targets resets a dragged position and commits pending edits. Eyedropper sampling stays open. UI regression tests cover added fills/strokes and page colours; headline estimates unchanged. |
| 2026-10-29 | minor | Gap 7: the browser draws a zoom's full render a band at a time (eight at most), so a deep zoom no longer holds the page for the whole render; pans and edits meanwhile are patched in once it is drawn. Numbers unchanged |
+2 -1
View File
File diff suppressed because one or more lines are too long