@designtools/next-plugin 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -125,6 +125,448 @@ export function CodeSurface() {
125
125
  return target;
126
126
  }
127
127
 
128
+ // --- Component tree extraction (React Fiber) ---
129
+
130
+ // IDs of overlay elements to skip during tree building
131
+ const overlayIds = new Set(["tool-highlight", "tool-tooltip", "tool-selected", "codesurface-token-preview"]);
132
+
133
+ // Semantic HTML elements shown as structural landmarks
134
+ const semanticTags = new Set(["header", "main", "nav", "section", "article", "footer", "aside"]);
135
+
136
+ // Tags to skip even if authored — document-level elements and void
137
+ // elements that aren't meaningful to designers
138
+ const skipTags = new Set([
139
+ "html", "body", "head", // document structure
140
+ "br", "hr", "wbr", // void/formatting
141
+ "template", "slot", // shadow DOM
142
+ ]);
143
+
144
+ // React and Next.js framework components to hide from the tree
145
+ const frameworkPatterns = [
146
+ /^Fragment$/, /^Suspense$/, /^ErrorBoundary$/,
147
+ /^Provider$/, /^Consumer$/, /Context$/,
148
+ /^ForwardRef$/, /^Memo$/, /^Lazy$/,
149
+ // Next.js routing internals
150
+ /^InnerLayoutRouter$/, /^OuterLayoutRouter$/, /^LayoutRouter$/,
151
+ /^RenderFromTemplateContext$/, /^TemplateContext$/,
152
+ /^RedirectBoundary$/, /^RedirectErrorBoundary$/,
153
+ /^NotFoundBoundary$/, /^LoadingBoundary$/,
154
+ /^HTTPAccessFallbackBoundary$/, /^HTTPAccessFallbackErrorBoundary$/,
155
+ /^ClientPageRoot$/, /^HotReload$/, /^ReactDevOverlay$/,
156
+ /^PathnameContextProviderAdapter$/,
157
+ // Next.js App Router internals (segment tree)
158
+ /^SegmentViewNode$/, /^SegmentTrieNode$/,
159
+ /^SegmentViewStateNode$/, /^SegmentBoundaryTriggerNode$/,
160
+ /^SegmentStateProvider$/,
161
+ /^ScrollAndFocusHandler$/, /^InnerScrollAndFocusHandler$/,
162
+ /^AppRouter$/, /^Router$/, /^Root$/, /^ServerRoot$/,
163
+ /^RootErrorBoundary$/, /^ErrorBoundaryHandler$/,
164
+ /^AppRouterAnnouncer$/, /^HistoryUpdater$/, /^RuntimeStyles$/,
165
+ /^DevRootHTTPAccessFallbackBoundary$/,
166
+ /^AppDevOverlayErrorBoundary$/, /^ReplaySsrOnlyErrors$/,
167
+ /^HeadManagerContext$/, /^Head$/,
168
+ /^MetadataOutlet$/, /^AsyncMetadataOutlet$/,
169
+ /^__next_/, // All __next_ prefixed components
170
+ ];
171
+
172
+ function isFrameworkComponent(name: string): boolean {
173
+ return frameworkPatterns.some(p => p.test(name));
174
+ }
175
+
176
+ /**
177
+ * Get the React fiber for a DOM element.
178
+ * React attaches fibers via __reactFiber$<randomKey> in dev mode.
179
+ * Stable since React 17 through React 19.
180
+ */
181
+ function getFiber(el: Element): any | null {
182
+ const key = Object.keys(el).find(k => k.startsWith("__reactFiber$"));
183
+ return key ? (el as any)[key] : null;
184
+ }
185
+
186
+ /**
187
+ * Get direct text content of an element (not descendant text).
188
+ */
189
+ function getDirectText(el: Element): string {
190
+ let text = "";
191
+ for (const node of Array.from(el.childNodes)) {
192
+ if (node.nodeType === Node.TEXT_NODE) {
193
+ text += (node.textContent || "").trim();
194
+ }
195
+ }
196
+ return text.slice(0, 40);
197
+ }
198
+
199
+ interface TreeNode {
200
+ id: string;
201
+ name: string;
202
+ type: "component" | "element";
203
+ dataSlot: string | null;
204
+ source: string | null;
205
+ scope: "layout" | "page" | null;
206
+ textContent: string;
207
+ children: TreeNode[];
208
+ }
209
+
210
+ /**
211
+ * Infer routing scope from a data-source or data-instance-source path.
212
+ * Framework-specific: Next.js uses layout.tsx / page.tsx file naming.
213
+ */
214
+ function inferScope(sourcePath: string | null): "layout" | "page" | null {
215
+ if (!sourcePath) return null;
216
+ // data-source format is "file:line:col" — extract the file part
217
+ const colonIdx = sourcePath.indexOf(":");
218
+ const file = colonIdx > 0 ? sourcePath.slice(0, colonIdx) : sourcePath;
219
+ if (/\/layout\.[tjsx]+$/i.test(file) || /^layout\.[tjsx]+$/i.test(file)) return "layout";
220
+ if (/\/page\.[tjsx]+$/i.test(file) || /^page\.[tjsx]+$/i.test(file)) return "page";
221
+ return null;
222
+ }
223
+
224
+ /**
225
+ * Determine scope for a component by checking its instance-source
226
+ * (where it's used) or its own source (where it's defined).
227
+ * Instance source takes priority — a Button in layout.tsx has layout scope.
228
+ */
229
+ function getScopeForElement(el: Element | null, parentScope: "layout" | "page" | null): "layout" | "page" | null {
230
+ if (!el) return parentScope;
231
+ // Check instance-source first (where this component is used)
232
+ const instanceSource = el.getAttribute("data-instance-source");
233
+ const fromInstance = inferScope(instanceSource);
234
+ if (fromInstance) return fromInstance;
235
+ // Check own source (where this element is defined)
236
+ const source = el.getAttribute("data-source");
237
+ const fromSource = inferScope(source);
238
+ if (fromSource) return fromSource;
239
+ // Inherit from parent context
240
+ return parentScope;
241
+ }
242
+
243
+ /**
244
+ * Build a component tree by walking the React fiber tree.
245
+ * Filters to: user-defined components, data-slot components, semantic HTML.
246
+ */
247
+ function buildComponentTree(rootEl: Element): TreeNode[] {
248
+ const fiber = getFiber(rootEl);
249
+ if (!fiber) {
250
+ // Fallback: data-slot-only tree via DOM walk
251
+ return buildDataSlotTree(rootEl);
252
+ }
253
+
254
+ // Walk up to the fiber root
255
+ let fiberRoot = fiber;
256
+ while (fiberRoot.return) fiberRoot = fiberRoot.return;
257
+
258
+ const results: TreeNode[] = [];
259
+ walkFiber(fiberRoot.child, results, null);
260
+ return results;
261
+ }
262
+
263
+ function walkFiber(fiber: any | null, siblings: TreeNode[], parentScope: "layout" | "page" | null): void {
264
+ while (fiber) {
265
+ const node = processFiber(fiber, parentScope);
266
+ if (node) {
267
+ siblings.push(node);
268
+ } else {
269
+ // This fiber was filtered out — but still walk its children
270
+ // so nested visible components bubble up.
271
+ // Infer scope from this invisible fiber for its children.
272
+ if (fiber.child) {
273
+ let childScope = parentScope;
274
+ if (typeof fiber.type === "string" && fiber.stateNode instanceof Element) {
275
+ // Host element (div, html, body, etc.) — check its data-source
276
+ childScope = getScopeForElement(fiber.stateNode, parentScope);
277
+ } else if (typeof fiber.type === "function" || typeof fiber.type === "object") {
278
+ // Filtered-out component — check its root host element for scope.
279
+ // This catches cases like RootLayout -> <html data-source="app/layout.tsx:...">
280
+ // where the component itself is filtered but its root element carries scope.
281
+ const hostEl = findOwnHostElement(fiber);
282
+ if (hostEl) {
283
+ childScope = getScopeForElement(hostEl, parentScope);
284
+ }
285
+ }
286
+ walkFiber(fiber.child, siblings, childScope);
287
+ }
288
+ }
289
+ fiber = fiber.sibling;
290
+ }
291
+ }
292
+
293
+ function processFiber(fiber: any, parentScope: "layout" | "page" | null): TreeNode | null {
294
+ // Skip text nodes and fragments
295
+ if (typeof fiber.type === "string") {
296
+ // This is a host element (div, span, etc.)
297
+ return processHostFiber(fiber, parentScope);
298
+ }
299
+
300
+ if (typeof fiber.type === "function" || typeof fiber.type === "object") {
301
+ return processComponentFiber(fiber, parentScope);
302
+ }
303
+
304
+ // Other fiber types (portals, etc.) — walk children transparently
305
+ return null;
306
+ }
307
+
308
+ function processHostFiber(fiber: any, parentScope: "layout" | "page" | null): TreeNode | null {
309
+ const tag = fiber.type as string;
310
+ const el = fiber.stateNode as Element | null;
311
+
312
+ // Skip our overlay elements
313
+ if (el && el.id && overlayIds.has(el.id)) return null;
314
+
315
+ // Skip script, style, link, noscript
316
+ if (["script", "style", "link", "noscript"].includes(tag)) return null;
317
+
318
+ const scope = getScopeForElement(el, parentScope);
319
+
320
+ // Check for data-slot — this is a design system component root element
321
+ const dataSlot = el?.getAttribute("data-slot") || null;
322
+ if (dataSlot) {
323
+ const name = dataSlot.split("-").map((s: string) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
324
+ const children: TreeNode[] = [];
325
+ if (fiber.child) walkFiber(fiber.child, children, scope);
326
+ return {
327
+ id: el ? getDomPath(el) : "",
328
+ name,
329
+ type: "component",
330
+ dataSlot,
331
+ source: el?.getAttribute("data-source") || null,
332
+ scope,
333
+ textContent: el ? getDirectText(el) : "",
334
+ children,
335
+ };
336
+ }
337
+
338
+ // Show semantic HTML landmarks
339
+ if (semanticTags.has(tag)) {
340
+ const children: TreeNode[] = [];
341
+ if (fiber.child) walkFiber(fiber.child, children, scope);
342
+ const text = el ? getDirectText(el) : "";
343
+ if (children.length > 0 || text) {
344
+ return {
345
+ id: el ? getDomPath(el) : "",
346
+ name: `<${tag}>`,
347
+ type: "element",
348
+ dataSlot: null,
349
+ source: el?.getAttribute("data-source") || null,
350
+ scope,
351
+ textContent: text,
352
+ children,
353
+ };
354
+ }
355
+ }
356
+
357
+ // Show authored elements — data-source is added by our Babel transform
358
+ // to every JSX element, so its presence proves this was deliberately
359
+ // written in user code. Skip document/void tags that aren't meaningful.
360
+ if (el?.hasAttribute("data-source") && !skipTags.has(tag)) {
361
+ const children: TreeNode[] = [];
362
+ if (fiber.child) walkFiber(fiber.child, children, scope);
363
+ const text = el ? getDirectText(el) : "";
364
+ return {
365
+ id: getDomPath(el),
366
+ name: `<${tag}>`,
367
+ type: "element",
368
+ dataSlot: null,
369
+ source: el.getAttribute("data-source"),
370
+ scope,
371
+ textContent: text,
372
+ children,
373
+ };
374
+ }
375
+
376
+ // Generic containers and elements without data-source: skip this node,
377
+ // but walk children (children bubble up to parent's list via walkFiber)
378
+ return null;
379
+ }
380
+
381
+ function processComponentFiber(fiber: any, parentScope: "layout" | "page" | null): TreeNode | null {
382
+ // Get component name
383
+ const type = fiber.type;
384
+ const name = type?.displayName || type?.name || null;
385
+
386
+ // No name = anonymous component, skip
387
+ if (!name) return null;
388
+
389
+ // Skip framework internals
390
+ if (isFrameworkComponent(name)) return null;
391
+
392
+ // Skip the CodeSurface component itself
393
+ if (name === "CodeSurface") return null;
394
+
395
+ // Find this component's own root host element — only walk down through
396
+ // non-host fibers (other components, fragments, etc.) to find the first
397
+ // DOM element this component directly renders. Don't descend into child
398
+ // components, which would give us a different component's element.
399
+ const hostEl = findOwnHostElement(fiber);
400
+
401
+ // Check if this component comes from user code by looking for
402
+ // data-instance-source (set on component JSX by our Babel transform)
403
+ // on the host element. data-instance-source proves the component
404
+ // usage was in a user file processed by our loader.
405
+ // Also accept data-slot as proof of being a known component.
406
+ const hasInstanceSource = hostEl?.getAttribute("data-instance-source");
407
+ const hasDataSlot = hostEl?.getAttribute("data-slot");
408
+ if (!hasInstanceSource && !hasDataSlot) return null;
409
+
410
+ const scope = getScopeForElement(hostEl, parentScope);
411
+ const dataSlot = hasDataSlot || null;
412
+ const children: TreeNode[] = [];
413
+
414
+ // When this component's root host element has data-slot, the child
415
+ // walker would also pick it up via processHostFiber and create a
416
+ // duplicate node. To avoid that, find the host fiber and walk its
417
+ // children directly (skipping the host element itself).
418
+ const hostFiber = dataSlot ? findHostFiber(fiber) : null;
419
+ const childFiber = hostFiber ? hostFiber.child : fiber.child;
420
+ if (childFiber) walkFiber(childFiber, children, scope);
421
+
422
+ // Collapse: if this component has exactly one child component and no
423
+ // direct text, skip this wrapper and promote the child
424
+ if (children.length === 1 && !dataSlot && !(hostEl && getDirectText(hostEl))) {
425
+ const child = children[0];
426
+ if (child.type === "component") {
427
+ return child;
428
+ }
429
+ }
430
+
431
+ return {
432
+ id: hostEl ? getDomPath(hostEl) : "",
433
+ name,
434
+ type: "component",
435
+ dataSlot,
436
+ source: hostEl?.getAttribute("data-source") || null,
437
+ scope,
438
+ textContent: hostEl ? getDirectText(hostEl) : "",
439
+ children,
440
+ };
441
+ }
442
+
443
+ /**
444
+ * Find a component fiber's own root host fiber (not the element).
445
+ * Same walk as findOwnHostElement but returns the fiber itself,
446
+ * so we can skip it in the tree walk and avoid data-slot duplication.
447
+ */
448
+ function findHostFiber(fiber: any): any | null {
449
+ let child = fiber.child;
450
+ while (child) {
451
+ if (child.stateNode instanceof Element) return child;
452
+ const tag = child.tag;
453
+ const isComponentBoundary = tag === 0 || tag === 1 || tag === 11 || tag === 14 || tag === 15;
454
+ if (!isComponentBoundary && child.child) {
455
+ const found = findHostFiber(child);
456
+ if (found) return found;
457
+ }
458
+ child = child.sibling;
459
+ }
460
+ return null;
461
+ }
462
+
463
+ /**
464
+ * Find a component fiber's own root host DOM element.
465
+ * Walks through transparent fibers (fragments, mode, profiler) but
466
+ * stops at component boundaries (function/class/forwardRef/memo) to
467
+ * avoid descending into child components.
468
+ */
469
+ function findOwnHostElement(fiber: any): Element | null {
470
+ let child = fiber.child;
471
+ while (child) {
472
+ // Found a DOM element — this is our root host element
473
+ if (child.stateNode instanceof Element) return child.stateNode;
474
+
475
+ // Check fiber tag to determine if this is a component boundary.
476
+ // React fiber tags: 0=FunctionComponent, 1=ClassComponent,
477
+ // 11=ForwardRef, 14=MemoComponent, 15=SimpleMemoComponent.
478
+ // These are component boundaries — don't descend.
479
+ const tag = child.tag;
480
+ const isComponentBoundary = tag === 0 || tag === 1 || tag === 11 || tag === 14 || tag === 15;
481
+
482
+ if (!isComponentBoundary && child.child) {
483
+ // Transparent fiber (fragment, mode, context, etc.) — walk through
484
+ const found = findOwnHostElement(child);
485
+ if (found) return found;
486
+ }
487
+
488
+ child = child.sibling;
489
+ }
490
+ return null;
491
+ }
492
+
493
+ /**
494
+ * Fallback: build tree from DOM using only data-slot elements.
495
+ * Used when React fiber access is unavailable.
496
+ */
497
+ function buildDataSlotTree(root: Element): TreeNode[] {
498
+ const results: TreeNode[] = [];
499
+ for (const child of Array.from(root.children)) {
500
+ walkDomForSlots(child, results, null);
501
+ }
502
+ return results;
503
+ }
504
+
505
+ function walkDomForSlots(el: Element, siblings: TreeNode[], parentScope: "layout" | "page" | null): void {
506
+ // Skip overlay elements
507
+ if (el.id && overlayIds.has(el.id)) return;
508
+
509
+ const dataSlot = el.getAttribute("data-slot");
510
+ const tag = el.tagName.toLowerCase();
511
+ const scope = getScopeForElement(el, parentScope);
512
+
513
+ if (dataSlot) {
514
+ const name = dataSlot.split("-").map((s: string) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
515
+ const children: TreeNode[] = [];
516
+ for (const child of Array.from(el.children)) {
517
+ walkDomForSlots(child, children, scope);
518
+ }
519
+ siblings.push({
520
+ id: getDomPath(el),
521
+ name,
522
+ type: "component",
523
+ dataSlot,
524
+ source: el.getAttribute("data-source") || null,
525
+ scope,
526
+ textContent: getDirectText(el),
527
+ children,
528
+ });
529
+ } else if (semanticTags.has(tag)) {
530
+ const children: TreeNode[] = [];
531
+ for (const child of Array.from(el.children)) {
532
+ walkDomForSlots(child, children, scope);
533
+ }
534
+ if (children.length > 0 || getDirectText(el)) {
535
+ siblings.push({
536
+ id: getDomPath(el),
537
+ name: `<${tag}>`,
538
+ type: "element",
539
+ dataSlot: null,
540
+ source: el.getAttribute("data-source") || null,
541
+ scope,
542
+ textContent: getDirectText(el),
543
+ children,
544
+ });
545
+ }
546
+ } else {
547
+ // Skip this element, but walk its children
548
+ for (const child of Array.from(el.children)) {
549
+ walkDomForSlots(child, siblings, scope);
550
+ }
551
+ }
552
+ }
553
+
554
+ function sendComponentTree() {
555
+ const tree = buildComponentTree(document.body);
556
+ window.parent.postMessage({ type: "tool:componentTree", tree }, "*");
557
+ }
558
+
559
+ // Debounce helper for MutationObserver
560
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
561
+ function debouncedSendTree() {
562
+ if (debounceTimer) clearTimeout(debounceTimer);
563
+ debounceTimer = setTimeout(sendComponentTree, 300);
564
+ }
565
+
566
+ // MutationObserver to send updated tree on DOM changes (HMR, dynamic content)
567
+ const treeObserver = new MutationObserver(debouncedSendTree);
568
+ treeObserver.observe(document.body, { childList: true, subtree: true });
569
+
128
570
  const relevantProps = [
129
571
  "display", "position", "top", "right", "bottom", "left",
130
572
  "z-index", "overflow", "overflow-x", "overflow-y",
@@ -412,6 +854,38 @@ export function CodeSurface() {
412
854
  document.documentElement.classList.remove("dark");
413
855
  }
414
856
  break;
857
+ case "tool:requestComponentTree":
858
+ sendComponentTree();
859
+ break;
860
+ case "tool:highlightByTreeId": {
861
+ const id = msg.id as string;
862
+ if (!id || !s.highlightOverlay || !s.tooltip) break;
863
+ const target = document.querySelector(id);
864
+ if (target) {
865
+ const rect = target.getBoundingClientRect();
866
+ positionOverlay(s.highlightOverlay, rect);
867
+ const name = getElementName(target);
868
+ s.tooltip.textContent = name;
869
+ s.tooltip.style.display = "block";
870
+ s.tooltip.style.left = `${rect.left}px`;
871
+ s.tooltip.style.top = `${Math.max(0, rect.top - 24)}px`;
872
+ }
873
+ break;
874
+ }
875
+ case "tool:clearHighlight":
876
+ if (s.highlightOverlay) s.highlightOverlay.style.display = "none";
877
+ if (s.tooltip) s.tooltip.style.display = "none";
878
+ break;
879
+ case "tool:selectByTreeId": {
880
+ const id = msg.id as string;
881
+ if (!id) break;
882
+ const target = document.querySelector(id);
883
+ if (target) {
884
+ const selectable = findSelectableElement(target);
885
+ selectElement(selectable);
886
+ }
887
+ break;
888
+ }
415
889
  }
416
890
  }
417
891
 
@@ -439,6 +913,8 @@ export function CodeSurface() {
439
913
  window.removeEventListener("message", onMessage);
440
914
  window.removeEventListener("popstate", notifyPathChanged);
441
915
 
916
+ treeObserver.disconnect();
917
+ if (debounceTimer) clearTimeout(debounceTimer);
442
918
  if (s.overlayRafId) cancelAnimationFrame(s.overlayRafId);
443
919
  s.tokenPreviewStyle?.remove();
444
920
  s.highlightOverlay?.remove();
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import path from "path";
11
+ import { generatePreviewRoute } from "./preview-route.js";
11
12
 
12
13
  export function withDesigntools<T extends Record<string, any>>(nextConfig: T = {} as T): T {
13
14
  return {
@@ -41,6 +42,16 @@ export function withDesigntools<T extends Record<string, any>>(nextConfig: T = {
41
42
  },
42
43
  ],
43
44
  });
45
+
46
+ // Generate the component isolation preview route
47
+ // This creates app/__designtools/preview/page.tsx which Next.js
48
+ // picks up as a route automatically via file-system routing.
49
+ const appDir = path.resolve(context.dir, "app");
50
+ try {
51
+ generatePreviewRoute(appDir);
52
+ } catch {
53
+ // Non-fatal — isolation feature just won't work
54
+ }
44
55
  }
45
56
 
46
57
  // Call the user's webpack config if provided