/** * cleanupOrphans * * Finds and removes all Figma nodes (pages, frames, components, variables, * and variable collections) that were tagged with the given `dsb_run_id` * by a previous build run. This is safe cleanup: it uses plugin data tags, * never name-prefix matching, so it cannot accidentally delete user-owned nodes. * * Use this when a build run fails mid-way and you need to reset to a clean * slate before retrying. The function traverses the entire document looking * for `dsb_run_id` plugin data matching `runId`. * * Variables and variable collections are handled separately (they are not * scene nodes and cannot be discovered via node traversal). * * @param {string} runId - The dsb_run_id value to match (e.g. "ds-build-2024-001"). * @returns {Promise<{ * removedCount: number, * removedIds: string[] * }>} */ async function cleanupOrphans(runId) { if (!runId) { throw new Error('cleanupOrphans: runId is required.') } const removedIds = [] const originalPage = figma.currentPage // --- Remove tagged scene nodes (pages, frames, components, etc.) --- // Collect pages to remove (can't remove during iteration) const pagesToRemove = [] for (const page of figma.root.children) { if (page.getPluginData('dsb_run_id') === runId) { pagesToRemove.push(page) continue } // Traverse all nodes on this page await figma.setCurrentPageAsync(page) const nodesToRemove = [] page.findAll((node) => { if (node.getPluginData('dsb_run_id') === runId) { nodesToRemove.push(node) return false // Don't descend — removing the parent removes its children } return true }) // Remove deepest nodes first (children before parents) to avoid // "parent no longer exists" errors const sorted = nodesToRemove.sort((a, b) => { // Sort by depth descending: deeper nodes first return getDepth(b) - getDepth(a) }) for (const node of sorted) { if (node && node.parent) { removedIds.push(node.id) node.remove() } } } // Remove tagged pages last for (const page of pagesToRemove) { // Cannot remove the last page in the document if (figma.root.children.length <= 1) { break } removedIds.push(page.id) page.remove() } // --- Remove tagged variables --- const allVariables = await figma.variables.getLocalVariablesAsync() for (const variable of allVariables) { if (variable.getPluginData('dsb_run_id') === runId) { removedIds.push(variable.id) variable.remove() } } // --- Remove tagged variable collections --- // Must be done after variables are removed const allCollections = await figma.variables.getLocalVariableCollectionsAsync() for (const collection of allCollections) { if (collection.getPluginData('dsb_run_id') === runId) { removedIds.push(collection.id) collection.remove() } } // Restore original page (if it still exists) try { await figma.setCurrentPageAsync(originalPage) } catch (_) { // Original page was removed — switch to first available page if (figma.root.children.length > 0) { await figma.setCurrentPageAsync(figma.root.children[0]) } } return { removedCount: removedIds.length, removedIds, } } /** * Returns the depth of a node in the document tree. * Root children (pages) have depth 1; their children have depth 2; etc. * * @param {BaseNode} node * @returns {number} */ function getDepth(node) { let depth = 0 let current = node while (current.parent) { depth++ current = current.parent } return depth }