Implement penpot_instance_component with id remapping #207

Closed
opened 2026-09-14 23:13:15 +00:00 by david · 1 comment
Owner

Summary

Implement penpot_instance_component: place a real Penpot component instance into a frame by cloning the component's main-instance shape subtree with fresh ids and correct parent wiring, so the result stays linked to the library component rather than being a detached copy.

Background

Depends on: #206

The field contract was validated end to end on Penpot 2.17 — the schema risk is retired and what remains is careful id remapping:

  • A component is identified by its entry in data.components (with mainInstanceId = the shape id of the frame that is the main instance, and mainInstancePage = its page). The main-instance marking lives on the component entry, not on the shape.
  • To instantiate: add-obj the root as a frame carrying componentId: <component id> and componentRoot: true; then add-obj each child with shapeRef: <the corresponding main-instance child's shape id>.
  • Children keep their own type and geometry; ids are freshly generated and parentId/frameId are rewired to the new instance's ids.
  • Applied in one update-file call this is atomic — which the staged-changeset architecture already provides.
  • Locating the components requires the consumer file to be linked to the library file (done in the previous milestone), and the instance needs the main instance's page objects, which live in the library file — so this step reads shapes from a different file than the one being written, and must pass the library file id through in the asset-reference fields where required.

Nested components (a component whose main instance itself contains an instance) are the known edge: handle a single level correctly and, if deeper nesting is out of scope, refuse with a clear message rather than silently producing a broken half-linked tree.

Documentation Required

A separate process downloads these into the listed folders before this issue is implemented. Check the folders for the actual reference material before starting.

docs/reference/penpot-api/

docs/reference/pi-coding-agent/ and docs/reference/typebox/

Implementation Details

Create extensions/penpot/src/instance.ts (pure remapping — the unit-tested core) and extensions/penpot/src/tools/instance.ts, and register the tool.

src/instance.ts

  • buildInstance({ component, mainInstanceShapes, rootId, x, y, parentId, pageId }) → an ordered list of add-obj changes:
    1. Generate a new id for the root and for every descendant.
    2. Build an old-id → new-id map, then a single pass rewriting: id, parentId, frameId, and each frame's shapes array entries.
    3. Root: type frame, componentId = the component id, componentRoot: true, positioned at x/y, parented to the target frame.
    4. Every descendant: shapeRef = the main-instance child's original shape id, with its own type and geometry, and parentId/frameId remapped.
    5. Preserve asset references (fillColorRefId/fillColorRefFile, typographyRefId/typographyRefFile) unchanged — they point at library assets, not at the instance.
    6. Return the changes parent-first (the order matters: update-file applies changes in order).
  • collectDescendants(objects, rootId) — walk a page's objects map into a parent→children-ordered list, and fail loudly on a cycle or a missing child link.
  • detectNestedInstance(shapes) — returns the offending shape id(s) if the subtree contains another instance root; used to refuse rather than half-build.

src/tools/instance.tspenpot_instance_component

Parameters: file_id (required — the consumer file being written), component_name (required; resolved to a component id via the linked-library index), page_id (optional), parent_id (required — the frame the instance is placed in), x, y (required), instance_name (optional; defaults to the component name).

Behaviour:

  1. Resolve component_name against the consumer file's components (including those from linked libraries). Missing or ambiguous → stage nothing, return the resolution error with candidates.
  2. Read the library file containing the component (componentFileId, or the linked library that owns it) and fetch the main instance's page objects.
  3. Refuse with a clear message if the component's main instance cannot be found, if the subtree contains a nested instance, or if the consumer file is not linked to the component's library — each with the specific remedy.
  4. Build the changes, stage them, and return the new root id in details plus a preview line naming the component.
  5. Do not write; penpot_commit applies the instance atomically with whatever else is staged.

Acceptance Criteria

  • Instantiating produces an add-obj for the root with componentId set and componentRoot: true, and one add-obj per descendant with shapeRef pointing at the corresponding main-instance shape id.
  • Every generated id is unique within the batch, and no original main-instance id appears in the staged changes.
  • parentId/frameId and frame shapes arrays are fully remapped to the new ids.
  • Changes are emitted parent-first so update-file can apply them in order.
  • Asset references (fillColorRefId/fillColorRefFile, typographyRefId/typographyRefFile) are preserved unchanged.
  • The instance is positioned at x/y inside parent_id, not at the main instance's original coordinates.
  • An unknown or ambiguous component name stages nothing and returns candidates.
  • A nested-instance subtree is refused with a clear message identifying the offending shape, with nothing staged.
  • An unlinked library is refused with a message pointing at penpot_link_library.
  • Unit tests cover the remapping map, ordering, asset-reference preservation, positioning, and each refusal path, with no network access.

Test Plan

node --test extensions/penpot/src/instance.test.ts extensions/penpot/src/tools/instance.test.ts

Live validation (requires PENPOT_URL/PENPOT_TOKEN, scratch project with the imported library linked to a consumer file):

  1. Stage a frame, then penpot_instance_component for the Button component inside it; commit.
  2. Confirm the instance renders in the Penpot UI inside the frame, with the component's appearance and the requested layer name.
  3. In the UI, inspect the instance's properties panel and confirm it is reported as an instance of the component (not a plain frame/group).
  4. Recount the file's ids: confirm no staged id collides with an existing shape id.
## Summary Implement `penpot_instance_component`: place a real Penpot component **instance** into a frame by cloning the component's main-instance shape subtree with fresh ids and correct parent wiring, so the result stays linked to the library component rather than being a detached copy. ## Background **Depends on:** #206 The field contract was validated end to end on Penpot 2.17 — the schema risk is retired and what remains is careful id remapping: - A component is identified by its entry in `data.components` (with `mainInstanceId` = the shape id of the frame that is the main instance, and `mainInstancePage` = its page). The main-instance marking lives on the **component entry**, not on the shape. - To instantiate: `add-obj` the **root** as a frame carrying `componentId: <component id>` and `componentRoot: true`; then `add-obj` **each child** with `shapeRef: <the corresponding main-instance child's shape id>`. - Children keep their own `type` and geometry; **ids are freshly generated** and `parentId`/`frameId` are rewired to the new instance's ids. - Applied in one `update-file` call this is atomic — which the staged-changeset architecture already provides. - Locating the components requires the consumer file to be **linked** to the library file (done in the previous milestone), and the instance needs the main instance's page objects, which live in the **library file** — so this step reads shapes from a different file than the one being written, and must pass the library file id through in the asset-reference fields where required. Nested components (a component whose main instance itself contains an instance) are the known edge: handle a single level correctly and, if deeper nesting is out of scope, **refuse with a clear message** rather than silently producing a broken half-linked tree. ## Documentation Required A separate process downloads these into the listed folders before this issue is implemented. Check the folders for the actual reference material before starting. **`docs/reference/penpot-api/`** - https://raw.githubusercontent.com/penpot/penpot/2.17.2/common/src/app/common/types/component.cljc — the component record: `mainInstanceId`, `mainInstancePage`, `path`, `name`, and how a component is identified. - https://raw.githubusercontent.com/penpot/penpot/2.17.2/common/src/app/common/types/shape.cljc — the shape fields involved in instancing: `componentId`, `componentRoot`, `shapeRef`, plus the full shape payload the root and children must still carry. - https://raw.githubusercontent.com/penpot/penpot/2.17.2/common/src/app/common/types/components_list.cljc — how components are stored on a file (`data.components`). - https://raw.githubusercontent.com/penpot/penpot/2.17.2/common/src/app/common/types/shape_tree.cljc — parent/child wiring and `shapes` arrays, for the remapping step. - `<PENPOT_URL>/api/main/doc/openapi.json` — `get-file` (to read the main instance's page objects) and `update-file`/`add-obj`. - https://help.penpot.app/user-guide/design-systems/components/ — what an instance is to a designer, including "edit main component updates instances", so the tool description is accurate about what it produces. **`docs/reference/pi-coding-agent/`** and **`docs/reference/typebox/`** - https://pi.dev/docs/latest/extensions — tool registration and structured `details`. - https://github.com/sinclairzx81/typebox — string/number parameter schemas. ## Implementation Details Create `extensions/penpot/src/instance.ts` (pure remapping — the unit-tested core) and `extensions/penpot/src/tools/instance.ts`, and register the tool. ### `src/instance.ts` - `buildInstance({ component, mainInstanceShapes, rootId, x, y, parentId, pageId })` → an ordered list of `add-obj` changes: 1. Generate a new id for the root and for every descendant. 2. Build an old-id → new-id map, then a single pass rewriting: `id`, `parentId`, `frameId`, and each frame's `shapes` array entries. 3. Root: type `frame`, `componentId` = the component id, `componentRoot: true`, positioned at `x`/`y`, parented to the target frame. 4. Every descendant: `shapeRef` = the **main-instance child's original shape id**, with its own `type` and geometry, and `parentId`/`frameId` remapped. 5. Preserve asset references (`fillColorRefId`/`fillColorRefFile`, `typographyRefId`/`typographyRefFile`) unchanged — they point at library assets, not at the instance. 6. Return the changes **parent-first** (the order matters: `update-file` applies changes in order). - `collectDescendants(objects, rootId)` — walk a page's `objects` map into a parent→children-ordered list, and fail loudly on a cycle or a missing child link. - `detectNestedInstance(shapes)` — returns the offending shape id(s) if the subtree contains another instance root; used to refuse rather than half-build. ### `src/tools/instance.ts` — `penpot_instance_component` Parameters: `file_id` (required — the consumer file being written), `component_name` (required; resolved to a component id via the linked-library index), `page_id` (optional), `parent_id` (required — the frame the instance is placed in), `x`, `y` (required), `instance_name` (optional; defaults to the component name). Behaviour: 1. Resolve `component_name` against the consumer file's components (including those from linked libraries). Missing or ambiguous → stage nothing, return the resolution error with candidates. 2. Read the library file containing the component (`componentFileId`, or the linked library that owns it) and fetch the main instance's page objects. 3. Refuse with a clear message if the component's main instance cannot be found, if the subtree contains a nested instance, or if the consumer file is not linked to the component's library — each with the specific remedy. 4. Build the changes, stage them, and return the new root id in `details` plus a preview line naming the component. 5. Do not write; `penpot_commit` applies the instance atomically with whatever else is staged. ## Acceptance Criteria - [ ] Instantiating produces an `add-obj` for the root with `componentId` set and `componentRoot: true`, and one `add-obj` per descendant with `shapeRef` pointing at the corresponding main-instance shape id. - [ ] Every generated id is unique within the batch, and no original main-instance id appears in the staged changes. - [ ] `parentId`/`frameId` and frame `shapes` arrays are fully remapped to the new ids. - [ ] Changes are emitted parent-first so `update-file` can apply them in order. - [ ] Asset references (`fillColorRefId`/`fillColorRefFile`, `typographyRefId`/`typographyRefFile`) are preserved unchanged. - [ ] The instance is positioned at `x`/`y` inside `parent_id`, not at the main instance's original coordinates. - [ ] An unknown or ambiguous component name stages nothing and returns candidates. - [ ] A nested-instance subtree is refused with a clear message identifying the offending shape, with nothing staged. - [ ] An unlinked library is refused with a message pointing at `penpot_link_library`. - [ ] Unit tests cover the remapping map, ordering, asset-reference preservation, positioning, and each refusal path, with no network access. ## Test Plan ```bash node --test extensions/penpot/src/instance.test.ts extensions/penpot/src/tools/instance.test.ts ``` Live validation (requires `PENPOT_URL`/`PENPOT_TOKEN`, scratch project with the imported library linked to a consumer file): 1. Stage a frame, then `penpot_instance_component` for the Button component inside it; commit. 2. Confirm the instance renders in the Penpot UI inside the frame, with the component's appearance and the requested layer name. 3. In the UI, inspect the instance's properties panel and confirm it is reported as an **instance** of the component (not a plain frame/group). 4. Recount the file's ids: confirm no staged id collides with an existing shape id.
david closed this issue 2026-09-15 05:31:21 +00:00
Author
Owner

pi-loop opened and merged a pull request for this issue: #240

pi-loop opened and merged a pull request for this issue: https://git.excelera.net/david/pi-extensions-and-skills/pulls/240
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
david/pi-extensions-and-skills#207
No description provided.