diff --git a/artifacts/wave31-peter-route-identity/route-identity-report.json b/artifacts/wave31-peter-route-identity/route-identity-report.json new file mode 100644 index 00000000..43710b66 --- /dev/null +++ b/artifacts/wave31-peter-route-identity/route-identity-report.json @@ -0,0 +1,4406 @@ +{ + "schema": "blocks-engine/figma-transformer/route-identity-report/v1", + "input_file": "tool_f2ac936a7001ta6RkEGDr1iaCb", + "input_sha256": "926668fbb0884db25739a411fcb299c4e8095743af9f4a30137cb2fb68a9b244", + "status": "success_with_warnings", + "summary": { + "page_count": 41, + "candidate_count": 169, + "selection_source": "heuristic", + "duplicate_draft_frame_count": 3, + "duplicate_route_draft_frame_count": 39, + "anchors_emitted": 139, + "node_link_count": 14, + "url_link_count": 3, + "implicit_route_link_count": 134, + "implicit_route_unresolved_count": 0, + "unresolved_link_count": 12, + "unique_route_target_count": 41 + }, + "pages": [ + { + "index": 0, + "frame_id": "1706:2975", + "name": "Homepage", + "slug": "homepage", + "path": "index.html", + "entrypoint": true, + "page_type": "front_page", + "figma_page_name": "Design v3", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1706:2975" + ], + "source_identity": { + "selected_frame_id": "1706:2975", + "primary_frame_id": "1706:2975", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 1, + "frame_id": "3802:3316", + "name": "01 Home - Mobile", + "slug": "01-home-mobile", + "path": "01-home-mobile.html", + "entrypoint": false, + "page_type": "front_page", + "figma_page_name": "Backgrounds - for dev", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "3802:3316" + ], + "source_identity": { + "selected_frame_id": "3802:3316", + "primary_frame_id": "3802:3316", + "selected_is_primary": true, + "device_hint": "mobile" + } + }, + { + "index": 2, + "frame_id": "1706:810", + "name": "Course", + "slug": "course", + "path": "course.html", + "entrypoint": false, + "page_type": "archive", + "figma_page_name": "Design v3", + "section_name": null, + "responsive": true, + "variant_frame_ids": [ + "1706:810", + "1706:1238" + ], + "source_identity": { + "selected_frame_id": "1706:810", + "primary_frame_id": "1706:810", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 3, + "frame_id": "1921:1965", + "name": "Course", + "slug": "course-2", + "path": "course-2.html", + "entrypoint": false, + "page_type": "archive", + "figma_page_name": "Design v4", + "section_name": null, + "responsive": true, + "variant_frame_ids": [ + "1921:1965", + "1921:2541" + ], + "source_identity": { + "selected_frame_id": "1921:1965", + "primary_frame_id": "1921:1965", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 4, + "frame_id": "1573:495", + "name": "Course", + "slug": "course-3", + "path": "course-3.html", + "entrypoint": false, + "page_type": "archive", + "figma_page_name": "Design v2", + "section_name": null, + "responsive": true, + "variant_frame_ids": [ + "1573:495", + "1573:1213" + ], + "source_identity": { + "selected_frame_id": "1573:495", + "primary_frame_id": "1573:495", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 5, + "frame_id": "1921:7712", + "name": "Sales Page", + "slug": "sales-page", + "path": "sales-page.html", + "entrypoint": false, + "page_type": "page", + "figma_page_name": "Design v4", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1921:7712" + ], + "source_identity": { + "selected_frame_id": "1921:7712", + "primary_frame_id": "1921:7712", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 6, + "frame_id": "1522:464", + "name": "My Account", + "slug": "my-account", + "path": "my-account.html", + "entrypoint": false, + "page_type": "page", + "figma_page_name": "Design", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1522:464" + ], + "source_identity": { + "selected_frame_id": "1522:464", + "primary_frame_id": "1522:464", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 7, + "frame_id": "1573:677", + "name": "My Account", + "slug": "my-account-2", + "path": "my-account-2.html", + "entrypoint": false, + "page_type": "single", + "figma_page_name": "Design v2", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1573:677" + ], + "source_identity": { + "selected_frame_id": "1573:677", + "primary_frame_id": "1573:677", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 8, + "frame_id": "1921:2491", + "name": "My Account", + "slug": "my-account-3", + "path": "my-account-3.html", + "entrypoint": false, + "page_type": "single", + "figma_page_name": "Design v4", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1921:2491" + ], + "source_identity": { + "selected_frame_id": "1921:2491", + "primary_frame_id": "1921:2491", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 9, + "frame_id": "1706:3956", + "name": "Cart", + "slug": "cart", + "path": "cart.html", + "entrypoint": false, + "page_type": "page", + "figma_page_name": "Design v3", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1706:3956" + ], + "source_identity": { + "selected_frame_id": "1706:3956", + "primary_frame_id": "1706:3956", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 10, + "frame_id": "1921:1777", + "name": "Cart", + "slug": "cart-2", + "path": "cart-2.html", + "entrypoint": false, + "page_type": "page", + "figma_page_name": "Design v4", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1921:1777" + ], + "source_identity": { + "selected_frame_id": "1921:1777", + "primary_frame_id": "1921:1777", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 11, + "frame_id": "3891:3396", + "name": "Course - Overview", + "slug": "course-overview", + "path": "course-overview.html", + "entrypoint": false, + "page_type": "archive", + "figma_page_name": "Design v5", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "3891:3396" + ], + "source_identity": { + "selected_frame_id": "3891:3396", + "primary_frame_id": "3891:3396", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 12, + "frame_id": "1921:2236", + "name": "Lesson - Notes Open", + "slug": "lesson-notes-open", + "path": "lesson-notes-open.html", + "entrypoint": false, + "page_type": "archive", + "figma_page_name": "Design v4", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1921:2236" + ], + "source_identity": { + "selected_frame_id": "1921:2236", + "primary_frame_id": "1921:2236", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 13, + "frame_id": "4034:8593", + "name": "Lesson - Option 2", + "slug": "lesson-option-2", + "path": "lesson-option-2.html", + "entrypoint": false, + "page_type": "archive", + "figma_page_name": "Design v7", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "4034:8593" + ], + "source_identity": { + "selected_frame_id": "4034:8593", + "primary_frame_id": "4034:8593", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 14, + "frame_id": "3802:3285", + "name": "landing", + "slug": "landing", + "path": "landing.html", + "entrypoint": false, + "page_type": "front_page", + "figma_page_name": "Backgrounds - for dev", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "3802:3285" + ], + "source_identity": { + "selected_frame_id": "3802:3285", + "primary_frame_id": "3802:3285", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 15, + "frame_id": "1706:2541", + "name": "Lesson - Notes Open", + "slug": "lesson-notes-open-2", + "path": "lesson-notes-open-2.html", + "entrypoint": false, + "page_type": "archive", + "figma_page_name": "Design v3", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1706:2541" + ], + "source_identity": { + "selected_frame_id": "1706:2541", + "primary_frame_id": "1706:2541", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 16, + "frame_id": "1921:7870", + "name": "My Notes", + "slug": "my-notes", + "path": "my-notes.html", + "entrypoint": false, + "page_type": "single", + "figma_page_name": "Design v4", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1921:7870" + ], + "source_identity": { + "selected_frame_id": "1921:7870", + "primary_frame_id": "1921:7870", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 17, + "frame_id": "1706:1073", + "name": "Lesson- Dive Deeper", + "slug": "lesson-dive-deeper", + "path": "lesson-dive-deeper.html", + "entrypoint": false, + "page_type": "archive", + "figma_page_name": "Design v3", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1706:1073" + ], + "source_identity": { + "selected_frame_id": "1706:1073", + "primary_frame_id": "1706:1073", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 18, + "frame_id": "1944:8961", + "name": "Lesson- Dive Deeper", + "slug": "lesson-dive-deeper-2", + "path": "lesson-dive-deeper-2.html", + "entrypoint": false, + "page_type": "archive", + "figma_page_name": "Design v4 - Shared", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1944:8961" + ], + "source_identity": { + "selected_frame_id": "1944:8961", + "primary_frame_id": "1944:8961", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 19, + "frame_id": "1706:2292", + "name": "Lesson- Lesson Notes", + "slug": "lesson-lesson-notes", + "path": "lesson-lesson-notes.html", + "entrypoint": false, + "page_type": "single", + "figma_page_name": "Design v3", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1706:2292" + ], + "source_identity": { + "selected_frame_id": "1706:2292", + "primary_frame_id": "1706:2292", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 20, + "frame_id": "3893:6348", + "name": "Course - Overview", + "slug": "course-overview-2", + "path": "course-overview-2.html", + "entrypoint": false, + "page_type": "single", + "figma_page_name": "Design v6 - Shared", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "3893:6348" + ], + "source_identity": { + "selected_frame_id": "3893:6348", + "primary_frame_id": "3893:6348", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 21, + "frame_id": "883:5461", + "name": "About", + "slug": "about", + "path": "about.html", + "entrypoint": false, + "page_type": "page", + "figma_page_name": "Wireframes", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "883:5461" + ], + "source_identity": { + "selected_frame_id": "883:5461", + "primary_frame_id": "883:5461", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 22, + "frame_id": "1585:916", + "name": "Lesson- Dive Deeper", + "slug": "lesson-dive-deeper-3", + "path": "lesson-dive-deeper-3.html", + "entrypoint": false, + "page_type": "archive", + "figma_page_name": "Design v2", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1585:916" + ], + "source_identity": { + "selected_frame_id": "1585:916", + "primary_frame_id": "1585:916", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 23, + "frame_id": "1921:2329", + "name": "Lesson- Dive Deeper", + "slug": "lesson-dive-deeper-4", + "path": "lesson-dive-deeper-4.html", + "entrypoint": false, + "page_type": "archive", + "figma_page_name": "Design v4", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1921:2329" + ], + "source_identity": { + "selected_frame_id": "1921:2329", + "primary_frame_id": "1921:2329", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 24, + "frame_id": "1921:2423", + "name": "Lesson- Lesson Notes", + "slug": "lesson-lesson-notes-2", + "path": "lesson-lesson-notes-2.html", + "entrypoint": false, + "page_type": "single", + "figma_page_name": "Design v4", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1921:2423" + ], + "source_identity": { + "selected_frame_id": "1921:2423", + "primary_frame_id": "1921:2423", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 25, + "frame_id": "1706:1001", + "name": "Lesson", + "slug": "lesson", + "path": "lesson.html", + "entrypoint": false, + "page_type": "archive", + "figma_page_name": "Design v3", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1706:1001" + ], + "source_identity": { + "selected_frame_id": "1706:1001", + "primary_frame_id": "1706:1001", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 26, + "frame_id": "1573:561", + "name": "Lesson", + "slug": "lesson-2", + "path": "lesson-2.html", + "entrypoint": false, + "page_type": "archive", + "figma_page_name": "Design v2", + "section_name": null, + "responsive": true, + "variant_frame_ids": [ + "1573:561", + "1573:2738" + ], + "source_identity": { + "selected_frame_id": "1573:2738", + "primary_frame_id": "1573:561", + "selected_is_primary": false, + "device_hint": "desktop" + } + }, + { + "index": 27, + "frame_id": "1921:2160", + "name": "Lesson", + "slug": "lesson-3", + "path": "lesson-3.html", + "entrypoint": false, + "page_type": "archive", + "figma_page_name": "Design v4", + "section_name": null, + "responsive": true, + "variant_frame_ids": [ + "1921:2160", + "1921:2705" + ], + "source_identity": { + "selected_frame_id": "1921:2160", + "primary_frame_id": "1921:2160", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 28, + "frame_id": "1522:431", + "name": "Quiz", + "slug": "quiz", + "path": "quiz.html", + "entrypoint": false, + "page_type": "single", + "figma_page_name": "Design", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1522:431" + ], + "source_identity": { + "selected_frame_id": "1522:431", + "primary_frame_id": "1522:431", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 29, + "frame_id": "1522:407", + "name": "Lesson", + "slug": "lesson-4", + "path": "lesson-4.html", + "entrypoint": false, + "page_type": "single", + "figma_page_name": "Design", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1522:407" + ], + "source_identity": { + "selected_frame_id": "1522:407", + "primary_frame_id": "1522:407", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 30, + "frame_id": "4028:8260", + "name": "Homework - Option 1", + "slug": "homework-option-1", + "path": "homework-option-1.html", + "entrypoint": false, + "page_type": "archive", + "figma_page_name": "Design v7", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "4028:8260" + ], + "source_identity": { + "selected_frame_id": "4028:8260", + "primary_frame_id": "4028:8260", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 31, + "frame_id": "1573:891", + "name": "Sign Up", + "slug": "sign-up", + "path": "sign-up.html", + "entrypoint": false, + "page_type": "single", + "figma_page_name": "Design v2", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1573:891" + ], + "source_identity": { + "selected_frame_id": "1573:891", + "primary_frame_id": "1573:891", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 32, + "frame_id": "1921:1734", + "name": "Sign Up", + "slug": "sign-up-2", + "path": "sign-up-2.html", + "entrypoint": false, + "page_type": "single", + "figma_page_name": "Design v4", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1921:1734" + ], + "source_identity": { + "selected_frame_id": "1921:1734", + "primary_frame_id": "1921:1734", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 33, + "frame_id": "883:290", + "name": "Course", + "slug": "course-4", + "path": "course-4.html", + "entrypoint": false, + "page_type": "single", + "figma_page_name": "Wireframes", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "883:290" + ], + "source_identity": { + "selected_frame_id": "883:290", + "primary_frame_id": "883:290", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 34, + "frame_id": "883:487", + "name": "Quiz", + "slug": "quiz-2", + "path": "quiz-2.html", + "entrypoint": false, + "page_type": "single", + "figma_page_name": "Wireframes", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "883:487" + ], + "source_identity": { + "selected_frame_id": "883:487", + "primary_frame_id": "883:487", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 35, + "frame_id": "1441:383", + "name": "All Courses", + "slug": "all-courses", + "path": "all-courses.html", + "entrypoint": false, + "page_type": "page", + "figma_page_name": "💀", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "1441:383" + ], + "source_identity": { + "selected_frame_id": "1441:383", + "primary_frame_id": "1441:383", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 36, + "frame_id": "3802:3074", + "name": "video-04", + "slug": "video-04", + "path": "video-04.html", + "entrypoint": false, + "page_type": "page", + "figma_page_name": "Backgrounds - for dev", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "3802:3074" + ], + "source_identity": { + "selected_frame_id": "3802:3074", + "primary_frame_id": "3802:3074", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 37, + "frame_id": "3802:3776", + "name": "course-bg", + "slug": "course-bg", + "path": "course-bg.html", + "entrypoint": false, + "page_type": "page", + "figma_page_name": "Backgrounds - for dev", + "section_name": null, + "responsive": true, + "variant_frame_ids": [ + "3802:3776", + "3802:3802" + ], + "source_identity": { + "selected_frame_id": "3802:3776", + "primary_frame_id": "3802:3776", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 38, + "frame_id": "5034:3874", + "name": "Module (month) - Option 1", + "slug": "module-month-option-1", + "path": "module-month-option-1.html", + "entrypoint": false, + "page_type": "unknown", + "figma_page_name": "Search bar - Feb '23", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "5034:3874" + ], + "source_identity": { + "selected_frame_id": "5034:3874", + "primary_frame_id": "5034:3874", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 39, + "frame_id": "883:5404", + "name": "The Course", + "slug": "the-course", + "path": "the-course.html", + "entrypoint": false, + "page_type": "page", + "figma_page_name": "Wireframes", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "883:5404" + ], + "source_identity": { + "selected_frame_id": "883:5404", + "primary_frame_id": "883:5404", + "selected_is_primary": true, + "device_hint": "desktop" + } + }, + { + "index": 40, + "frame_id": "5037:4997", + "name": "Course - Mobile", + "slug": "course-mobile", + "path": "course-mobile.html", + "entrypoint": false, + "page_type": "page", + "figma_page_name": "Search bar - Feb '23", + "section_name": null, + "responsive": false, + "variant_frame_ids": [ + "5037:4997" + ], + "source_identity": { + "selected_frame_id": "5037:4997", + "primary_frame_id": "5037:4997", + "selected_is_primary": true, + "device_hint": "mobile" + } + } + ], + "path_by_frame_id": { + "1706:2975": "index.html", + "3802:3316": "01-home-mobile.html", + "1706:810": "course.html", + "1706:1238": "course.html", + "1921:1965": "course-2.html", + "1921:2541": "course-2.html", + "1573:495": "course-3.html", + "1573:1213": "course-3.html", + "1921:7712": "sales-page.html", + "1522:464": "my-account.html", + "1573:677": "my-account-2.html", + "1921:2491": "my-account-3.html", + "1706:3956": "cart.html", + "1921:1777": "cart-2.html", + "3891:3396": "course-overview.html", + "1921:2236": "lesson-notes-open.html", + "4034:8593": "lesson-option-2.html", + "3802:3285": "landing.html", + "1706:2541": "lesson-notes-open-2.html", + "1921:7870": "my-notes.html", + "1706:1073": "lesson-dive-deeper.html", + "1944:8961": "lesson-dive-deeper-2.html", + "1706:2292": "lesson-lesson-notes.html", + "3893:6348": "course-overview-2.html", + "883:5461": "about.html", + "1585:916": "lesson-dive-deeper-3.html", + "1921:2329": "lesson-dive-deeper-4.html", + "1921:2423": "lesson-lesson-notes-2.html", + "1706:1001": "lesson.html", + "1573:561": "lesson-2.html", + "1573:2738": "lesson-2.html", + "1921:2160": "lesson-3.html", + "1921:2705": "lesson-3.html", + "1522:431": "quiz.html", + "1522:407": "lesson-4.html", + "4028:8260": "homework-option-1.html", + "1573:891": "sign-up.html", + "1921:1734": "sign-up-2.html", + "883:290": "course-4.html", + "883:487": "quiz-2.html", + "1441:383": "all-courses.html", + "3802:3074": "video-04.html", + "3802:3776": "course-bg.html", + "3802:3802": "course-bg.html", + "5034:3874": "module-month-option-1.html", + "883:5404": "the-course.html", + "5037:4997": "course-mobile.html" + }, + "path_by_slug": { + "homepage": "index.html", + "01-home-mobile": "01-home-mobile.html", + "course": "course.html", + "course-2": "course-2.html", + "course-3": "course-3.html", + "sales-page": "sales-page.html", + "my-account": "my-account.html", + "my-account-2": "my-account-2.html", + "my-account-3": "my-account-3.html", + "cart": "cart.html", + "cart-2": "cart-2.html", + "course-overview": "course-overview.html", + "lesson-notes-open": "lesson-notes-open.html", + "lesson-option-2": "lesson-option-2.html", + "landing": "landing.html", + "lesson-notes-open-2": "lesson-notes-open-2.html", + "my-notes": "my-notes.html", + "lesson-dive-deeper": "lesson-dive-deeper.html", + "lesson-dive-deeper-2": "lesson-dive-deeper-2.html", + "lesson-lesson-notes": "lesson-lesson-notes.html", + "course-overview-2": "course-overview-2.html", + "about": "about.html", + "lesson-dive-deeper-3": "lesson-dive-deeper-3.html", + "lesson-dive-deeper-4": "lesson-dive-deeper-4.html", + "lesson-lesson-notes-2": "lesson-lesson-notes-2.html", + "lesson": "lesson.html", + "lesson-2": "lesson-2.html", + "lesson-3": "lesson-3.html", + "quiz": "quiz.html", + "lesson-4": "lesson-4.html", + "homework-option-1": "homework-option-1.html", + "sign-up": "sign-up.html", + "sign-up-2": "sign-up-2.html", + "course-4": "course-4.html", + "quiz-2": "quiz-2.html", + "all-courses": "all-courses.html", + "video-04": "video-04.html", + "course-bg": "course-bg.html", + "module-month-option-1": "module-month-option-1.html", + "the-course": "the-course.html", + "course-mobile": "course-mobile.html" + }, + "duplicate_draft_frames": [ + { + "canonical_frame_id": "8:111", + "canonical_path": null, + "draft_frame_ids": [ + "8:124" + ], + "device_hint": "desktop", + "width": 1440 + }, + { + "canonical_frame_id": "168:201", + "canonical_path": null, + "draft_frame_ids": [ + "168:230" + ], + "device_hint": "desktop", + "width": 1440 + }, + { + "canonical_frame_id": "4034:8593", + "canonical_path": "lesson-option-2.html", + "draft_frame_ids": [ + "4028:8038" + ], + "device_hint": "desktop", + "width": 1440 + } + ], + "duplicate_route_draft_frames": [ + { + "route_identity": "homepage", + "canonical_frame_id": "1706:2975", + "canonical_path": "index.html", + "draft_frame_ids": [ + "1921:1459", + "1944:8052", + "3891:2833" + ] + }, + { + "route_identity": "course", + "canonical_frame_id": "1921:1965", + "canonical_path": "course-2.html", + "draft_frame_ids": [ + "1944:8615", + "3802:2789" + ] + }, + { + "route_identity": "sales-page", + "canonical_frame_id": "1921:7712", + "canonical_path": "sales-page.html", + "draft_frame_ids": [ + "1944:8370", + "3891:3151" + ] + }, + { + "route_identity": "my-account", + "canonical_frame_id": "1522:464", + "canonical_path": "my-account.html", + "draft_frame_ids": [ + "883:569" + ] + }, + { + "route_identity": "my-account", + "canonical_frame_id": "1573:677", + "canonical_path": "my-account-2.html", + "draft_frame_ids": [ + "1706:1145" + ] + }, + { + "route_identity": "my-account", + "canonical_frame_id": "1921:2491", + "canonical_path": "my-account-3.html", + "draft_frame_ids": [ + "1944:9144", + "3891:3933" + ] + }, + { + "route_identity": "cart", + "canonical_frame_id": "1921:1777", + "canonical_path": "cart-2.html", + "draft_frame_ids": [ + "1944:8427", + "3891:3208" + ] + }, + { + "route_identity": "course-mobile", + "canonical_frame_id": "1706:1238", + "canonical_path": "course.html", + "draft_frame_ids": [ + "1921:2541", + "1944:9194", + "3891:3983" + ] + }, + { + "route_identity": "lesson-notes-open", + "canonical_frame_id": "1921:2236", + "canonical_path": "lesson-notes-open.html", + "draft_frame_ids": [ + "1944:8876", + "3891:3657" + ] + }, + { + "route_identity": "my-notes", + "canonical_frame_id": "1921:7870", + "canonical_path": "my-notes.html", + "draft_frame_ids": [ + "1944:9086", + "3891:3875" + ] + }, + { + "route_identity": "lesson-dive-deeper", + "canonical_frame_id": "1944:8961", + "canonical_path": "lesson-dive-deeper-2.html", + "draft_frame_ids": [ + "3891:3742" + ] + }, + { + "route_identity": "course-overview", + "canonical_frame_id": "3893:6348", + "canonical_path": "course-overview-2.html", + "draft_frame_ids": [ + "4028:7891" + ] + }, + { + "route_identity": "lesson-lesson-notes", + "canonical_frame_id": "1921:2423", + "canonical_path": "lesson-lesson-notes-2.html", + "draft_frame_ids": [ + "1944:9039", + "3891:3828" + ] + }, + { + "route_identity": "lesson", + "canonical_frame_id": "1921:2160", + "canonical_path": "lesson-3.html", + "draft_frame_ids": [ + "1944:8809", + "3893:6511", + "3891:3590" + ] + }, + { + "route_identity": "lesson-mobile", + "canonical_frame_id": "1921:2705", + "canonical_path": "lesson-3.html", + "draft_frame_ids": [ + "1944:9357", + "3891:4146" + ] + }, + { + "route_identity": "lesson", + "canonical_frame_id": "1522:407", + "canonical_path": "lesson-4.html", + "draft_frame_ids": [ + "883:415" + ] + }, + { + "route_identity": "sign-up", + "canonical_frame_id": "1573:891", + "canonical_path": "sign-up.html", + "draft_frame_ids": [ + "1706:1195" + ] + }, + { + "route_identity": "sign-up", + "canonical_frame_id": "1921:1734", + "canonical_path": "sign-up-2.html", + "draft_frame_ids": [ + "1944:8327", + "3891:3108" + ] + }, + { + "route_identity": "intro", + "canonical_frame_id": "1441:356", + "canonical_path": null, + "draft_frame_ids": [ + "1522:348", + "1573:474", + "1706:789", + "1921:1438", + "1944:8031", + "3891:2812" + ] + } + ], + "route_target_counts": { + "01-home-mobile.html": 12, + "about.html": 6, + "all-courses.html": 6, + "cart-2.html": 6, + "cart.html": 6, + "course-2.html": 6, + "course-3.html": 6, + "course-4.html": 12, + "course-bg.html": 6, + "course-mobile.html": 6, + "course-overview-2.html": 6, + "course-overview.html": 12, + "course.html": 114, + "homework-option-1.html": 12, + "index.html": 18, + "landing.html": 6, + "lesson-2.html": 6, + "lesson-3.html": 6, + "lesson-4.html": 6, + "lesson-dive-deeper-2.html": 6, + "lesson-dive-deeper-3.html": 6, + "lesson-dive-deeper-4.html": 6, + "lesson-dive-deeper.html": 12, + "lesson-lesson-notes-2.html": 6, + "lesson-lesson-notes.html": 12, + "lesson-notes-open-2.html": 6, + "lesson-notes-open.html": 12, + "lesson-option-2.html": 6, + "lesson.html": 6, + "module-month-option-1.html": 6, + "my-account-2.html": 6, + "my-account-3.html": 6, + "my-account.html": 6, + "my-notes.html": 6, + "quiz-2.html": 6, + "quiz.html": 12, + "sales-page.html": 12, + "sign-up-2.html": 6, + "sign-up.html": 6, + "the-course.html": 12, + "video-04.html": 12 + }, + "links_by_page": [ + { + "path": "index.html", + "sources_found": 6, + "anchors_emitted": 3, + "node_links": 6, + "url_links": 0, + "implicit_route_links": 1, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 4, + "route_targets": [] + }, + { + "path": "01-home-mobile.html", + "sources_found": 2, + "anchors_emitted": 1, + "node_links": 2, + "url_links": 0, + "implicit_route_links": 1, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 2, + "route_targets": [] + }, + { + "path": "course.html", + "sources_found": 0, + "anchors_emitted": 22, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 22, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [ + { + "label": "Homepage", + "path": "index.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "home", + "path": "index.html", + "confidence": "high", + "evidence": "front_page_alias" + }, + { + "label": "front page", + "path": "index.html", + "confidence": "high", + "evidence": "front_page_alias" + }, + { + "label": "01 Home - Mobile", + "path": "01-home-mobile.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "01-home-mobile", + "path": "01-home-mobile.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Course", + "path": "course.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "archive", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "archives", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "blog", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "posts", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "news", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "Month Five – Tactics Part 1", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Six – Tactics Part 2", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Eight – Understanding Labs", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Ten – Reverse-Engineering Risk part 2", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Seven – Tactics Deep Dive", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Nine – Reverse-Engineering Risk part 1", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Eleven – Exogenous Molecules Deep Dive", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Twelve – Leveling up: Life after the course", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Introduction", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month One – Foundations", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month 2 – Frameworks", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Three – The Four Horsemen Part 1", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Four – The Four Horsemen Part 2", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "My Notes", + "path": "my-notes.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "course-2", + "path": "course-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "course-3", + "path": "course-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Sales Page", + "path": "sales-page.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Course sales page", + "path": "sales-page.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "My Account", + "path": "my-account.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "my-account-2", + "path": "my-account-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "my-account-3", + "path": "my-account-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Cart", + "path": "cart.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "cart-2", + "path": "cart-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Course - Overview", + "path": "course-overview.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Welcome To Early", + "path": "course-overview.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Lesson - Notes Open", + "path": "lesson-notes-open.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Welcome to Early", + "path": "lesson-notes-open.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Lesson - Option 2", + "path": "lesson-option-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "landing", + "path": "landing.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-notes-open-2", + "path": "lesson-notes-open-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson- Dive Deeper", + "path": "lesson-dive-deeper.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Intro to Early", + "path": "lesson-dive-deeper.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "lesson-dive-deeper-2", + "path": "lesson-dive-deeper-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson- Lesson Notes", + "path": "lesson-lesson-notes.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson Notes", + "path": "lesson-lesson-notes.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "course-overview-2", + "path": "course-overview-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "About", + "path": "about.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-dive-deeper-3", + "path": "lesson-dive-deeper-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-dive-deeper-4", + "path": "lesson-dive-deeper-4.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-lesson-notes-2", + "path": "lesson-lesson-notes-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson", + "path": "lesson.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-2", + "path": "lesson-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-3", + "path": "lesson-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Quiz", + "path": "quiz.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Intro to Early Quiz", + "path": "quiz.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "lesson-4", + "path": "lesson-4.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Homework - Option 1", + "path": "homework-option-1.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Activity", + "path": "homework-option-1.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Sign Up", + "path": "sign-up.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "sign-up-2", + "path": "sign-up-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "course-4", + "path": "course-4.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Early Medical Course", + "path": "course-4.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "quiz-2", + "path": "quiz-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "All Courses", + "path": "all-courses.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "video-04", + "path": "video-04.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Early", + "path": "video-04.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "course-bg", + "path": "course-bg.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Module (month) - Option 1", + "path": "module-month-option-1.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "The Course", + "path": "the-course.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Early Medical Course Intro Page", + "path": "the-course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "course-mobile", + "path": "course-mobile.html", + "confidence": "high", + "evidence": "planned_page_identity" + } + ] + }, + { + "path": "course-2.html", + "sources_found": 0, + "anchors_emitted": 21, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 21, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [ + { + "label": "Homepage", + "path": "index.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "home", + "path": "index.html", + "confidence": "high", + "evidence": "front_page_alias" + }, + { + "label": "front page", + "path": "index.html", + "confidence": "high", + "evidence": "front_page_alias" + }, + { + "label": "01 Home - Mobile", + "path": "01-home-mobile.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "01-home-mobile", + "path": "01-home-mobile.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Course", + "path": "course.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "archive", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "archives", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "blog", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "posts", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "news", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "Month Five – Tactics Part 1", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Six – Tactics Part 2", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Eight – Understanding Labs", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Ten – Reverse-Engineering Risk part 2", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Seven – Tactics Deep Dive", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Nine – Reverse-Engineering Risk part 1", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Eleven – Exogenous Molecules Deep Dive", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Twelve – Leveling up: Life after the course", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Introduction", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month One – Foundations", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month 2 – Frameworks", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Three – The Four Horsemen Part 1", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Four – The Four Horsemen Part 2", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "My Notes", + "path": "my-notes.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "course-2", + "path": "course-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "course-3", + "path": "course-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Sales Page", + "path": "sales-page.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Course sales page", + "path": "sales-page.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "My Account", + "path": "my-account.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "my-account-2", + "path": "my-account-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "my-account-3", + "path": "my-account-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Cart", + "path": "cart.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "cart-2", + "path": "cart-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Course - Overview", + "path": "course-overview.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Welcome To Early", + "path": "course-overview.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Lesson - Notes Open", + "path": "lesson-notes-open.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Welcome to Early", + "path": "lesson-notes-open.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Lesson - Option 2", + "path": "lesson-option-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "landing", + "path": "landing.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-notes-open-2", + "path": "lesson-notes-open-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson- Dive Deeper", + "path": "lesson-dive-deeper.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Intro to Early", + "path": "lesson-dive-deeper.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "lesson-dive-deeper-2", + "path": "lesson-dive-deeper-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson- Lesson Notes", + "path": "lesson-lesson-notes.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson Notes", + "path": "lesson-lesson-notes.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "course-overview-2", + "path": "course-overview-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "About", + "path": "about.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-dive-deeper-3", + "path": "lesson-dive-deeper-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-dive-deeper-4", + "path": "lesson-dive-deeper-4.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-lesson-notes-2", + "path": "lesson-lesson-notes-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson", + "path": "lesson.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-2", + "path": "lesson-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-3", + "path": "lesson-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Quiz", + "path": "quiz.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Intro to Early Quiz", + "path": "quiz.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "lesson-4", + "path": "lesson-4.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Homework - Option 1", + "path": "homework-option-1.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Activity", + "path": "homework-option-1.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Sign Up", + "path": "sign-up.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "sign-up-2", + "path": "sign-up-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "course-4", + "path": "course-4.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Early Medical Course", + "path": "course-4.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "quiz-2", + "path": "quiz-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "All Courses", + "path": "all-courses.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "video-04", + "path": "video-04.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Early", + "path": "video-04.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "course-bg", + "path": "course-bg.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Module (month) - Option 1", + "path": "module-month-option-1.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "The Course", + "path": "the-course.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Early Medical Course Intro Page", + "path": "the-course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "course-mobile", + "path": "course-mobile.html", + "confidence": "high", + "evidence": "planned_page_identity" + } + ] + }, + { + "path": "course-3.html", + "sources_found": 0, + "anchors_emitted": 21, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 21, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [ + { + "label": "Homepage", + "path": "index.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "home", + "path": "index.html", + "confidence": "high", + "evidence": "front_page_alias" + }, + { + "label": "front page", + "path": "index.html", + "confidence": "high", + "evidence": "front_page_alias" + }, + { + "label": "01 Home - Mobile", + "path": "01-home-mobile.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "01-home-mobile", + "path": "01-home-mobile.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Course", + "path": "course.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "archive", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "archives", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "blog", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "posts", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "news", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "Month Five – Tactics Part 1", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Six – Tactics Part 2", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Eight – Understanding Labs", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Ten – Reverse-Engineering Risk part 2", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Seven – Tactics Deep Dive", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Nine – Reverse-Engineering Risk part 1", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Eleven – Exogenous Molecules Deep Dive", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Twelve – Leveling up: Life after the course", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Introduction", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month One – Foundations", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month 2 – Frameworks", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Three – The Four Horsemen Part 1", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Four – The Four Horsemen Part 2", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "My Notes", + "path": "my-notes.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "course-2", + "path": "course-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "course-3", + "path": "course-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Sales Page", + "path": "sales-page.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Course sales page", + "path": "sales-page.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "My Account", + "path": "my-account.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "my-account-2", + "path": "my-account-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "my-account-3", + "path": "my-account-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Cart", + "path": "cart.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "cart-2", + "path": "cart-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Course - Overview", + "path": "course-overview.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Welcome To Early", + "path": "course-overview.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Lesson - Notes Open", + "path": "lesson-notes-open.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Welcome to Early", + "path": "lesson-notes-open.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Lesson - Option 2", + "path": "lesson-option-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "landing", + "path": "landing.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-notes-open-2", + "path": "lesson-notes-open-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson- Dive Deeper", + "path": "lesson-dive-deeper.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Intro to Early", + "path": "lesson-dive-deeper.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "lesson-dive-deeper-2", + "path": "lesson-dive-deeper-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson- Lesson Notes", + "path": "lesson-lesson-notes.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson Notes", + "path": "lesson-lesson-notes.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "course-overview-2", + "path": "course-overview-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "About", + "path": "about.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-dive-deeper-3", + "path": "lesson-dive-deeper-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-dive-deeper-4", + "path": "lesson-dive-deeper-4.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-lesson-notes-2", + "path": "lesson-lesson-notes-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson", + "path": "lesson.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-2", + "path": "lesson-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-3", + "path": "lesson-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Quiz", + "path": "quiz.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Intro to Early Quiz", + "path": "quiz.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "lesson-4", + "path": "lesson-4.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Homework - Option 1", + "path": "homework-option-1.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Activity", + "path": "homework-option-1.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Sign Up", + "path": "sign-up.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "sign-up-2", + "path": "sign-up-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "course-4", + "path": "course-4.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Early Medical Course", + "path": "course-4.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "quiz-2", + "path": "quiz-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "All Courses", + "path": "all-courses.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "video-04", + "path": "video-04.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Early", + "path": "video-04.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "course-bg", + "path": "course-bg.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Module (month) - Option 1", + "path": "module-month-option-1.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "The Course", + "path": "the-course.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Early Medical Course Intro Page", + "path": "the-course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "course-mobile", + "path": "course-mobile.html", + "confidence": "high", + "evidence": "planned_page_identity" + } + ] + }, + { + "path": "sales-page.html", + "sources_found": 2, + "anchors_emitted": 2, + "node_links": 2, + "url_links": 0, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 2, + "route_targets": [] + }, + { + "path": "my-account.html", + "sources_found": 1, + "anchors_emitted": 2, + "node_links": 0, + "url_links": 1, + "implicit_route_links": 1, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "my-account-2.html", + "sources_found": 1, + "anchors_emitted": 3, + "node_links": 0, + "url_links": 1, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "my-account-3.html", + "sources_found": 1, + "anchors_emitted": 3, + "node_links": 0, + "url_links": 1, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "cart.html", + "sources_found": 0, + "anchors_emitted": 2, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "cart-2.html", + "sources_found": 0, + "anchors_emitted": 2, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "course-overview.html", + "sources_found": 0, + "anchors_emitted": 2, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "lesson-notes-open.html", + "sources_found": 0, + "anchors_emitted": 2, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "lesson-option-2.html", + "sources_found": 0, + "anchors_emitted": 2, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "landing.html", + "sources_found": 1, + "anchors_emitted": 1, + "node_links": 1, + "url_links": 0, + "implicit_route_links": 1, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 1, + "route_targets": [] + }, + { + "path": "lesson-notes-open-2.html", + "sources_found": 0, + "anchors_emitted": 2, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "my-notes.html", + "sources_found": 0, + "anchors_emitted": 2, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "lesson-dive-deeper.html", + "sources_found": 0, + "anchors_emitted": 2, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "lesson-dive-deeper-2.html", + "sources_found": 0, + "anchors_emitted": 2, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "lesson-lesson-notes.html", + "sources_found": 0, + "anchors_emitted": 2, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "course-overview-2.html", + "sources_found": 0, + "anchors_emitted": 2, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "about.html", + "sources_found": 0, + "anchors_emitted": 1, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 1, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "lesson-dive-deeper-3.html", + "sources_found": 1, + "anchors_emitted": 2, + "node_links": 1, + "url_links": 0, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 1, + "route_targets": [] + }, + { + "path": "lesson-dive-deeper-4.html", + "sources_found": 0, + "anchors_emitted": 2, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "lesson-lesson-notes-2.html", + "sources_found": 0, + "anchors_emitted": 2, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "lesson.html", + "sources_found": 0, + "anchors_emitted": 2, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "lesson-2.html", + "sources_found": 0, + "anchors_emitted": 8, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 8, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [ + { + "label": "Homepage", + "path": "index.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "home", + "path": "index.html", + "confidence": "high", + "evidence": "front_page_alias" + }, + { + "label": "front page", + "path": "index.html", + "confidence": "high", + "evidence": "front_page_alias" + }, + { + "label": "01 Home - Mobile", + "path": "01-home-mobile.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "01-home-mobile", + "path": "01-home-mobile.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Course", + "path": "course.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "archive", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "archives", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "blog", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "posts", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "news", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "Month Five – Tactics Part 1", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Six – Tactics Part 2", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Eight – Understanding Labs", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Ten – Reverse-Engineering Risk part 2", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Seven – Tactics Deep Dive", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Nine – Reverse-Engineering Risk part 1", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Eleven – Exogenous Molecules Deep Dive", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Twelve – Leveling up: Life after the course", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Introduction", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month One – Foundations", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month 2 – Frameworks", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Three – The Four Horsemen Part 1", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Four – The Four Horsemen Part 2", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "My Notes", + "path": "my-notes.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "course-2", + "path": "course-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "course-3", + "path": "course-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Sales Page", + "path": "sales-page.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Course sales page", + "path": "sales-page.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "My Account", + "path": "my-account.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "my-account-2", + "path": "my-account-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "my-account-3", + "path": "my-account-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Cart", + "path": "cart.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "cart-2", + "path": "cart-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Course - Overview", + "path": "course-overview.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Welcome To Early", + "path": "course-overview.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Lesson - Notes Open", + "path": "lesson-notes-open.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Welcome to Early", + "path": "lesson-notes-open.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Lesson - Option 2", + "path": "lesson-option-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "landing", + "path": "landing.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-notes-open-2", + "path": "lesson-notes-open-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson- Dive Deeper", + "path": "lesson-dive-deeper.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Intro to Early", + "path": "lesson-dive-deeper.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "lesson-dive-deeper-2", + "path": "lesson-dive-deeper-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson- Lesson Notes", + "path": "lesson-lesson-notes.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson Notes", + "path": "lesson-lesson-notes.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "course-overview-2", + "path": "course-overview-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "About", + "path": "about.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-dive-deeper-3", + "path": "lesson-dive-deeper-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-dive-deeper-4", + "path": "lesson-dive-deeper-4.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-lesson-notes-2", + "path": "lesson-lesson-notes-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson", + "path": "lesson.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-2", + "path": "lesson-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-3", + "path": "lesson-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Quiz", + "path": "quiz.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Intro to Early Quiz", + "path": "quiz.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "lesson-4", + "path": "lesson-4.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Homework - Option 1", + "path": "homework-option-1.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Activity", + "path": "homework-option-1.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Sign Up", + "path": "sign-up.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "sign-up-2", + "path": "sign-up-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "course-4", + "path": "course-4.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Early Medical Course", + "path": "course-4.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "quiz-2", + "path": "quiz-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "All Courses", + "path": "all-courses.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "video-04", + "path": "video-04.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Early", + "path": "video-04.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "course-bg", + "path": "course-bg.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Module (month) - Option 1", + "path": "module-month-option-1.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "The Course", + "path": "the-course.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Early Medical Course Intro Page", + "path": "the-course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "course-mobile", + "path": "course-mobile.html", + "confidence": "high", + "evidence": "planned_page_identity" + } + ] + }, + { + "path": "lesson-3.html", + "sources_found": 0, + "anchors_emitted": 9, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 9, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [ + { + "label": "Homepage", + "path": "index.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "home", + "path": "index.html", + "confidence": "high", + "evidence": "front_page_alias" + }, + { + "label": "front page", + "path": "index.html", + "confidence": "high", + "evidence": "front_page_alias" + }, + { + "label": "01 Home - Mobile", + "path": "01-home-mobile.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "01-home-mobile", + "path": "01-home-mobile.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Course", + "path": "course.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "archive", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "archives", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "blog", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "posts", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "news", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "Month Five – Tactics Part 1", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Six – Tactics Part 2", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Eight – Understanding Labs", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Ten – Reverse-Engineering Risk part 2", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Seven – Tactics Deep Dive", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Nine – Reverse-Engineering Risk part 1", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Eleven – Exogenous Molecules Deep Dive", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Twelve – Leveling up: Life after the course", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Introduction", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month One – Foundations", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month 2 – Frameworks", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Three – The Four Horsemen Part 1", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Four – The Four Horsemen Part 2", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "My Notes", + "path": "my-notes.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "course-2", + "path": "course-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "course-3", + "path": "course-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Sales Page", + "path": "sales-page.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Course sales page", + "path": "sales-page.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "My Account", + "path": "my-account.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "my-account-2", + "path": "my-account-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "my-account-3", + "path": "my-account-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Cart", + "path": "cart.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "cart-2", + "path": "cart-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Course - Overview", + "path": "course-overview.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Welcome To Early", + "path": "course-overview.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Lesson - Notes Open", + "path": "lesson-notes-open.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Welcome to Early", + "path": "lesson-notes-open.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Lesson - Option 2", + "path": "lesson-option-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "landing", + "path": "landing.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-notes-open-2", + "path": "lesson-notes-open-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson- Dive Deeper", + "path": "lesson-dive-deeper.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Intro to Early", + "path": "lesson-dive-deeper.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "lesson-dive-deeper-2", + "path": "lesson-dive-deeper-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson- Lesson Notes", + "path": "lesson-lesson-notes.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson Notes", + "path": "lesson-lesson-notes.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "course-overview-2", + "path": "course-overview-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "About", + "path": "about.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-dive-deeper-3", + "path": "lesson-dive-deeper-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-dive-deeper-4", + "path": "lesson-dive-deeper-4.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-lesson-notes-2", + "path": "lesson-lesson-notes-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson", + "path": "lesson.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-2", + "path": "lesson-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-3", + "path": "lesson-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Quiz", + "path": "quiz.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Intro to Early Quiz", + "path": "quiz.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "lesson-4", + "path": "lesson-4.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Homework - Option 1", + "path": "homework-option-1.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Activity", + "path": "homework-option-1.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Sign Up", + "path": "sign-up.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "sign-up-2", + "path": "sign-up-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "course-4", + "path": "course-4.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Early Medical Course", + "path": "course-4.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "quiz-2", + "path": "quiz-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "All Courses", + "path": "all-courses.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "video-04", + "path": "video-04.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Early", + "path": "video-04.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "course-bg", + "path": "course-bg.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Module (month) - Option 1", + "path": "module-month-option-1.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "The Course", + "path": "the-course.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Early Medical Course Intro Page", + "path": "the-course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "course-mobile", + "path": "course-mobile.html", + "confidence": "high", + "evidence": "planned_page_identity" + } + ] + }, + { + "path": "quiz.html", + "sources_found": 0, + "anchors_emitted": 1, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 1, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "lesson-4.html", + "sources_found": 1, + "anchors_emitted": 1, + "node_links": 1, + "url_links": 0, + "implicit_route_links": 1, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 1, + "route_targets": [] + }, + { + "path": "homework-option-1.html", + "sources_found": 0, + "anchors_emitted": 2, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "sign-up.html", + "sources_found": 0, + "anchors_emitted": 2, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "sign-up-2.html", + "sources_found": 0, + "anchors_emitted": 2, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 2, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "course-4.html", + "sources_found": 0, + "anchors_emitted": 1, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 1, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "quiz-2.html", + "sources_found": 0, + "anchors_emitted": 1, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 1, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "all-courses.html", + "sources_found": 0, + "anchors_emitted": 1, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 1, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "video-04.html", + "sources_found": 1, + "anchors_emitted": 0, + "node_links": 1, + "url_links": 0, + "implicit_route_links": 0, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 1, + "route_targets": [] + }, + { + "path": "course-bg.html", + "sources_found": 0, + "anchors_emitted": 0, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 0, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [ + { + "label": "Homepage", + "path": "index.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "home", + "path": "index.html", + "confidence": "high", + "evidence": "front_page_alias" + }, + { + "label": "front page", + "path": "index.html", + "confidence": "high", + "evidence": "front_page_alias" + }, + { + "label": "01 Home - Mobile", + "path": "01-home-mobile.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "01-home-mobile", + "path": "01-home-mobile.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Course", + "path": "course.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "archive", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "archives", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "blog", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "posts", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "news", + "path": "course.html", + "confidence": "high", + "evidence": "archive_page_type_alias" + }, + { + "label": "Month Five – Tactics Part 1", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Six – Tactics Part 2", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Eight – Understanding Labs", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Ten – Reverse-Engineering Risk part 2", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Seven – Tactics Deep Dive", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Nine – Reverse-Engineering Risk part 1", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Eleven – Exogenous Molecules Deep Dive", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Twelve – Leveling up: Life after the course", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Introduction", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month One – Foundations", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month 2 – Frameworks", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Three – The Four Horsemen Part 1", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Month Four – The Four Horsemen Part 2", + "path": "course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "My Notes", + "path": "my-notes.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "course-2", + "path": "course-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "course-3", + "path": "course-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Sales Page", + "path": "sales-page.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Course sales page", + "path": "sales-page.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "My Account", + "path": "my-account.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "my-account-2", + "path": "my-account-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "my-account-3", + "path": "my-account-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Cart", + "path": "cart.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "cart-2", + "path": "cart-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Course - Overview", + "path": "course-overview.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Welcome To Early", + "path": "course-overview.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Lesson - Notes Open", + "path": "lesson-notes-open.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Welcome to Early", + "path": "lesson-notes-open.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Lesson - Option 2", + "path": "lesson-option-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "landing", + "path": "landing.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-notes-open-2", + "path": "lesson-notes-open-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson- Dive Deeper", + "path": "lesson-dive-deeper.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Intro to Early", + "path": "lesson-dive-deeper.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "lesson-dive-deeper-2", + "path": "lesson-dive-deeper-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson- Lesson Notes", + "path": "lesson-lesson-notes.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson Notes", + "path": "lesson-lesson-notes.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "course-overview-2", + "path": "course-overview-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "About", + "path": "about.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-dive-deeper-3", + "path": "lesson-dive-deeper-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-dive-deeper-4", + "path": "lesson-dive-deeper-4.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-lesson-notes-2", + "path": "lesson-lesson-notes-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Lesson", + "path": "lesson.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-2", + "path": "lesson-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "lesson-3", + "path": "lesson-3.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Quiz", + "path": "quiz.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Intro to Early Quiz", + "path": "quiz.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "lesson-4", + "path": "lesson-4.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Homework - Option 1", + "path": "homework-option-1.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "1.1 Activity", + "path": "homework-option-1.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "Sign Up", + "path": "sign-up.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "sign-up-2", + "path": "sign-up-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "course-4", + "path": "course-4.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Early Medical Course", + "path": "course-4.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "quiz-2", + "path": "quiz-2.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "All Courses", + "path": "all-courses.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "video-04", + "path": "video-04.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Early", + "path": "video-04.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "course-bg", + "path": "course-bg.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Module (month) - Option 1", + "path": "module-month-option-1.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "The Course", + "path": "the-course.html", + "confidence": "high", + "evidence": "planned_page_identity" + }, + { + "label": "Early Medical Course Intro Page", + "path": "the-course.html", + "confidence": "medium", + "evidence": "page_heading" + }, + { + "label": "course-mobile", + "path": "course-mobile.html", + "confidence": "high", + "evidence": "planned_page_identity" + } + ] + }, + { + "path": "module-month-option-1.html", + "sources_found": 0, + "anchors_emitted": 0, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 0, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "the-course.html", + "sources_found": 0, + "anchors_emitted": 1, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 1, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + }, + { + "path": "course-mobile.html", + "sources_found": 0, + "anchors_emitted": 0, + "node_links": 0, + "url_links": 0, + "implicit_route_links": 0, + "implicit_route_self_suppressed": 0, + "implicit_route_unresolved": 0, + "unresolved": 0, + "route_targets": [] + } + ], + "unresolved_targets": [ + { + "node_id": "1706:3046", + "link_type": "node", + "target_node_id": "4294967295:4294967295", + "source": "reaction", + "page_path": "index.html" + }, + { + "node_id": "1706:3079", + "link_type": "node", + "target_node_id": "4294967295:4294967295", + "source": "reaction", + "page_path": "index.html" + }, + { + "node_id": "1706:3115", + "link_type": "node", + "target_node_id": "4294967295:4294967295", + "source": "reaction", + "page_path": "index.html" + }, + { + "node_id": "1706:3233", + "link_type": "node", + "target_node_id": "4294967295:4294967295", + "source": "reaction", + "page_path": "index.html" + }, + { + "node_id": "3802:3406", + "link_type": "node", + "target_node_id": "4294967295:4294967295", + "source": "reaction", + "page_path": "01-home-mobile.html" + }, + { + "node_id": "3802:3561", + "link_type": "node", + "target_node_id": "4294967295:4294967295", + "source": "reaction", + "page_path": "01-home-mobile.html" + }, + { + "node_id": "1921:7830", + "link_type": "node", + "target_node_id": "4294967295:4294967295", + "source": "reaction", + "page_path": "sales-page.html" + }, + { + "node_id": "1921:7866", + "link_type": "node", + "target_node_id": "4294967295:4294967295", + "source": "reaction", + "page_path": "sales-page.html" + }, + { + "node_id": "3802:3310", + "link_type": "node", + "target_node_id": "4294967295:4294967295", + "source": "reaction", + "page_path": "landing.html" + }, + { + "node_id": "1585:953", + "link_type": "node", + "target_node_id": "4294967295:4294967295", + "source": "reaction", + "page_path": "lesson-dive-deeper-3.html" + }, + { + "node_id": "1524:1405", + "link_type": "node", + "target_node_id": "4294967295:4294967295", + "source": "reaction", + "page_path": "lesson-4.html" + }, + { + "node_id": "3802:3074", + "link_type": "node", + "target_node_id": "4294967295:4294967295", + "source": "reaction", + "page_path": "video-04.html" + } + ], + "implicit_route_unresolved_targets": [] +} diff --git a/docs/contracts/figma-transformer-result.md b/docs/contracts/figma-transformer-result.md index 86cb1731..42231a93 100644 --- a/docs/contracts/figma-transformer-result.md +++ b/docs/contracts/figma-transformer-result.md @@ -41,6 +41,43 @@ Required parity fields: - `diff_summary`: optional compact diff summary such as changed pixels, threshold, or ratio - `metrics`: optional runner metrics +## Transform Diagnostics + +`source_reports.figma.html.transform_diagnostics` uses schema `blocks-engine/figma-transformer/transform-diagnostics/v1`. It is a development/parity diagnostics envelope, not a rendering contract. It explains source nodes that were decoded but not materially emitted so visual gaps can be triaged without papering over output. + +`source_reports.figma.html.visual_node_map` is the aggregate source-to-artifact evidence map. Each emitted visual node entry includes `id`, `rect`, `page_path`, optional `emitted_class`/`emitted_tag`, and multi-page provenance fields `source_page_index` and `source_page_frame_id`. The same traced entries are preserved in each `source_reports.figma.html.pages[].visual_node_map` page report so arbitrary `.fig` scale output can be traced from aggregate JSON to the generated page HTML and shared CSS selector. + +Text coverage lives at `transform_diagnostics.text` with schema `blocks-engine/figma-transformer/text-coverage/v1`: + +- `decoded_text_node_count`: non-empty decoded text nodes considered for emission. +- `emitted_text_node_count`: decoded text nodes whose `data-figma-node-id` appears in generated HTML. +- `missing_emitted_text_node_count`: decoded non-empty text nodes not found in generated HTML. +- `missing_emitted_text_reason_categories`: stable counts by reason. +- `missing_emitted_text_nodes[]`: sample nodes with `node_id`, `name`, `type`, `class`, `page_id`, `page_name`, `character_count`, and `reason`. + +Asset coverage lives at `transform_diagnostics.images`: + +- `node_refs`: raw nodes carrying an explicit asset reference or image paint. +- `asset_nodes[]`: sample nodes with `node_id`, `name`, `type`, `class`, `emitted`, `reason`, optional `path`, and `refs`. +- `asset_node_reason_categories`: stable counts by reason. +- `missing_assets[]`: asset-bearing node samples with no resolved archive asset path. + +Initial omission reasons include `hidden`, `zero_area`, `parent_omitted`, `decorative`, `no_archive_asset`, `no_archive_asset_hash`, `clipped_masked`, `converted_to_background`, `converted_to_form_control`, and `not_emitted`. + +Positional parity coverage lives at `transform_diagnostics.layout.positional_parity` with schema `blocks-engine/figma-transformer/positional-parity/v1`. It summarizes emitted CSS and layout evidence that can affect arbitrary `.fig` visual parity without changing runtime behavior: + +- `full_bleed_viewport_width_count`: emitted `width:100vw` declarations. +- `full_bleed_breakout_count`: emitted viewport breakout declarations using `left:50%` plus `margin-left:±50vw`. +- `mirrored_transform_count`: emitted CSS matrix transforms with a negative horizontal or vertical scale component. +- `reflected_full_bleed_count`: emitted full-bleed reflected nodes using `margin-left:50vw` plus a mirrored matrix. +- `fixed_over_root_width_underlay_count`: decorative underlay samples whose fixed source width exceeds parent/root width. +- `fixed_over_root_width_underlays[]`: bounded samples with page, node, parent, geometry, and class evidence. +- `chrome_overflow_count`: off-canvas visual nodes associated with header/footer chrome. +- `chrome_overflow_nodes[]`: bounded samples with page, node, parent, geometry, and class evidence. +- `root_stacking_trace_count`: count of recorded stacking-context decision traces. +- `root_stacking_reason_counts`: stable stacking/z-index/overlap decision reason counts when present. +- `decision_trace_samples[]`: bounded positional decision trace samples derived from `decision_traces.samples` for effective geometry, stacking context, transform viewport, and responsive decisions. Samples include stable node/page/class identity plus compact source geometry, emitted CSS geometry, full-bleed/canvas-shell, stacking, transform, or responsive declaration evidence when present. + Status meanings: - `not_run`: no parity runner has executed for this transform. @@ -51,6 +88,8 @@ Status meanings: Arbitrary `.fig` files are not fully decoded by the PHP-native package yet. Current `.fig` support safely opens `.fig` files or wrapper ZIPs, identifies nested `.fig` entries, reports `fig-kiwi` prelude/version/chunk metadata, inventories embedded files/assets, and records compression diagnostics. +The bounded Kiwi skipped-field inventory reports skipped fields in `fields`/`summary` and selected decoded paint-variable/interpolation diagnostics in `decoded_fields`/`decoded_summary`. Decoded paint diagnostics currently cover `Paint.colorVar`, `Paint.stopsVar`, color-stop variable bindings, and gradient interpolation/color-space fields so large real files can be audited without full visual rendering. + Next decoder milestones are schema chunk parsing, Zstandard message decoding when supported by the runtime, mapping decoded Kiwi messages into normalized IR, and expanding layout/paint/text/component/asset coverage against external real-file evidence. ## Fixture Strategy diff --git a/figma-transformer/README.md b/figma-transformer/README.md index fefe873b..3c123313 100644 --- a/figma-transformer/README.md +++ b/figma-transformer/README.md @@ -104,6 +104,19 @@ add_filter( No pure-PHP Zstandard decoder is bundled today. Unsupported runtimes report `figma_transformer_zstd_extension_missing` or adapter failure diagnostics and continue parsing the rest of the archive metadata. +### Large `.fig` Memory Profiles + +Large production `.fig` exports should default to bounded inspection before full scenegraph materialization. Keep the library defaults conservative: the eager Kiwi message decode limit is 16 MB, the selective decode preflight limit is 32 MB, and the zstd inflated chunk limit is 64 MB. With those defaults, oversized modern Kiwi messages are reported with `figma_transformer_kiwi_message_decode_skipped_preflight` instead of risking a PHP fatal. + +Recommended operator profiles: + +- Archive/gate inspection: run with `--inspect-kiwi-gate`, `--omit-asset-content` when asset bytes are not needed, and a 512 MB PHP memory limit. +- Skipped-field inventory: run `scripts/figma-kiwi-skipped-field-inventory.php` with an explicit `--zstd-command` when the host lacks `ext-zstd`; 512 MB is expected to be enough for bounded inventory scans. +- Parser parity dry run: keep `--max-kiwi-message-decode-bytes=1` for the safe preflight/default path. Raise `--max-kiwi-selective-message-decode-bytes` only for an intentional selective decode experiment on a high-memory worker. +- Full transform: do not raise `--max-kiwi-selective-message-decode-bytes` for untrusted or fatal-scale files unless the process budget has been measured against that file. `--max-nodes` limits normalized output size, but it does not avoid the current cost of decoding and indexing the source graph. + +When a real file exceeds the default selective decode ceiling, prefer `--inspect-kiwi-gate` and skipped-field inventory to identify the page/frame scope before allocating a larger transform worker. Treat memory budgets that finish within a few percent of the configured PHP limit as unsafe defaults; use them only for one-off operator runs. + ## Output Contract Successful transforms produce a static website artifact: diff --git a/figma-transformer/bin/figma-transformer b/figma-transformer/bin/figma-transformer index f32bb769..148e6026 100755 --- a/figma-transformer/bin/figma-transformer +++ b/figma-transformer/bin/figma-transformer @@ -12,13 +12,14 @@ if ( is_readable($autoload) ) { $path = $argv[1] ?? ''; if ( '' === $path || '--help' === $path || '-h' === $path ) { - fwrite(STDERR, "Usage: figma-transformer [--inspect-frames[=]] [--multi-page] [--frame-ids=] [--entry-frame-id=] [--max-pages=] [--output-dir=] [--omit-asset-content] [--max-nodes=] [--max-kiwi-message-decode-bytes=] [--zstd-command=] [--frame-id=] [--font-css=] [--font-css-file=] [--font-family-css=:] [--font-family-css-file=:] [--render-text-glyph-paths] [--parity-status=] [--parity-report-path=] [--parity-source-screenshot-url=] [--parity-source-screenshot-path=] [--parity-generated-screenshot-artifact=] [--parity-diff-image-artifact=] [--parity-pixel-mismatch-count=] [--parity-pixel-mismatch-ratio=] [--parity-threshold=] [--parity-viewport=x] [--parity-dom-boxes-path=] [--parity-layout-report-path=] [--parity-render-evidence-path=] [--parity-layout-mismatch-count=]\n"); + fwrite(STDERR, "Usage: figma-transformer [--inspect-frames[=]] [--inspect-kiwi-gate] [--kiwi-gated-decode] [--multi-page] [--frame-ids=] [--entry-frame-id=] [--max-pages=] [--output-dir=] [--omit-asset-content] [--max-canvas-bytes=] [--max-nested-fig-bytes=] [--max-archive-asset-content-bytes=] [--max-zstd-inflated-bytes=] [--max-nodes=] [--max-kiwi-message-decode-bytes=] [--max-kiwi-selective-message-decode-bytes=] [--zstd-command=] [--frame-id=] [--font-css=] [--font-css-file=] [--font-family-css=:] [--font-family-css-file=:] [--render-text-glyph-paths] [--parity-status=] [--parity-report-path=] [--parity-source-screenshot-url=] [--parity-source-screenshot-path=] [--parity-generated-screenshot-artifact=] [--parity-diff-image-artifact=] [--parity-pixel-mismatch-count=] [--parity-pixel-mismatch-ratio=] [--parity-threshold=] [--parity-viewport=x] [--parity-dom-boxes-path=] [--parity-layout-report-path=] [--parity-render-evidence-path=] [--parity-layout-mismatch-count=]\n"); exit(1); } $options = array(); $zstdCommand = getenv('FIGMA_TRANSFORMER_ZSTD_COMMAND') ?: null; $outputDir = null; +$inspectKiwiGateMode = false; foreach ( array_slice($argv, 2) as $argument ) { if ( ! str_starts_with($argument, '--') ) { continue; @@ -46,6 +47,17 @@ foreach ( array_slice($argv, 2) as $argument ) { continue; } + if ( 'inspect-kiwi-gate' === $name ) { + $options['inspect_kiwi_gate'] = true; + $inspectKiwiGateMode = true; + continue; + } + + if ( 'kiwi-gated-decode' === $name ) { + $options['inspect_kiwi_gate'] = true; + continue; + } + if ( 'inspect-frame-limit' === $name ) { $options['frame_inspection_limit'] = max(1, (int) $value); continue; @@ -61,6 +73,31 @@ foreach ( array_slice($argv, 2) as $argument ) { continue; } + if ( 'max-kiwi-selective-message-decode-bytes' === $name ) { + $options['max_kiwi_selective_message_decode_bytes'] = max(0, (int) $value); + continue; + } + + if ( 'max-canvas-bytes' === $name ) { + $options['max_canvas_bytes'] = max(0, (int) $value); + continue; + } + + if ( 'max-nested-fig-bytes' === $name ) { + $options['max_nested_fig_bytes'] = max(0, (int) $value); + continue; + } + + if ( 'max-archive-asset-content-bytes' === $name ) { + $options['max_archive_asset_content_bytes'] = max(0, (int) $value); + continue; + } + + if ( 'max-zstd-inflated-bytes' === $name ) { + $options['max_zstd_inflated_bytes'] = max(0, (int) $value); + continue; + } + if ( 'max-nodes' === $name ) { $options['max_nodes'] = max(0, (int) $value); continue; @@ -195,7 +232,9 @@ if ( isset($options['parity']['render_evidence_path']) && is_scalar($options['pa } $transformer = blocks_engine_figma_transformer_cli_transformer(is_string($zstdCommand) && '' !== $zstdCommand ? $zstdCommand : null); -if ( true === ($options['inspect_frames'] ?? false) ) { +if ( $inspectKiwiGateMode && ! str_ends_with(strtolower($path), '.json') ) { + $result = $transformer->inspectKiwiGateFile($path, $options); +} elseif ( true === ($options['inspect_frames'] ?? false) ) { if ( str_ends_with(strtolower($path), '.json') ) { $decoded = json_decode((string) file_get_contents($path), true); $result = $transformer->inspectFramesScenegraph(is_array($decoded) ? $decoded : array(), $options); diff --git a/figma-transformer/scripts/figma-kiwi-skipped-field-inventory.php b/figma-transformer/scripts/figma-kiwi-skipped-field-inventory.php index 1b931ae1..f3a01bb0 100755 --- a/figma-transformer/scripts/figma-kiwi-skipped-field-inventory.php +++ b/figma-transformer/scripts/figma-kiwi-skipped-field-inventory.php @@ -13,29 +13,25 @@ } else { require_once __DIR__ . '/../figma-transformer.php'; } +require_once __DIR__ . '/figma-script-utils.php'; $options = blocks_engine_figma_kiwi_inventory_options($argv); +if ( blocks_engine_figma_script_bool_option($options['self_check'] ?? false) ) { + blocks_engine_figma_script_self_check(); + exit(0); +} if ( true === ($options['help'] ?? false) || '' === ($options['input'] ?? '') ) { blocks_engine_figma_kiwi_inventory_usage(true === ($options['help'] ?? false) ? STDOUT : STDERR); exit(true === ($options['help'] ?? false) ? 0 : 1); } $zstdCommand = $options['zstd_command'] ?? (getenv('FIGMA_TRANSFORMER_ZSTD_COMMAND') ?: null); -$result = blocks_engine_figma_kiwi_inventory((string) $options['input'], is_string($zstdCommand) && '' !== $zstdCommand ? $zstdCommand : null); -$json = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; -$outputPath = $options['output'] ?? null; -if ( is_string($outputPath) && '' !== $outputPath ) { - if ( false === file_put_contents($outputPath, $json) ) { - fwrite(STDERR, "Failed to write skipped-field inventory output to {$outputPath}\n"); - exit(1); - } - fwrite(STDOUT, json_encode(array( - 'schema' => 'blocks-engine/figma-transformer/kiwi-skipped-field-inventory-output/v1', - 'output' => $outputPath, - 'summary' => $result['summary'] ?? array(), - ), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); -} else { - fwrite(STDOUT, $json); +try { + $input = blocks_engine_figma_script_require_input_path((string) $options['input']); + $result = blocks_engine_figma_kiwi_inventory($input, is_string($zstdCommand) && '' !== $zstdCommand ? $zstdCommand : null); + blocks_engine_figma_script_output_json($result, isset($options['output']) ? (string) $options['output'] : null, $result['summary'] ?? array()); +} catch (Throwable $error) { + blocks_engine_figma_script_fail($error); } exit(empty($result['diagnostics']) ? 0 : 0); @@ -62,7 +58,7 @@ function blocks_engine_figma_kiwi_inventory_options(array $argv): array function blocks_engine_figma_kiwi_inventory_usage(mixed $stream): void { - fwrite($stream, "Usage: figma-kiwi-skipped-field-inventory.php [--zstd-command=/opt/homebrew/bin/zstd] [--output=/tmp/inventory.json]\n"); + fwrite($stream, "Usage: figma-kiwi-skipped-field-inventory.php [--zstd-command=/opt/homebrew/bin/zstd] [--output=/tmp/inventory.json] [--self-check]\n"); } /** @@ -187,8 +183,11 @@ function blocks_engine_figma_kiwi_inventory_payload(string $payload, ZstdCapabil function blocks_engine_figma_kiwi_inventory_file_summary(array $inventories): array { $byRole = array(); + $decodedByRole = array(); $fieldCount = 0; + $decodedFieldCount = 0; $occurrences = 0; + $decodedOccurrences = 0; foreach ( $inventories as $inventory ) { $summary = is_array($inventory['summary'] ?? null) ? $inventory['summary'] : array(); $fieldCount += (int) ($summary['field_count'] ?? 0); @@ -196,7 +195,21 @@ function blocks_engine_figma_kiwi_inventory_file_summary(array $inventories): ar foreach ( is_array($summary['by_role'] ?? null) ? $summary['by_role'] : array() as $role => $count ) { $byRole[(string) $role] = ($byRole[(string) $role] ?? 0) + (int) $count; } + $decodedSummary = is_array($inventory['decoded_summary'] ?? null) ? $inventory['decoded_summary'] : array(); + $decodedFieldCount += (int) ($decodedSummary['field_count'] ?? 0); + $decodedOccurrences += (int) ($decodedSummary['occurrences'] ?? 0); + foreach ( is_array($decodedSummary['by_role'] ?? null) ? $decodedSummary['by_role'] : array() as $role => $count ) { + $decodedByRole[(string) $role] = ($decodedByRole[(string) $role] ?? 0) + (int) $count; + } } arsort($byRole); - return array('field_count' => $fieldCount, 'occurrences' => $occurrences, 'by_role' => $byRole); + arsort($decodedByRole); + return array( + 'field_count' => $fieldCount, + 'occurrences' => $occurrences, + 'by_role' => $byRole, + 'decoded_field_count' => $decodedFieldCount, + 'decoded_occurrences' => $decodedOccurrences, + 'decoded_by_role' => $decodedByRole, + ); } diff --git a/figma-transformer/scripts/figma-node-trace.php b/figma-transformer/scripts/figma-node-trace.php index 150fc110..8ff285fa 100755 --- a/figma-transformer/scripts/figma-node-trace.php +++ b/figma-transformer/scripts/figma-node-trace.php @@ -17,23 +17,33 @@ } else { require_once __DIR__ . '/../figma-transformer.php'; } +require_once __DIR__ . '/figma-script-utils.php'; $options = blocks_engine_figma_trace_options($argv); if ( true === ($options['help'] ?? false) ) { blocks_engine_figma_trace_usage(STDOUT); exit(0); } +if ( blocks_engine_figma_script_bool_option($options['self_check'] ?? false) ) { + blocks_engine_figma_script_self_check(); + exit(0); +} if ( '' === ($options['input'] ?? '') || '' === ($options['frame_id'] ?? '') || empty($options['node_ids']) ) { blocks_engine_figma_trace_usage(STDERR); exit(1); } -$input = (string) $options['input']; +try { + $input = blocks_engine_figma_script_require_input_path((string) $options['input']); +} catch (Throwable $error) { + blocks_engine_figma_script_fail($error); +} $frameId = (string) $options['frame_id']; $nodeIds = $options['node_ids']; $zstdCommand = $options['zstd_command'] ?? (getenv('FIGMA_TRANSFORMER_ZSTD_COMMAND') ?: null); -$diagnosticLimit = (int) ($options['diagnostic_limit'] ?? 20); +$diagnosticLimit = blocks_engine_figma_script_int_option($options['diagnostic_limit'] ?? null, 20, 0, 500); +$summaryLimit = blocks_engine_figma_script_int_option($options['summary_limit'] ?? null, 20, 0, 500); $maxNodes = isset($options['max_nodes']) ? (int) $options['max_nodes'] : null; $archiveOptions = blocks_engine_figma_trace_archive_options($options); @@ -71,7 +81,7 @@ 'frame_id' => $frameId, 'node_ids' => $nodeIds, 'nodes' => array(), - 'transform_diagnostics' => blocks_engine_figma_trace_transform_diagnostics_summary($htmlReport), + 'transform_diagnostics' => blocks_engine_figma_trace_transform_diagnostics_summary($htmlReport, $summaryLimit), 'diagnostics_sample' => blocks_engine_figma_trace_diagnostics_sample($result, $htmlReport, $diagnosticLimit), 'metrics' => $result['metrics'] ?? array(), ); @@ -82,11 +92,13 @@ $sourceId = blocks_engine_figma_trace_component_source_id($normalizedNode); $style = blocks_engine_figma_trace_style_diagnostic($htmlReport, $nodeId); $className = is_array($style) ? (string) ($style['node']['class'] ?? '') : ''; + $visualNode = blocks_engine_figma_trace_visual_node($htmlReport, $nodeId); $trace['nodes'][] = array( 'id' => $nodeId, 'raw' => blocks_engine_figma_trace_node_summary($rawNodes[$nodeId] ?? (null !== $sourceId ? ($rawNodes[$sourceId] ?? null) : null), array(), isset($rawNodes[$nodeId]['id']) && is_scalar($rawNodes[$nodeId]['id']) ? (string) $rawNodes[$nodeId]['id'] : ($sourceId ?? $nodeId)), 'source' => null !== $sourceId ? blocks_engine_figma_trace_node_summary($rawNodes[$sourceId] ?? ($normalizedNodes[$sourceId] ?? null), $normalized, $sourceId) : null, 'normalized' => blocks_engine_figma_trace_node_summary($normalizedNode, $normalized, $nodeId), + 'ancestry' => blocks_engine_figma_trace_normalized_ancestry($normalizedNode, $normalizedNodes, $htmlReport), 'field_coverage' => blocks_engine_figma_trace_field_coverage($rawNodes[$nodeId] ?? (null !== $sourceId ? ($rawNodes[$sourceId] ?? null) : null), $normalizedNode), 'emitted' => array_filter(array( 'class' => '' !== $className ? $className : null, @@ -96,12 +108,17 @@ 'style_diagnostic' => $style, ), static fn (mixed $value): bool => null !== $value && array() !== $value), 'transform_diagnostics' => blocks_engine_figma_trace_node_transform_diagnostics($htmlReport, $nodeId), - 'visual' => blocks_engine_figma_trace_visual_node($htmlReport, $nodeId), + 'visual' => $visualNode, + 'geometry_trace' => blocks_engine_figma_trace_geometry_context($rawNodes[$nodeId] ?? (null !== $sourceId ? ($rawNodes[$sourceId] ?? null) : null), $normalizedNode, $visualNode, $htmlReport, $style), ); } -fwrite(STDOUT, blocks_engine_figma_trace_json_encode($trace) . "\n"); -exit(0); +try { + blocks_engine_figma_script_output_json($trace, isset($options['output']) ? (string) $options['output'] : null, blocks_engine_figma_trace_summary($trace)); + exit(0); +} catch (Throwable $error) { + blocks_engine_figma_script_fail($error); +} /** * @return array @@ -147,7 +164,7 @@ function blocks_engine_figma_trace_options(array $argv): array function blocks_engine_figma_trace_usage(mixed $stream): void { - fwrite($stream, "Usage: figma-node-trace.php --frame-id= --node-ids= [--zstd-command=/opt/homebrew/bin/zstd] [--max-kiwi-message-decode-bytes=1] [--max-nodes=5000] [--diagnostic-limit=20]\n"); + fwrite($stream, "Usage: figma-node-trace.php --frame-id= --node-ids= [--zstd-command=/opt/homebrew/bin/zstd] [--max-kiwi-message-decode-bytes=1] [--max-nodes=5000] [--diagnostic-limit=20] [--summary-limit=20] [--output=/tmp/trace.json] [--self-check]\n"); } /** @@ -157,7 +174,7 @@ function blocks_engine_figma_trace_archive_options(array $options): array { return array( 'include_asset_content' => false, - 'max_kiwi_message_decode_bytes' => max(1, (int) ($options['max_kiwi_message_decode_bytes'] ?? 1)), + 'max_kiwi_message_decode_bytes' => blocks_engine_figma_script_int_option($options['max_kiwi_message_decode_bytes'] ?? null, 1, 1, 104857600), ); } @@ -221,19 +238,19 @@ function blocks_engine_figma_trace_emit_result(array $normalized, array $transfo /** * @return array */ -function blocks_engine_figma_trace_transform_diagnostics_summary(array $htmlReport): array +function blocks_engine_figma_trace_transform_diagnostics_summary(array $htmlReport, int $limit = 20): array { $diagnostics = is_array($htmlReport['transform_diagnostics'] ?? null) ? $htmlReport['transform_diagnostics'] : array(); $artifactQuality = is_array($diagnostics['artifact_quality'] ?? null) ? $diagnostics['artifact_quality'] : array(); return array( 'schema' => 'blocks-engine/figma-transformer/node-trace-transform-diagnostics/v1', - 'artifact_quality_summary' => is_array($artifactQuality['summary'] ?? null) ? $artifactQuality['summary'] : array(), - 'components' => is_array($diagnostics['components'] ?? null) ? $diagnostics['components'] : array(), - 'effects' => is_array($diagnostics['effects'] ?? null) ? $diagnostics['effects'] : array(), - 'mask_effect_clipping' => is_array($diagnostics['mask_effect_clipping'] ?? null) ? $diagnostics['mask_effect_clipping'] : array(), - 'vector_child_composition' => is_array($diagnostics['vectors']['child_composition'] ?? null) ? $diagnostics['vectors']['child_composition'] : array(), - 'stacking_order' => is_array($diagnostics['layout']['stacking_order'] ?? null) ? $diagnostics['layout']['stacking_order'] : array(), + 'artifact_quality_summary' => blocks_engine_figma_script_bounded_summary_map(is_array($artifactQuality['summary'] ?? null) ? $artifactQuality['summary'] : array(), $limit), + 'components' => blocks_engine_figma_script_bounded_summary_map(is_array($diagnostics['components'] ?? null) ? $diagnostics['components'] : array(), $limit), + 'effects' => blocks_engine_figma_script_bounded_summary_map(is_array($diagnostics['effects'] ?? null) ? $diagnostics['effects'] : array(), $limit), + 'mask_effect_clipping' => blocks_engine_figma_script_bounded_summary_map(is_array($diagnostics['mask_effect_clipping'] ?? null) ? $diagnostics['mask_effect_clipping'] : array(), $limit), + 'vector_child_composition' => blocks_engine_figma_script_bounded_summary_map(is_array($diagnostics['vectors']['child_composition'] ?? null) ? $diagnostics['vectors']['child_composition'] : array(), $limit), + 'stacking_order' => blocks_engine_figma_script_bounded_summary_map(is_array($diagnostics['layout']['stacking_order'] ?? null) ? $diagnostics['layout']['stacking_order'] : array(), $limit), ); } @@ -480,6 +497,8 @@ function blocks_engine_figma_trace_node_summary(mixed $node, array $index, strin 'parent_id' => is_scalar($index['parent_index'][$nodeId] ?? null) ? (string) $index['parent_index'][$nodeId] : null, 'child_ids' => is_array($index['children_index'][$nodeId] ?? null) ? array_values($index['children_index'][$nodeId]) : array(), 'box' => ! empty($box) ? $box : blocks_engine_figma_trace_raw_box($node), + 'figma_box' => is_array($node['figma_box'] ?? null) ? $node['figma_box'] : null, + 'transform' => blocks_engine_figma_trace_transform_summary($node), 'layout' => ! empty($layout) ? $layout : blocks_engine_figma_trace_raw_layout($node), 'mask' => blocks_engine_figma_trace_mask_summary($node), 'text' => blocks_engine_figma_trace_text_summary($node), @@ -487,6 +506,93 @@ function blocks_engine_figma_trace_node_summary(mixed $node, array $index, strin ), static fn (mixed $value): bool => null !== $value && array() !== $value); } +function blocks_engine_figma_trace_transform_summary(array $node): array +{ + $summary = array(); + foreach ( array('transform', 'relativeTransform', 'absoluteTransform') as $key ) { + if ( is_array($node[$key] ?? null) ) { + $summary[$key] = $node[$key]; + } + } + if ( is_array($node['figma_box']['transform'] ?? null) ) { + $summary['figma_box_transform'] = $node['figma_box']['transform']; + } + if ( is_array($node['box']['transform'] ?? null) ) { + $summary['box_transform'] = $node['box']['transform']; + } + return $summary; +} + +/** + * @param array> $normalizedNodes + * @return array> + */ +function blocks_engine_figma_trace_normalized_ancestry(?array $node, array $normalizedNodes, array $htmlReport): array +{ + if ( ! is_array($node) ) { + return array(); + } + + $ancestors = array(); + $seen = array(); + $parentId = isset($node['parent_id']) && is_scalar($node['parent_id']) ? (string) $node['parent_id'] : ''; + while ( '' !== $parentId && ! isset($seen[$parentId]) && is_array($normalizedNodes[$parentId] ?? null) ) { + $seen[$parentId] = true; + $parent = $normalizedNodes[$parentId]; + $visual = blocks_engine_figma_trace_visual_node($htmlReport, $parentId); + $ancestors[] = array_filter(array( + 'id' => $parentId, + 'name' => isset($parent['name']) && is_scalar($parent['name']) ? (string) $parent['name'] : null, + 'type' => isset($parent['type']) && is_scalar($parent['type']) ? strtoupper((string) $parent['type']) : null, + 'box' => is_array($parent['box'] ?? null) ? $parent['box'] : null, + 'layout' => is_array($parent['layout'] ?? null) ? $parent['layout'] : null, + 'mask' => blocks_engine_figma_trace_mask_summary($parent), + 'visual_rect' => is_array($visual['rect'] ?? null) ? $visual['rect'] : null, + 'visible_rect' => is_array($visual['visible_rect'] ?? null) ? $visual['visible_rect'] : null, + 'clip' => is_array($visual['clip'] ?? null) ? $visual['clip'] : null, + ), static fn (mixed $value): bool => null !== $value && array() !== $value); + $parentId = isset($parent['parent_id']) && is_scalar($parent['parent_id']) ? (string) $parent['parent_id'] : ''; + } + + return $ancestors; +} + +function blocks_engine_figma_trace_geometry_context(mixed $rawNode, mixed $normalizedNode, mixed $visualNode, array $htmlReport, ?array $style): array +{ + $parentVisual = null; + if ( is_array($visualNode) && isset($visualNode['parent_id']) && is_scalar($visualNode['parent_id']) ) { + $parentVisual = blocks_engine_figma_trace_visual_node($htmlReport, (string) $visualNode['parent_id']); + } + + return array_filter(array( + 'raw_box' => is_array($rawNode) ? blocks_engine_figma_trace_raw_box($rawNode) : null, + 'raw_transform' => is_array($rawNode) ? blocks_engine_figma_trace_transform_summary($rawNode) : null, + 'normalized_box' => is_array($normalizedNode['box'] ?? null) ? $normalizedNode['box'] : null, + 'normalized_figma_box' => is_array($normalizedNode['figma_box'] ?? null) ? $normalizedNode['figma_box'] : null, + 'component_source_clone' => is_array($normalizedNode) ? blocks_engine_figma_trace_component_source_clone_summary($normalizedNode) : null, + 'normalized_transform' => is_array($normalizedNode) ? blocks_engine_figma_trace_transform_summary($normalizedNode) : null, + 'visual_rect' => is_array($visualNode['rect'] ?? null) ? $visualNode['rect'] : null, + 'visible_rect' => is_array($visualNode['visible_rect'] ?? null) ? $visualNode['visible_rect'] : null, + 'clip' => is_array($visualNode['clip'] ?? null) ? $visualNode['clip'] : null, + 'parent_visual_rect' => is_array($parentVisual['rect'] ?? null) ? $parentVisual['rect'] : null, + 'parent_visible_rect' => is_array($parentVisual['visible_rect'] ?? null) ? $parentVisual['visible_rect'] : null, + 'emitted_geometry' => is_array($style['emitted'] ?? null) ? $style['emitted'] : null, + 'expected_geometry' => is_array($style['expected'] ?? null) ? $style['expected'] : null, + 'style_mismatches' => is_array($style['mismatches'] ?? null) ? $style['mismatches'] : null, + ), static fn (mixed $value): bool => null !== $value && array() !== $value); +} + +function blocks_engine_figma_trace_component_source_clone_summary(array $node): array +{ + return array_filter(array( + 'source_id' => isset($node['figma_component_source_id']) && is_scalar($node['figma_component_source_id']) ? (string) $node['figma_component_source_id'] : null, + 'is_clone_geometry' => true === ($node['_component_source_clone_geometry'] ?? false) ? true : null, + 'source_box' => is_array($node['_component_source_clone_source_box'] ?? null) ? $node['_component_source_clone_source_box'] : null, + 'scale' => is_array($node['_component_source_clone_scale'] ?? null) ? $node['_component_source_clone_scale'] : null, + 'geometry_decision' => is_array($node['_component_source_clone_geometry_decision'] ?? null) ? $node['_component_source_clone_geometry_decision'] : null, + ), static fn (mixed $value): bool => null !== $value && array() !== $value); +} + function blocks_engine_figma_trace_mask_summary(array $node): array { if ( is_array($node['figma_mask'] ?? null) ) { @@ -619,8 +725,13 @@ function blocks_engine_figma_trace_interesting_fields(array $node): array 'absoluteBoundingBox', 'absoluteRenderBounds', 'size', 'x', 'y', 'width', 'height', 'rotation', 'fills', 'fillPaints', 'strokes', 'strokePaints', 'styles', 'styleIdForFill', 'styleIdForStroke', 'boundVariables', 'variableConsumptionMap', 'componentId', 'componentProperties', 'componentPropertyDefinitions', 'overrides', 'overrideTable', 'overrideMap', + 'overrideKey', 'proportionsConstrained', 'targetAspectRatio', 'derivedSymbolDataLayoutVersion', 'figma_component', + 'symbolData', 'derivedSymbolData', 'componentPropAssignments', 'componentPropDefs', 'componentPropRefs', 'guidPath', + 'componentKey', 'key', 'variantPropSpecs', 'isStateGroup', 'stateGroupPropertyValueOrders', 'characters', 'text', 'figma_text', 'style', 'textStyleOverrides', 'lineTypes', 'lineIndentations', 'vectorNetwork', 'vectorPaths', 'vectorPath', 'arcData', 'cornerRadius', 'rectangleCornerRadii', 'figma_component_source_id', + '_component_source_clone_geometry', '_component_source_clone_source_box', '_component_source_clone_scale', + '_component_source_clone_geometry_decision', '_figma_instance_override_applied', ); $summary = array(); foreach ( $interesting as $field ) { @@ -718,11 +829,22 @@ function blocks_engine_figma_trace_diagnostics_sample(array $result, array $html $transformDiagnostics = is_array($htmlReport['transform_diagnostics'] ?? null) ? $htmlReport['transform_diagnostics'] : array(); return array( 'top_level' => array_slice($diagnostics, 0, max(0, $limit)), - 'transform' => $transformDiagnostics, + 'transform' => blocks_engine_figma_script_bounded_summary_map($transformDiagnostics, $limit), ); } function blocks_engine_figma_trace_json_encode(array $value): string { - return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR) ?: '{}'; + return blocks_engine_figma_script_json_encode($value); +} + +function blocks_engine_figma_trace_summary(array $trace): array +{ + return array( + 'schema' => 'blocks-engine/figma-transformer/node-trace-summary/v1', + 'input' => $trace['input'] ?? array(), + 'frame_id' => $trace['frame_id'] ?? null, + 'node_count' => count(is_array($trace['nodes'] ?? null) ? $trace['nodes'] : array()), + 'metric_summary' => $trace['metrics'] ?? array(), + ); } diff --git a/figma-transformer/scripts/figma-parser-parity.php b/figma-transformer/scripts/figma-parser-parity.php index f439f409..15c6c747 100755 --- a/figma-transformer/scripts/figma-parser-parity.php +++ b/figma-transformer/scripts/figma-parser-parity.php @@ -16,23 +16,33 @@ } else { require_once __DIR__ . '/../figma-transformer.php'; } +require_once __DIR__ . '/figma-script-utils.php'; $options = blocks_engine_figma_parser_parity_options($argv); if ( true === ($options['help'] ?? false) ) { blocks_engine_figma_parser_parity_usage(STDOUT); exit(0); } +if ( blocks_engine_figma_script_bool_option($options['self_check'] ?? false) ) { + blocks_engine_figma_script_self_check(); + exit(0); +} if ( '' === ($options['input'] ?? '') ) { blocks_engine_figma_parser_parity_usage(STDERR); exit(1); } -$input = (string) $options['input']; +try { + $input = blocks_engine_figma_script_require_input_path((string) $options['input']); +} catch (Throwable $error) { + blocks_engine_figma_script_fail($error); +} $zstdCommand = $options['zstd_command'] ?? (getenv('FIGMA_TRANSFORMER_ZSTD_COMMAND') ?: null); $zstdCommand = is_string($zstdCommand) && '' !== $zstdCommand ? $zstdCommand : null; -$limit = max(1, (int) ($options['limit'] ?? 20)); -$sampleLimit = max(1, (int) ($options['sample_limit'] ?? 5)); +$limit = blocks_engine_figma_script_int_option($options['limit'] ?? null, 20, 1, 500); +$sampleLimit = blocks_engine_figma_script_int_option($options['sample_limit'] ?? null, 5, 1, 100); +$summaryLimit = blocks_engine_figma_script_int_option($options['summary_limit'] ?? null, 20, 0, 500); $maxNodes = isset($options['max_nodes']) ? (int) $options['max_nodes'] : null; $archiveOptions = blocks_engine_figma_parser_parity_archive_options($options); @@ -56,22 +66,15 @@ unset($transformScenegraph); $result = blocks_engine_figma_parser_parity_emit_result($normalized, $transformOptions); -$report = blocks_engine_figma_parser_parity_report($input, $source, $archive, $scenegraph, $normalized, $result, $transformOptions, $archiveOptions, $limit, $sampleLimit, $zstdCommand); -$json = blocks_engine_figma_parser_parity_json_encode($report) . "\n"; +$report = blocks_engine_figma_parser_parity_report($input, $source, $archive, $scenegraph, $normalized, $result, $transformOptions, $archiveOptions, $limit, $sampleLimit, $summaryLimit, $zstdCommand); -if ( isset($options['output']) && '' !== (string) $options['output'] ) { - $output = (string) $options['output']; - $directory = dirname($output); - if ( ! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory) ) { - fwrite(STDERR, "Unable to create output directory: {$directory}\n"); - exit(1); - } - file_put_contents($output, $json); +try { + blocks_engine_figma_script_output_json($report, isset($options['output']) ? (string) $options['output'] : null, $report['summary'] ?? array()); + exit(0); +} catch (Throwable $error) { + blocks_engine_figma_script_fail($error); } -fwrite(STDOUT, $json); -exit(0); - /** * @return array */ @@ -97,7 +100,7 @@ function blocks_engine_figma_parser_parity_options(array $argv): array function blocks_engine_figma_parser_parity_usage(mixed $stream): void { - fwrite($stream, "Usage: figma-parser-parity.php [--frame-id=] [--zstd-command=/opt/homebrew/bin/zstd] [--max-nodes=5000] [--max-kiwi-message-decode-bytes=1] [--include-asset-content=0] [--limit=20] [--sample-limit=5] [--output=/tmp/parity.json]\n"); + fwrite($stream, "Usage: figma-parser-parity.php [--frame-id=] [--zstd-command=/opt/homebrew/bin/zstd] [--max-nodes=5000] [--max-kiwi-message-decode-bytes=1] [--max-kiwi-selective-message-decode-bytes=33554432] [--include-asset-content=0] [--limit=20] [--sample-limit=5] [--summary-limit=20] [--output=/tmp/parity.json] [--self-check]\n"); } /** @@ -107,7 +110,8 @@ function blocks_engine_figma_parser_parity_archive_options(array $options): arra { return array( 'include_asset_content' => blocks_engine_figma_parser_parity_bool_option($options['include_asset_content'] ?? false), - 'max_kiwi_message_decode_bytes' => max(1, (int) ($options['max_kiwi_message_decode_bytes'] ?? 1)), + 'max_kiwi_message_decode_bytes' => blocks_engine_figma_script_int_option($options['max_kiwi_message_decode_bytes'] ?? null, 1, 1, 104857600), + 'max_kiwi_selective_message_decode_bytes' => blocks_engine_figma_script_int_option($options['max_kiwi_selective_message_decode_bytes'] ?? null, 33554432, 0, 104857600), ); } @@ -118,7 +122,7 @@ function blocks_engine_figma_parser_parity_bool_option(mixed $value): bool } $normalized = strtolower((string) $value); - return in_array($normalized, array('1', 'true', 'yes', 'on'), true); + return blocks_engine_figma_script_bool_option($normalized); } /** @@ -270,7 +274,7 @@ function blocks_engine_figma_parser_parity_scenegraph_score(array $payload, stri * @param array|null $archive * @return array */ -function blocks_engine_figma_parser_parity_report(string $input, array $source, ?array $archive, array $scenegraph, array $normalized, array $result, array $transformOptions, array $archiveOptions, int $limit, int $sampleLimit, ?string $zstdCommand): array +function blocks_engine_figma_parser_parity_report(string $input, array $source, ?array $archive, array $scenegraph, array $normalized, array $result, array $transformOptions, array $archiveOptions, int $limit, int $sampleLimit, int $summaryLimit, ?string $zstdCommand): array { $rawNodes = blocks_engine_figma_parser_parity_flat_node_map($scenegraph); $normalizedNodes = is_array($normalized['node_map'] ?? null) ? $normalized['node_map'] : array(); @@ -304,6 +308,14 @@ function blocks_engine_figma_parser_parity_report(string $input, array $source, 'zstd_command' => $zstdCommand, ), static fn (mixed $value): bool => null !== $value), 'options' => $transformOptions, + 'summary' => array( + 'schema' => 'blocks-engine/figma-transformer/parser-parity-summary/v1', + 'input' => array('path' => $input, 'shape' => $source['shape'] ?? null), + 'raw_node_count' => count($rawNodes), + 'normalized_node_count' => count($normalizedNodes), + 'emitted_visual_node_count' => count($visualNodeIds), + 'top_missing_field_path_count' => count($rawFieldReport['top_missing_field_paths']), + ), 'raw' => array( 'node_count' => count($rawNodes), 'field_path_count' => count($rawFieldReport['raw_field_paths']), @@ -346,7 +358,7 @@ function blocks_engine_figma_parser_parity_report(string $input, array $source, 'raw_component_props_to_normalized' => blocks_engine_figma_parser_parity_id_coverage($rawComponentPropNodeIds, array_keys($normalizedNodes), $sampleLimit), 'normalized_component_clone_to_emitted' => blocks_engine_figma_parser_parity_id_coverage($normalizedCloneNodeIds, $emittedNodeIdList, $sampleLimit, true), ), - 'transform_diagnostics' => blocks_engine_figma_parser_parity_transform_diagnostics_summary($htmlReport), + 'transform_diagnostics' => blocks_engine_figma_parser_parity_transform_diagnostics_summary($htmlReport, $summaryLimit), 'top_missing_field_paths' => $rawFieldReport['top_missing_field_paths'], 'diagnostics' => array( 'normalized_sample' => array_slice(is_array($normalized['diagnostics'] ?? null) ? $normalized['diagnostics'] : array(), 0, $limit), @@ -404,20 +416,20 @@ function blocks_engine_figma_parser_parity_variable_field_counts(array $rawNodes /** * @return array */ -function blocks_engine_figma_parser_parity_transform_diagnostics_summary(array $htmlReport): array +function blocks_engine_figma_parser_parity_transform_diagnostics_summary(array $htmlReport, int $limit = 20): array { $diagnostics = is_array($htmlReport['transform_diagnostics'] ?? null) ? $htmlReport['transform_diagnostics'] : array(); $artifactQuality = is_array($diagnostics['artifact_quality'] ?? null) ? $diagnostics['artifact_quality'] : array(); return array( 'schema' => 'blocks-engine/figma-transformer/parser-parity-transform-diagnostics/v1', - 'artifact_quality_summary' => is_array($artifactQuality['summary'] ?? null) ? $artifactQuality['summary'] : array(), - 'text' => is_array($diagnostics['text'] ?? null) ? $diagnostics['text'] : array(), - 'components' => is_array($diagnostics['components'] ?? null) ? $diagnostics['components'] : array(), - 'effects' => is_array($diagnostics['effects'] ?? null) ? $diagnostics['effects'] : array(), - 'mask_effect_clipping' => is_array($diagnostics['mask_effect_clipping'] ?? null) ? $diagnostics['mask_effect_clipping'] : array(), - 'vector_child_composition' => is_array($diagnostics['vectors']['child_composition'] ?? null) ? $diagnostics['vectors']['child_composition'] : array(), - 'stacking_order' => is_array($diagnostics['layout']['stacking_order'] ?? null) ? $diagnostics['layout']['stacking_order'] : array(), + 'artifact_quality_summary' => blocks_engine_figma_script_bounded_summary_map(is_array($artifactQuality['summary'] ?? null) ? $artifactQuality['summary'] : array(), $limit), + 'text' => blocks_engine_figma_script_bounded_summary_map(is_array($diagnostics['text'] ?? null) ? $diagnostics['text'] : array(), $limit), + 'components' => blocks_engine_figma_script_bounded_summary_map(is_array($diagnostics['components'] ?? null) ? $diagnostics['components'] : array(), $limit), + 'effects' => blocks_engine_figma_script_bounded_summary_map(is_array($diagnostics['effects'] ?? null) ? $diagnostics['effects'] : array(), $limit), + 'mask_effect_clipping' => blocks_engine_figma_script_bounded_summary_map(is_array($diagnostics['mask_effect_clipping'] ?? null) ? $diagnostics['mask_effect_clipping'] : array(), $limit), + 'vector_child_composition' => blocks_engine_figma_script_bounded_summary_map(is_array($diagnostics['vectors']['child_composition'] ?? null) ? $diagnostics['vectors']['child_composition'] : array(), $limit), + 'stacking_order' => blocks_engine_figma_script_bounded_summary_map(is_array($diagnostics['layout']['stacking_order'] ?? null) ? $diagnostics['layout']['stacking_order'] : array(), $limit), ); } @@ -704,7 +716,7 @@ function blocks_engine_figma_parser_parity_raw_asset_reference_node_ids(array $n if ( ! is_array($node) ) { continue; } - $encoded = json_encode($node, JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR); + $encoded = blocks_engine_figma_script_json_encode($node); if ( is_string($encoded) && preg_match('/("asset_id"|"imageRef"|"imageHash"|"hash"|"ref")/', $encoded) ) { $ids[] = (string) $id; } @@ -859,5 +871,5 @@ function blocks_engine_figma_parser_parity_file_content(array $result, string $p function blocks_engine_figma_parser_parity_json_encode(array $value): string { - return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR) ?: '{}'; + return blocks_engine_figma_script_json_encode($value); } diff --git a/figma-transformer/scripts/figma-route-identity-report.php b/figma-transformer/scripts/figma-route-identity-report.php new file mode 100644 index 00000000..74885421 --- /dev/null +++ b/figma-transformer/scripts/figma-route-identity-report.php @@ -0,0 +1,257 @@ + [--output=]\n"); + exit('' === $input ? 1 : 0); + } + + $options = array(); + foreach ( array_slice($argv, 2) as $argument ) { + if ( str_starts_with($argument, '--output=') ) { + $options['output'] = substr($argument, strlen('--output=')); + } + } + + $path = blocks_engine_figma_script_require_input_path($input); + $json = file_get_contents($path); + if ( false === $json ) { + throw new RuntimeException("Unable to read input JSON: {$path}"); + } + + $result = json_decode($json, true, flags: JSON_THROW_ON_ERROR); + if ( ! is_array($result) ) { + throw new RuntimeException('Input JSON did not decode to an object.'); + } + + $report = blocks_engine_figma_route_identity_report($result, $path); + blocks_engine_figma_script_output_json($report, $options['output'] ?? null, array( + 'page_count' => $report['summary']['page_count'], + 'duplicate_route_draft_frame_count' => $report['summary']['duplicate_route_draft_frame_count'], + 'unresolved_link_count' => $report['summary']['unresolved_link_count'], + 'implicit_route_link_count' => $report['summary']['implicit_route_link_count'], + )); +} catch ( Throwable $error ) { + blocks_engine_figma_script_fail($error); +} + +/** + * @param array $result + * @return array + */ +function blocks_engine_figma_route_identity_report(array $result, string $inputPath): array +{ + $html = is_array($result['source_reports']['figma']['html'] ?? null) ? $result['source_reports']['figma']['html'] : array(); + $pagePlan = is_array($html['page_plan'] ?? null) ? $html['page_plan'] : array(); + $plannedPages = is_array($pagePlan['pages'] ?? null) ? $pagePlan['pages'] : array(); + $renderedPages = is_array($html['pages'] ?? null) ? $html['pages'] : array(); + + $pages = array(); + $pathByFrameId = array(); + $pathBySlug = array(); + foreach ( $plannedPages as $index => $page ) { + if ( ! is_array($page) ) { + continue; + } + $identity = is_array($page['source_frame_identity'] ?? null) ? $page['source_frame_identity'] : array(); + $variants = is_array($page['variants'] ?? null) ? $page['variants'] : array(); + $frameId = (string) ($page['frame_id'] ?? ''); + $path = (string) ($page['path'] ?? ''); + $slug = (string) ($page['slug'] ?? ''); + $variantFrameIds = array_values(array_filter(array_map( + static fn (mixed $variant): string => is_array($variant) ? (string) ($variant['frame_id'] ?? '') : '', + $variants + ))); + + if ( '' !== $frameId && '' !== $path ) { + $pathByFrameId[$frameId] = $path; + } + foreach ( $variantFrameIds as $variantFrameId ) { + if ( '' !== $variantFrameId && '' !== $path ) { + $pathByFrameId[$variantFrameId] = $path; + } + } + if ( '' !== $slug && '' !== $path ) { + $pathBySlug[$slug] = $path; + } + + $pages[] = array( + 'index' => $index, + 'frame_id' => $frameId, + 'name' => (string) ($page['name'] ?? ''), + 'slug' => $slug, + 'path' => $path, + 'entrypoint' => true === ($page['entrypoint'] ?? false), + 'page_type' => (string) ($page['page_type'] ?? ''), + 'figma_page_name' => $page['figma_page_name'] ?? null, + 'section_name' => $page['section_name'] ?? null, + 'responsive' => true === ($page['responsive'] ?? false), + 'variant_frame_ids' => $variantFrameIds, + 'source_identity' => array( + 'selected_frame_id' => $identity['selected_frame_id'] ?? null, + 'primary_frame_id' => $identity['primary_frame_id'] ?? null, + 'selected_is_primary' => $identity['selected_is_primary'] ?? null, + 'device_hint' => $identity['device_hint'] ?? null, + ), + ); + } + + $diagnostics = blocks_engine_figma_route_identity_unique_diagnostics(array_merge( + is_array($result['diagnostics'] ?? null) ? $result['diagnostics'] : array(), + is_array($pagePlan['diagnostics'] ?? null) ? $pagePlan['diagnostics'] : array() + )); + $duplicateRouteDrafts = array(); + $duplicateDrafts = array(); + foreach ( $diagnostics as $diagnostic ) { + if ( ! is_array($diagnostic) ) { + continue; + } + $code = (string) ($diagnostic['code'] ?? ''); + if ( 'duplicate_route_draft_frames' === $code ) { + $duplicateRouteDrafts[] = array( + 'route_identity' => $diagnostic['route_identity'] ?? null, + 'canonical_frame_id' => $diagnostic['canonical_frame_id'] ?? null, + 'canonical_path' => is_string($diagnostic['canonical_frame_id'] ?? null) ? ($pathByFrameId[$diagnostic['canonical_frame_id']] ?? null) : null, + 'draft_frame_ids' => array_values(is_array($diagnostic['draft_frame_ids'] ?? null) ? $diagnostic['draft_frame_ids'] : array()), + ); + } + if ( 'duplicate_draft_frames' === $code ) { + $duplicateDrafts[] = array( + 'canonical_frame_id' => $diagnostic['canonical_frame_id'] ?? null, + 'canonical_path' => is_string($diagnostic['canonical_frame_id'] ?? null) ? ($pathByFrameId[$diagnostic['canonical_frame_id']] ?? null) : null, + 'draft_frame_ids' => array_values(is_array($diagnostic['draft_frame_ids'] ?? null) ? $diagnostic['draft_frame_ids'] : array()), + 'device_hint' => $diagnostic['device_hint'] ?? null, + 'width' => $diagnostic['width'] ?? null, + ); + } + } + + $linksByPage = array(); + $routeTargetCounts = array(); + $unresolvedTargets = array(); + $implicitRouteUnresolvedTargets = array(); + $implicitRouteLinkCount = 0; + $nodeLinkCount = 0; + $urlLinkCount = 0; + $anchorsEmitted = 0; + foreach ( $renderedPages as $page ) { + if ( ! is_array($page) ) { + continue; + } + $links = is_array($page['transform_diagnostics']['links'] ?? null) ? $page['transform_diagnostics']['links'] : array(); + $pagePath = (string) ($page['path'] ?? ($page['output_path'] ?? '')); + $routeTargets = is_array($links['route_targets'] ?? null) ? $links['route_targets'] : array(); + foreach ( $routeTargets as $routeTarget ) { + if ( ! is_array($routeTarget) ) { + continue; + } + $path = (string) ($routeTarget['path'] ?? ''); + if ( '' !== $path ) { + $routeTargetCounts[$path] = ($routeTargetCounts[$path] ?? 0) + 1; + } + } + + $pageUnresolved = is_array($links['unresolved_targets'] ?? null) ? $links['unresolved_targets'] : array(); + foreach ( $pageUnresolved as $target ) { + if ( is_array($target) ) { + $target['page_path'] = $pagePath; + $unresolvedTargets[] = $target; + } + } + $pageImplicitUnresolved = is_array($links['implicit_route_unresolved_targets'] ?? null) ? $links['implicit_route_unresolved_targets'] : array(); + foreach ( $pageImplicitUnresolved as $target ) { + if ( is_array($target) ) { + $target['page_path'] = $pagePath; + $implicitRouteUnresolvedTargets[] = $target; + } + } + + $implicitRouteLinkCount += (int) ($links['implicit_route_links'] ?? 0); + $nodeLinkCount += (int) ($links['node_links'] ?? 0); + $urlLinkCount += (int) ($links['url_links'] ?? 0); + $anchorsEmitted += (int) ($links['anchors_emitted'] ?? 0); + $linksByPage[] = array( + 'path' => $pagePath, + 'sources_found' => (int) ($links['sources_found'] ?? 0), + 'anchors_emitted' => (int) ($links['anchors_emitted'] ?? 0), + 'node_links' => (int) ($links['node_links'] ?? 0), + 'url_links' => (int) ($links['url_links'] ?? 0), + 'implicit_route_links' => (int) ($links['implicit_route_links'] ?? 0), + 'implicit_route_self_suppressed' => (int) ($links['implicit_route_self_suppressed'] ?? 0), + 'implicit_route_unresolved' => (int) ($links['implicit_route_unresolved'] ?? 0), + 'unresolved' => (int) ($links['unresolved'] ?? 0), + 'route_targets' => $routeTargets, + ); + } + ksort($routeTargetCounts); + + return array( + 'schema' => 'blocks-engine/figma-transformer/route-identity-report/v1', + 'input_file' => basename($inputPath), + 'input_sha256' => hash_file('sha256', $inputPath) ?: null, + 'status' => $result['status'] ?? null, + 'summary' => array( + 'page_count' => count($pages), + 'candidate_count' => (int) ($pagePlan['candidate_count'] ?? 0), + 'selection_source' => $pagePlan['selection_source'] ?? null, + 'duplicate_draft_frame_count' => array_sum(array_map(static fn (array $entry): int => count($entry['draft_frame_ids']), $duplicateDrafts)), + 'duplicate_route_draft_frame_count' => array_sum(array_map(static fn (array $entry): int => count($entry['draft_frame_ids']), $duplicateRouteDrafts)), + 'anchors_emitted' => $anchorsEmitted, + 'node_link_count' => $nodeLinkCount, + 'url_link_count' => $urlLinkCount, + 'implicit_route_link_count' => $implicitRouteLinkCount, + 'implicit_route_unresolved_count' => count($implicitRouteUnresolvedTargets), + 'unresolved_link_count' => count($unresolvedTargets), + 'unique_route_target_count' => count($routeTargetCounts), + ), + 'pages' => $pages, + 'path_by_frame_id' => $pathByFrameId, + 'path_by_slug' => $pathBySlug, + 'duplicate_draft_frames' => $duplicateDrafts, + 'duplicate_route_draft_frames' => $duplicateRouteDrafts, + 'route_target_counts' => $routeTargetCounts, + 'links_by_page' => $linksByPage, + 'unresolved_targets' => $unresolvedTargets, + 'implicit_route_unresolved_targets' => $implicitRouteUnresolvedTargets, + ); +} + +/** + * @param array $diagnostics + * @return array> + */ +function blocks_engine_figma_route_identity_unique_diagnostics(array $diagnostics): array +{ + $unique = array(); + $seen = array(); + foreach ( $diagnostics as $diagnostic ) { + if ( ! is_array($diagnostic) ) { + continue; + } + $key = json_encode(array( + $diagnostic['code'] ?? null, + $diagnostic['canonical_frame_id'] ?? null, + $diagnostic['route_identity'] ?? null, + $diagnostic['draft_frame_ids'] ?? null, + $diagnostic['frame_ids'] ?? null, + )); + if ( ! is_string($key) || isset($seen[$key]) ) { + continue; + } + $seen[$key] = true; + $unique[] = $diagnostic; + } + return $unique; +} diff --git a/figma-transformer/scripts/figma-script-utils.php b/figma-transformer/scripts/figma-script-utils.php new file mode 100644 index 00000000..7f5c43da --- /dev/null +++ b/figma-transformer/scripts/figma-script-utils.php @@ -0,0 +1,176 @@ +getMessage(), 0, $error); + } +} + +function blocks_engine_figma_script_json_safe_value(mixed $value): mixed +{ + if ( is_float($value) ) { + return is_finite($value) ? $value : null; + } + + if ( is_int($value) || is_string($value) || is_bool($value) || null === $value ) { + return $value; + } + + if ( is_array($value) ) { + $safe = array(); + foreach ( $value as $key => $child ) { + $safe[is_int($key) ? $key : (string) $key] = blocks_engine_figma_script_json_safe_value($child); + } + return $safe; + } + + return get_debug_type($value); +} + +function blocks_engine_figma_script_limit_list(array $values, int $limit): array +{ + return array_slice(array_values($values), 0, max(0, $limit)); +} + +function blocks_engine_figma_script_limited_summary_value(mixed $value, int $limit): mixed +{ + if ( ! is_array($value) ) { + return blocks_engine_figma_script_json_safe_value($value); + } + + if ( array_is_list($value) ) { + return array( + 'count' => count($value), + 'sample' => array_map( + static fn (mixed $child): mixed => blocks_engine_figma_script_limited_summary_value($child, $limit), + blocks_engine_figma_script_limit_list($value, $limit) + ), + ); + } + + $summary = array('count' => count($value), 'sample' => array()); + foreach ( array_slice($value, 0, max(0, $limit), true) as $key => $child ) { + $summary['sample'][(string) $key] = blocks_engine_figma_script_limited_summary_value($child, $limit); + } + return $summary; +} + +function blocks_engine_figma_script_bounded_summary_map(array $value, int $limit): array +{ + $summary = array(); + foreach ( $value as $key => $child ) { + $summary[(string) $key] = is_array($child) + ? blocks_engine_figma_script_limited_summary_value($child, $limit) + : blocks_engine_figma_script_json_safe_value($child); + } + return $summary; +} + +function blocks_engine_figma_script_output_json(array $payload, ?string $outputPath = null, ?array $summary = null): void +{ + $json = blocks_engine_figma_script_json_encode($payload) . "\n"; + if ( null === $outputPath || '' === $outputPath ) { + fwrite(STDOUT, $json); + return; + } + + $path = blocks_engine_figma_script_prepare_output_path($outputPath); + if ( false === file_put_contents($path, $json) ) { + throw new RuntimeException("Failed to write JSON output to {$path}"); + } + + fwrite(STDOUT, blocks_engine_figma_script_json_encode(array( + 'schema' => 'blocks-engine/figma-transformer/script-output/v1', + 'output' => $path, + 'summary' => $summary ?? array(), + )) . "\n"); +} + +function blocks_engine_figma_script_self_check(): void +{ + $payload = array( + 'schema' => 'blocks-engine/figma-transformer/script-self-check/v1', + 'invalid_utf8' => "bad\xB1string", + 'non_finite' => array(NAN, INF, -INF), + 'nested' => array('sample' => array_fill(0, 3, array('value' => 1.25))), + ); + $json = blocks_engine_figma_script_json_encode($payload); + $decoded = json_decode($json, true); + if ( ! is_array($decoded) || 'blocks-engine/figma-transformer/script-self-check/v1' !== ($decoded['schema'] ?? null) ) { + throw new RuntimeException('Self-check failed: JSON output is not decodable.'); + } + if ( array(null, null, null) !== ($decoded['non_finite'] ?? null) ) { + throw new RuntimeException('Self-check failed: non-finite numbers were not sanitized.'); + } + + fwrite(STDOUT, $json . "\n"); +} + +function blocks_engine_figma_script_fail(Throwable $error): void +{ + fwrite(STDERR, $error->getMessage() . "\n"); + exit(1); +} diff --git a/figma-transformer/scripts/generate-system-fonts.php b/figma-transformer/scripts/generate-system-fonts.php index e40b1bbe..996438fc 100644 --- a/figma-transformer/scripts/generate-system-fonts.php +++ b/figma-transformer/scripts/generate-system-fonts.php @@ -57,13 +57,18 @@ 'Charcoal' => 'Charcoal, Impact, sans-serif', // --- Sans-serif: Humanist / UI (system UI faces) --- + 'SF Pro Display' => '"SF Pro Display", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + 'SF Pro Text' => '"SF Pro Text", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', 'Segoe UI' => '"Segoe UI", Tahoma, Geneva, Verdana, sans-serif', + 'SF Pro Text' => '"SF Pro Text", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + 'SF UI Text' => '"SF UI Text", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', 'Tahoma' => 'Tahoma, Geneva, Verdana, sans-serif', 'Geneva' => 'Geneva, Tahoma, Verdana, sans-serif', 'Verdana' => 'Verdana, Geneva, sans-serif', 'Trebuchet MS' => '"Trebuchet MS", "Lucida Grande", "Lucida Sans Unicode", "Lucida Sans", Tahoma, sans-serif', 'Calibri' => 'Calibri, "Segoe UI", Candara, Optima, sans-serif', 'Candara' => 'Candara, Calibri, "Segoe UI", Optima, sans-serif', + 'Avenir' => 'Avenir, "Avenir Next", "Helvetica Neue", Arial, sans-serif', 'Optima' => 'Optima, Candara, "Segoe UI", sans-serif', 'Gill Sans' => '"Gill Sans", "Gill Sans MT", Calibri, "Trebuchet MS", sans-serif', 'Gill Sans MT' => '"Gill Sans MT", "Gill Sans", Calibri, "Trebuchet MS", sans-serif', @@ -101,6 +106,7 @@ 'Consolas' => 'Consolas, "Lucida Console", Monaco, monospace', 'Monaco' => 'Monaco, Consolas, "Lucida Console", monospace', 'Menlo' => 'Menlo, Monaco, Consolas, "Courier New", monospace', + 'SF Mono' => '"SF Mono", Menlo, Monaco, Consolas, "Courier New", monospace', 'Lucida Console' => '"Lucida Console", Monaco, monospace', 'Andale Mono' => '"Andale Mono", AndaleMono, Monaco, monospace', ); diff --git a/figma-transformer/src/Compression/ZstdCapability.php b/figma-transformer/src/Compression/ZstdCapability.php index 77b753ea..9bcb7249 100644 --- a/figma-transformer/src/Compression/ZstdCapability.php +++ b/figma-transformer/src/Compression/ZstdCapability.php @@ -59,7 +59,7 @@ public function status(): array /** * @return array{data: string|null, diagnostics: array>} */ - public function uncompress(string $payload, string $source, int $chunkIndex): array + public function uncompress(string $payload, string $source, int $chunkIndex, array $context = array()): array { $status = $this->status(); @@ -76,7 +76,7 @@ public function uncompress(string $payload, string $source, int $chunkIndex): ar } try { - $decoded = $decoder($payload, array('source' => $source, 'chunk_index' => $chunkIndex, 'status' => $status)); + $decoded = $decoder($payload, array_merge($context, array('source' => $source, 'chunk_index' => $chunkIndex, 'status' => $status))); } catch ( \Throwable $throwable ) { return array( 'data' => null, @@ -101,6 +101,12 @@ public function uncompress(string $payload, string $source, int $chunkIndex): ar if ( is_array($decoded) ) { $diagnostics = array_merge($diagnostics, is_array($decoded['diagnostics'] ?? null) ? $decoded['diagnostics'] : array()); $decoded = $decoded['data'] ?? null; + if ( ! is_string($decoded) ) { + return array( + 'data' => null, + 'diagnostics' => $diagnostics, + ); + } } if ( is_string($decoded) ) { diff --git a/figma-transformer/src/Compression/ZstdCommandDecoder.php b/figma-transformer/src/Compression/ZstdCommandDecoder.php index 43e2b577..3862be61 100644 --- a/figma-transformer/src/Compression/ZstdCommandDecoder.php +++ b/figma-transformer/src/Compression/ZstdCommandDecoder.php @@ -68,6 +68,30 @@ public function __invoke(string $payload, array $context): array fclose($pipes[2]); $exitCode = proc_close($process); + $decodedBytes = 0 === $exitCode && is_readable($outputPath) ? filesize($outputPath) : false; + $maxDecodedBytes = isset($context['max_decoded_bytes']) && is_numeric($context['max_decoded_bytes']) ? max(0, (int) $context['max_decoded_bytes']) : 0; + if ( 0 === $exitCode && false !== $decodedBytes && $maxDecodedBytes > 0 && (int) $decodedBytes > $maxDecodedBytes ) { + @unlink($inputPath); + @unlink($outputPath); + + return array( + 'data' => null, + 'diagnostics' => array( + $this->diagnostic( + 'figma_transformer_zstd_command_output_preflight_failed', + 'Configured Zstandard command output exceeds the safe read limit and was not loaded into memory.', + array_merge( + $context, + array( + 'decoded_bytes' => (int) $decodedBytes, + 'max_decoded_bytes' => $maxDecodedBytes, + ) + ) + ), + ), + ); + } + $decoded = 0 === $exitCode && is_readable($outputPath) ? file_get_contents($outputPath) : false; @unlink($inputPath); @unlink($outputPath); diff --git a/figma-transformer/src/FigFile/FigArchiveReader.php b/figma-transformer/src/FigFile/FigArchiveReader.php index 2e63419f..fc4bb2bc 100644 --- a/figma-transformer/src/FigFile/FigArchiveReader.php +++ b/figma-transformer/src/FigFile/FigArchiveReader.php @@ -11,6 +11,10 @@ */ final class FigArchiveReader { + private const DEFAULT_MAX_CANVAS_BYTES = 67108864; + private const DEFAULT_MAX_NESTED_FIG_BYTES = 134217728; + private const DEFAULT_MAX_ARCHIVE_ASSET_CONTENT_BYTES = 134217728; + public function __construct( private readonly FigKiwiParser $figKiwiParser = new FigKiwiParser() ) { @@ -47,9 +51,37 @@ public function read(string $path, array $options = array()): array } $entries = $this->entries($zip); + $archiveMetrics = $this->archiveMetrics($zip, $entries); $figEntry = $this->findNestedFigEntry($entries); if ( null !== $figEntry ) { + $nestedStat = $zip->statName($figEntry); + $nestedBytes = is_array($nestedStat) ? (int) ($nestedStat['size'] ?? 0) : 0; + $maxNestedFigBytes = $this->optionBytes($options, 'max_nested_fig_bytes', self::DEFAULT_MAX_NESTED_FIG_BYTES); + if ( $maxNestedFigBytes > 0 && $nestedBytes > $maxNestedFigBytes ) { + $zip->close(); + return array( + 'input' => $input + array('nested_fig' => $figEntry), + 'archive' => array( + 'entries' => $entries, + 'metrics' => $archiveMetrics, + ), + 'meta' => array(), + 'assets' => array(), + 'diagnostics' => array($this->diagnostic( + 'figma_transformer_nested_fig_preflight_failed', + 'Nested .fig entry exceeds the configured safe read limit.', + 'FigArchiveReader', + array( + 'entry' => $figEntry, + 'bytes' => $nestedBytes, + 'max_read_bytes' => $maxNestedFigBytes, + 'recommended_next_step' => 'Inspect the wrapper archive and raise max_nested_fig_bytes only when the PHP memory limit can safely hold the nested .fig bytes.', + ) + )), + ); + } + $stream = $zip->getFromName($figEntry); $zip->close(); @@ -70,12 +102,13 @@ public function read(string $path, array $options = array()): array } $meta = $this->readMeta($zip); - $assets = $this->assetManifest($zip, $options); + $assetResult = $this->assetManifest($zip, $options); + $assets = $assetResult['assets']; $canvasResult = $this->readCanvas($zip, $options); $canvas = $canvasResult['canvas']; $zip->close(); - $diagnostics = $canvasResult['diagnostics']; + $diagnostics = array_merge($assetResult['diagnostics'], $canvasResult['diagnostics']); if ( null === $canvas ) { $diagnostics[] = $this->diagnostic('figma_transformer_missing_canvas', 'Archive does not contain canvas.fig.', 'FigArchiveReader'); } @@ -84,6 +117,7 @@ public function read(string $path, array $options = array()): array 'input' => $input, 'archive' => array( 'entries' => $entries, + 'metrics' => array_merge($archiveMetrics, $assetResult['metrics']), 'canvas' => $canvas, ), 'meta' => $meta, @@ -137,12 +171,33 @@ private function readMeta(ZipArchive $zip): array } /** - * @return array> + * @return array{assets: array>, diagnostics: array>, metrics: array} */ private function assetManifest(ZipArchive $zip, array $options = array()): array { $assets = array(); + $diagnostics = array(); $includeContent = false !== ($options['include_asset_content'] ?? true); + $assetStats = $this->assetStats($zip); + $maxAssetContentBytes = $this->optionBytes($options, 'max_archive_asset_content_bytes', self::DEFAULT_MAX_ARCHIVE_ASSET_CONTENT_BYTES); + + if ( $includeContent && $maxAssetContentBytes > 0 && $assetStats['total_asset_bytes'] > $maxAssetContentBytes ) { + $includeContent = false; + $diagnostics[] = $this->diagnostic( + 'figma_transformer_archive_asset_content_omitted_size', + 'Embedded asset content exceeds the configured safe read limit; asset metadata was retained without loading all bytes into memory.', + 'FigArchiveReader', + array( + 'asset_count' => $assetStats['asset_count'], + 'total_asset_bytes' => $assetStats['total_asset_bytes'], + 'largest_asset_bytes' => $assetStats['largest_asset_bytes'], + 'largest_asset_path' => $assetStats['largest_asset_path'], + 'max_asset_content_bytes' => $maxAssetContentBytes, + 'recommended_next_step' => 'Run with --omit-asset-content for archive inspection, or raise max_archive_asset_content_bytes only when the PHP memory limit can safely hold embedded asset bytes.', + ) + ); + } + for ( $index = 0; $index < $zip->numFiles; $index++ ) { $name = $zip->getNameIndex($index); if ( false === $name || ! str_starts_with($name, 'images/') || str_ends_with($name, '/') ) { @@ -169,7 +224,14 @@ private function assetManifest(ZipArchive $zip, array $options = array()): array $assets[] = $asset; } - return $assets; + return array( + 'assets' => $assets, + 'diagnostics' => $diagnostics, + 'metrics' => $assetStats + array( + 'asset_content_included' => $includeContent, + 'max_archive_asset_content_bytes' => $maxAssetContentBytes, + ), + ); } private function mimeTypeForPath(string $path, string $content = ''): string @@ -207,6 +269,32 @@ private function mimeTypeForPath(string $path, string $content = ''): string */ private function readCanvas(ZipArchive $zip, array $options = array()): array { + $stat = $zip->statName('canvas.fig'); + if ( is_array($stat) ) { + $canvasBytes = (int) ($stat['size'] ?? 0); + $maxCanvasBytes = $this->optionBytes($options, 'max_canvas_bytes', self::DEFAULT_MAX_CANVAS_BYTES); + if ( $maxCanvasBytes > 0 && $canvasBytes > $maxCanvasBytes ) { + return array( + 'canvas' => array( + 'bytes' => $canvasBytes, + 'skipped' => true, + 'reason' => 'canvas_decode_preflight_size_limit', + 'stat' => $this->zipStatReport($stat), + ), + 'diagnostics' => array($this->diagnostic( + 'figma_transformer_canvas_decode_preflight_failed', + 'canvas.fig exceeds the configured safe decode read limit.', + 'FigArchiveReader', + array( + 'bytes' => $canvasBytes, + 'max_read_bytes' => $maxCanvasBytes, + 'recommended_next_step' => 'Raise max_canvas_bytes only when the PHP memory limit can safely hold canvas.fig and inflated decode chunks, or inspect the archive with a lower-scope fixture.', + ) + )), + ); + } + } + $raw = $zip->getFromName('canvas.fig'); if ( false === $raw ) { return array( @@ -218,6 +306,83 @@ private function readCanvas(ZipArchive $zip, array $options = array()): array return $this->figKiwiParser->parse($raw, $options); } + /** + * @param array $entries + * @return array + */ + private function archiveMetrics(ZipArchive $zip, array $entries): array + { + $assetStats = $this->assetStats($zip); + $canvasStat = $zip->statName('canvas.fig'); + + return array_merge( + array( + 'entry_count' => count($entries), + 'canvas' => is_array($canvasStat) ? $this->zipStatReport($canvasStat) : null, + ), + $assetStats + ); + } + + /** + * @return array{asset_count: int, total_asset_bytes: int, largest_asset_bytes: int, largest_asset_path: string|null} + */ + private function assetStats(ZipArchive $zip): array + { + $assetCount = 0; + $totalAssetBytes = 0; + $largestAssetBytes = 0; + $largestAssetPath = null; + + for ( $index = 0; $index < $zip->numFiles; $index++ ) { + $name = $zip->getNameIndex($index); + if ( false === $name || ! str_starts_with($name, 'images/') || str_ends_with($name, '/') ) { + continue; + } + + $stat = $zip->statIndex($index); + $bytes = is_array($stat) ? (int) ($stat['size'] ?? 0) : 0; + $assetCount++; + $totalAssetBytes += $bytes; + if ( $bytes > $largestAssetBytes ) { + $largestAssetBytes = $bytes; + $largestAssetPath = $name; + } + } + + return array( + 'asset_count' => $assetCount, + 'total_asset_bytes' => $totalAssetBytes, + 'largest_asset_bytes' => $largestAssetBytes, + 'largest_asset_path' => $largestAssetPath, + ); + } + + /** + * @param array $stat + * @return array + */ + private function zipStatReport(array $stat): array + { + return array( + 'name' => (string) ($stat['name'] ?? ''), + 'bytes' => (int) ($stat['size'] ?? 0), + 'compressed_bytes' => (int) ($stat['comp_size'] ?? 0), + ); + } + + /** + * @param array $options + */ + private function optionBytes(array $options, string $key, int $default): int + { + if ( isset($options[$key]) && is_numeric($options[$key]) ) { + return max(0, (int) $options[$key]); + } + + return $default; + } + /** * @return array */ diff --git a/figma-transformer/src/FigFile/FigKiwiDecodePolicy.php b/figma-transformer/src/FigFile/FigKiwiDecodePolicy.php index b30a4263..b5b2ee74 100644 --- a/figma-transformer/src/FigFile/FigKiwiDecodePolicy.php +++ b/figma-transformer/src/FigFile/FigKiwiDecodePolicy.php @@ -14,7 +14,7 @@ final class FigKiwiDecodePolicy */ public function defaultScenegraphFieldPolicy(): array { - return array( + $policy = array( 'Message' => $this->scenegraphRootFields(), 'NodeChange' => $this->nodeChangeScenegraphFields(), 'GUID' => array('sessionID', 'localID'), @@ -23,7 +23,7 @@ public function defaultScenegraphFieldPolicy(): array 'Matrix' => array('m00', 'm01', 'm02', 'm10', 'm11', 'm12'), 'OptionalVector' => array('x', 'y'), 'Color' => array('r', 'g', 'b', 'a'), - 'ColorStop' => array('position', 'color'), + 'ColorStop' => array('position', 'color', 'colorVar', 'interpolation', 'interpolationMode', 'interpolationColorSpace'), 'FontName' => array('family', 'style', 'postscript'), // Inline styled-text spans (#328, feeding the normalizer path added in // #305/#299). In the Kiwi encoding the per-character style-run IDs ride @@ -47,7 +47,7 @@ public function defaultScenegraphFieldPolicy(): array 'HyperlinkBox' => array('bounds', 'url', 'guid', 'hyperlinkID', 'cmsTarget', 'openInNewTab'), 'FontVariation' => array('axisTag', 'axisName', 'value'), 'Number' => array('value', 'units'), - 'Paint' => array('type', 'color', 'opacity', 'visible', 'blendMode', 'stops', 'transform', 'imageTransform', 'cropTransform', 'cropRect', 'image', 'imageThumbnail', 'imageScaleMode', 'originalImageWidth', 'originalImageHeight', 'scale', 'rotation', 'imageShouldColorManage', 'thumbHash', 'animationFrame', 'altText', 'assetRef', 'sourceImage', 'publishID', 'sourceLibraryKey', 'libraryKey', 'exportSettings'), + 'Paint' => array('type', 'color', 'colorVar', 'opacity', 'visible', 'blendMode', 'stops', 'stopsVar', 'gradientInterpolation', 'interpolation', 'interpolationMode', 'colorInterpolation', 'colorSpace', 'interpolationColorSpace', 'gradientTransform', 'transform', 'imageTransform', 'cropTransform', 'cropRect', 'image', 'imageThumbnail', 'imageScaleMode', 'originalImageWidth', 'originalImageHeight', 'scale', 'rotation', 'imageShouldColorManage', 'thumbHash', 'animationFrame', 'altText', 'assetRef', 'sourceImage', 'publishID', 'sourceLibraryKey', 'libraryKey', 'exportSettings'), // Effect struct (#328). The Kiwi blur token is `FOREGROUND_BLUR` // (REST calls it `LAYER_BLUR`); the normalizer bridges both. `offset` // resolves to the whitelisted `Vector` struct and `color` to `Color`. @@ -67,7 +67,7 @@ public function defaultScenegraphFieldPolicy(): array 'Constraints' => array('horizontal', 'vertical'), 'SymbolData' => array('symbolID', 'symbolOverrides', 'symbolOverride', 'overrides', 'uniformScaleFactor'), 'DerivedSymbolData' => array('symbolID', 'symbolOverrides', 'symbolOverride', 'overrides', 'uniformScaleFactor'), - 'SymbolOverride' => array('nodeId', 'node_id', 'nodeID', 'id', 'guid', 'nodeGuid', 'guidPath', 'characters', 'text', 'name', 'textData', 'derivedTextData', 'fontName', 'fontFamily', 'fontPostScriptName', 'fontWeight', 'fontSize', 'lineHeight', 'lineHeightPx', 'lineHeightPercent', 'letterSpacing', 'listSpacing', 'styleIdForText', 'size', 'relativeTransform', 'absoluteTransform', 'transform', 'fillPaints', 'fills', 'strokes', 'strokePaints', 'strokeWeight', 'strokeAlign', 'dashPattern', 'borderStrokeWeightsIndependent', 'borderTopWeight', 'borderBottomWeight', 'borderLeftWeight', 'borderRightWeight', 'effects', 'styleIdForFill', 'styleIdForStrokeFill', 'styleIdForStroke', 'styleIdForEffect', 'fillGeometry', 'strokeGeometry', 'vectorPaths', 'paths', 'pathData', 'path', 'd', 'arcData', 'cornerRadius', 'rectangleTopLeftCornerRadius', 'rectangleTopRightCornerRadius', 'rectangleBottomLeftCornerRadius', 'rectangleBottomRightCornerRadius', 'stackWidth', 'stackHeight', 'stackMode', 'stackPrimarySizing', 'stackCounterSizing', 'stackSpacing', 'stackCounterSpacing', 'stackHorizontalGap', 'stackVerticalGap', 'stackWrap', 'stackReverseZIndex', 'stackPositioning', 'stackChildAlignSelf', 'stackChildPrimaryGrow', 'stackChildOrder', 'stackHorizontalPadding', 'stackVerticalPadding', 'stackPadding', 'stackPaddingLeft', 'stackPaddingRight', 'stackPaddingTop', 'stackPaddingBottom', 'stackPrimaryAlignItems', 'stackCounterAlignItems', 'layoutMode', 'primaryAxisSizingMode', 'counterAxisSizingMode', 'primaryAxisAlignItems', 'counterAxisAlignItems', 'itemSpacing', 'gap', 'counterAxisSpacing', 'counterAxisGap', 'layoutWrap', 'layoutGrow', 'layoutAlign', 'layoutOrder', 'layoutPositioning', 'layoutSizingHorizontal', 'layoutSizingVertical', 'horizontalSizing', 'verticalSizing', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'paddingHorizontal', 'paddingVertical', 'constraints', 'horizontalConstraint', 'verticalConstraint', 'minSize', 'maxSize', 'minWidth', 'maxWidth', 'minHeight', 'maxHeight', 'componentPropAssignments'), + 'SymbolOverride' => array('nodeId', 'node_id', 'nodeID', 'id', 'guid', 'nodeGuid', 'guidPath', 'characters', 'text', 'name', 'textData', 'derivedTextData', 'fontName', 'fontFamily', 'fontPostScriptName', 'fontWeight', 'fontSize', 'lineHeight', 'lineHeightPx', 'lineHeightPercent', 'letterSpacing', 'listSpacing', 'styleIdForText', 'size', 'relativeTransform', 'absoluteTransform', 'transform', 'fillPaints', 'fills', 'strokes', 'strokePaints', 'strokeWeight', 'strokeAlign', 'dashPattern', 'borderStrokeWeightsIndependent', 'borderTopWeight', 'borderBottomWeight', 'borderLeftWeight', 'borderRightWeight', 'effects', 'styleIdForFill', 'styleIdForStrokeFill', 'styleIdForStroke', 'styleIdForEffect', 'fillGeometry', 'strokeGeometry', 'vectorPaths', 'paths', 'pathData', 'path', 'd', 'arcData', 'cornerRadius', 'rectangleCornerRadiiIndependent', 'rectangleTopLeftCornerRadius', 'rectangleTopRightCornerRadius', 'rectangleBottomLeftCornerRadius', 'rectangleBottomRightCornerRadius', 'stackWidth', 'stackHeight', 'stackMode', 'stackPrimarySizing', 'stackCounterSizing', 'stackSpacing', 'stackCounterSpacing', 'stackHorizontalGap', 'stackVerticalGap', 'stackWrap', 'stackReverseZIndex', 'stackPositioning', 'stackChildAlignSelf', 'stackChildPrimaryGrow', 'stackChildOrder', 'stackHorizontalPadding', 'stackVerticalPadding', 'stackPadding', 'stackPaddingLeft', 'stackPaddingRight', 'stackPaddingTop', 'stackPaddingBottom', 'stackPrimaryAlignItems', 'stackCounterAlignItems', 'layoutMode', 'primaryAxisSizingMode', 'counterAxisSizingMode', 'primaryAxisAlignItems', 'counterAxisAlignItems', 'itemSpacing', 'gap', 'counterAxisSpacing', 'counterAxisGap', 'layoutWrap', 'layoutGrow', 'layoutAlign', 'layoutOrder', 'layoutPositioning', 'layoutSizingHorizontal', 'layoutSizingVertical', 'horizontalSizing', 'verticalSizing', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'paddingHorizontal', 'paddingVertical', 'constraints', 'horizontalConstraint', 'verticalConstraint', 'minSize', 'maxSize', 'minWidth', 'maxWidth', 'minHeight', 'maxHeight', 'componentPropAssignments'), 'GUIDPath' => array('guids', 'guid'), 'StyleId' => array('guid'), 'StateGroupPropertyValueOrder' => array('property', 'values'), @@ -113,6 +113,17 @@ public function defaultScenegraphFieldPolicy(): array 'PrototypeEvent' => array('interactionType'), 'PrototypeAction' => array('connectionType', 'connectionURL', 'transitionNodeID', 'navigationType', 'overlayPositionType', 'overlayRelativePosition', 'overlayBackground', 'overlayBackgroundInteraction', 'preserveScrollPosition', 'resetScrollPosition', 'resetVideoPosition', 'openUrlInNewTab', 'urlTarget'), ); + + $policy['NodeChange'] = array_values(array_unique(array_merge( + $policy['NodeChange'], + array('overrideKey', 'proportionsConstrained', 'targetAspectRatio', 'derivedSymbolDataLayoutVersion') + ))); + $policy['SymbolOverride'] = array_values(array_unique(array_merge( + $policy['SymbolOverride'], + array('overrideKey', 'proportionsConstrained', 'targetAspectRatio') + ))); + + return $policy; } /** @@ -164,7 +175,7 @@ public function initialInventoryContext(string $rootType): array public function classifySkippedFieldRole(string $fieldName, string $type, string $parentMessage = ''): string { $name = strtolower($fieldName . ' ' . $type . ' ' . $parentMessage); - if ( str_contains($name, 'variable') || str_contains($name, 'varvalue') || str_contains($name, 'parameter') || str_contains($name, 'consumption') ) { + if ( str_contains($name, 'variable') || str_contains($name, 'varvalue') || str_contains($name, 'colorvar') || str_contains($name, 'stopsvar') || str_contains($name, 'parameter') || str_contains($name, 'consumption') ) { return 'variables_bindings'; } if ( str_contains($name, 'stategroup') ) { @@ -318,10 +329,11 @@ private function nodeDevStatusFields(): array private function nodeGeometryFields(): array { return array( - 'size', 'transform', 'useAbsoluteBounds', 'cornerRadius', 'arcData', 'guides', 'layoutGrids', + 'size', 'transform', 'useAbsoluteBounds', 'cornerRadius', 'rectangleCornerRadiiIndependent', 'arcData', 'guides', 'layoutGrids', 'rectangleTopLeftCornerRadius', 'rectangleTopRightCornerRadius', 'rectangleBottomLeftCornerRadius', 'rectangleBottomRightCornerRadius', 'horizontalConstraint', 'verticalConstraint', 'resizeToFit', 'isClip', 'frameMaskDisabled', 'minSize', 'maxSize', + 'proportionsConstrained', 'targetAspectRatio', ); } @@ -357,6 +369,7 @@ private function nodeComponentFields(): array return array( 'key', 'componentKey', 'componentOrStateGroupKey', 'originComponentKey', 'componentId', 'mainComponentId', 'componentPropAssignments', 'componentPropDefs', 'componentPropRefs', + 'overrideKey', 'derivedSymbolDataLayoutVersion', 'isStateGroup', 'stateGroupPropertyValueOrders', 'variantPropSpecs', 'styleIdForEffect', 'mainComponent', 'component', 'symbolData', 'derivedSymbolData', 'guidPath', diff --git a/figma-transformer/src/FigFile/FigKiwiDecoder.php b/figma-transformer/src/FigFile/FigKiwiDecoder.php index a3a609ea..7c542ab3 100644 --- a/figma-transformer/src/FigFile/FigKiwiDecoder.php +++ b/figma-transformer/src/FigFile/FigKiwiDecoder.php @@ -13,10 +13,25 @@ final class FigKiwiDecoder private const INVENTORY_SAMPLE_LIMIT = 3; private const INVENTORY_SAMPLE_STRING_BYTES = 120; private const INVENTORY_SAMPLE_ARRAY_ITEMS = 8; + private const INVENTORY_DECODED_FIELD_NAMES = array( + 'colorVar' => true, + 'stopsVar' => true, + 'gradientInterpolation' => true, + 'interpolation' => true, + 'interpolationMode' => true, + 'colorInterpolation' => true, + 'colorSpace' => true, + 'interpolationColorSpace' => true, + ); private FigKiwiDecodePolicy $decodePolicy; private FigKiwiSchemaFields $schemaFields; + /** + * @var array> + */ + private array $allowedFieldCache = array(); + public function __construct(?FigKiwiDecodePolicy $decodePolicy = null, ?FigKiwiSchemaFields $schemaFields = null) { $this->decodePolicy = $decodePolicy ?? new FigKiwiDecodePolicy(); @@ -105,7 +120,7 @@ public function decodeMessage(string $payload, array $schema, string $rootType = * @param array $schema * @return array{message: array|null, diagnostics: array>} */ - public function decodeMessageSelective(string $payload, array $schema, string $rootType = 'Message', array $fieldPolicy = array()): array + public function decodeMessageSelective(string $payload, array $schema, string $rootType = 'Message', array $fieldPolicy = array(), array $options = array()): array { try { $definitions = $this->schemaFields->definitionsByName($schema); @@ -114,8 +129,23 @@ public function decodeMessageSelective(string $payload, array $schema, string $r } $policy = empty($fieldPolicy) ? $this->decodePolicy->defaultScenegraphFieldPolicy() : $fieldPolicy; - $message = $this->decodeDefinitionSelective(new FigKiwiByteReader($payload), $definitions[$rootType], $definitions, $policy); - return array('message' => is_array($message) ? $message : null, 'diagnostics' => array()); + $gate = $this->decodeNodeGateOptions($options); + $message = $this->decodeDefinitionSelective(new FigKiwiByteReader($payload), $definitions[$rootType], $definitions, $policy, $gate); + $diagnostics = array(); + if ( null !== $gate ) { + $diagnostics[] = array( + 'code' => 'figma_transformer_kiwi_message_gate_selective_decode_used', + 'message' => 'Kiwi message nodeChanges were filtered through a bounded gate plan during selective decode.', + 'source' => 'FigKiwiDecoder', + 'context' => array( + 'selected_node_count' => count($gate['selected_node_ids']), + 'decoded_node_count' => $gate['decoded_node_count'], + 'retained_node_count' => $gate['retained_node_count'], + 'skipped_node_count' => $gate['skipped_node_count'], + ), + ); + } + return array('message' => is_array($message) ? $message : null, 'diagnostics' => $diagnostics); } catch ( \Throwable $throwable ) { return array( 'message' => null, @@ -146,11 +176,13 @@ public function inventorySkippedFieldsSelective(string $payload, array $schema, 'policy_groups' => $this->decodePolicy->scenegraphFieldPolicyGroups(), 'schema_definitions' => $this->schemaFields->schemaDefinitionInventory($schema), 'fields' => array(), + 'decoded_fields' => array(), ); $context = $this->decodePolicy->initialInventoryContext($rootType); $this->inventoryDefinitionSelective(new FigKiwiByteReader($payload), $definitions[$rootType], $definitions, $policy, $inventory, $context); $inventory['summary'] = $this->decodePolicy->summarizeSkippedFieldInventory($inventory['fields']); + $inventory['decoded_summary'] = $this->decodePolicy->summarizeSkippedFieldInventory($inventory['decoded_fields']); return array('inventory' => $inventory, 'diagnostics' => array()); } catch ( \Throwable $throwable ) { return array( @@ -160,6 +192,407 @@ public function inventorySkippedFieldsSelective(string $payload, array $schema, } } + /** + * Inspect the minimal raw Kiwi fields needed to decide page/frame/node gates + * before materializing the full scenegraph payload. + * + * @param array $schema + * @param array $options + * @return array{gate: array|null, diagnostics: array>} + */ + public function inspectNodeGate(string $payload, array $schema, string $rootType = 'Message', array $options = array()): array + { + $policy = array( + 'Message' => array('type', 'nodeChanges'), + 'NodeChange' => array( + 'guid', 'type', 'name', 'parentIndex', 'sortPosition', 'visible', + 'componentId', 'mainComponentId', 'symbolData', 'detachedSymbolId', 'detachedSymbolID', 'overriddenSymbolId', 'overriddenSymbolID', 'derivedSymbolData', 'derivedSymbolDataLayoutVersion', + 'styleID', 'styleIdForText', 'styleIdForFill', 'styleIdForStrokeFill', 'styleIdForStroke', 'styleIdForEffect', + 'fillPaints', 'strokePaints', 'backgroundPaints', 'effects', + 'variableConsumptionMap', 'parameterConsumptionMap', 'variableDataValues', 'variableSetID', + ), + 'GUID' => array('sessionID', 'localID'), + 'ParentIndex' => array('guid', 'position'), + 'SymbolData' => array('symbolID', 'symbolOverrides', 'symbolOverride', 'overrides'), + 'DerivedSymbolData' => array('symbolID', 'symbolOverrides', 'symbolOverride', 'overrides'), + 'SymbolOverride' => array('nodeId', 'node_id', 'nodeID', 'id', 'guid', 'nodeGuid', 'guidPath', 'styleID', 'styleIdForText', 'styleIdForFill', 'styleIdForStrokeFill', 'styleIdForStroke', 'styleIdForEffect', 'fillPaints', 'fills', 'strokes', 'strokePaints', 'effects', 'variableConsumptionMap', 'parameterConsumptionMap', 'variableDataValues'), + 'GUIDPath' => array('guids', 'guid'), + 'SymbolId' => array('guid'), + 'StyleId' => array('guid'), + 'Paint' => array('type', 'colorVar', 'stops', 'stopsVar', 'image', 'imageThumbnail', 'assetRef', 'sourceImage', 'hash', 'publishID', 'sourceLibraryKey', 'libraryKey'), + 'ColorStop' => array('colorVar'), + 'Image' => array('hash', 'assetRef', 'sourceImage', 'publishID', 'sourceLibraryKey', 'libraryKey'), + 'SourceImage' => array('hash', 'assetRef', 'publishID', 'sourceLibraryKey', 'libraryKey'), + 'AssetRef' => array('id', 'key', 'nodeID', 'fileKey', 'libraryKey', 'publishID', 'sourceLibraryKey', 'guid'), + 'Effect' => array('styleID'), + 'VariableDataMap' => array('entries'), + 'VariableDataMapEntry' => array('nodeField', 'variableData', 'variableField'), + 'VariableData' => array('value', 'dataType', 'resolvedDataType'), + 'VariableAnyValue' => array('alias', 'colorValue', 'symbolIdValue', 'textDataValue', 'vectorValue', 'linkValue', 'propRefValue'), + 'VariableID' => array('guid', 'assetRef'), + 'VariableOverrideId' => array('guid', 'assetRef'), + 'VariableDataValues' => array('entries'), + 'VariableDataValuesEntry' => array('modeID', 'variableData'), + 'VariableSetID' => array('guid', 'assetRef'), + ); + $messageResult = $this->decodeMessageSelective($payload, $schema, $rootType, $policy); + if ( null === $messageResult['message'] ) { + return array('gate' => null, 'diagnostics' => $messageResult['diagnostics']); + } + + $nodeChanges = is_array($messageResult['message']['nodeChanges'] ?? null) ? $messageResult['message']['nodeChanges'] : array(); + $nodes = array(); + $children = array(); + $pages = array(); + $frames = array(); + $missingIds = 0; + $missingParents = 0; + + foreach ( $nodeChanges as $index => $node ) { + if ( ! is_array($node) ) { + continue; + } + + $id = $this->readGateNodeId($node); + if ( null === $id || '' === $id ) { + $missingIds++; + continue; + } + + $parentId = $this->readGateParentId($node); + if ( null === $parentId ) { + $missingParents++; + } else { + $children[$parentId] ??= array(); + $children[$parentId][] = $id; + } + + $type = isset($node['type']) && is_scalar($node['type']) ? (string) $node['type'] : ''; + $summary = array_filter( + array( + 'id' => $id, + 'type' => '' !== $type ? $type : null, + 'name' => isset($node['name']) && is_scalar($node['name']) ? (string) $node['name'] : null, + 'parent_id' => $parentId, + 'source_order' => is_int($index) ? $index : null, + ), + static fn (mixed $value): bool => null !== $value + ); + $nodes[$id] = $summary; + + if ( 'CANVAS' === $type ) { + $pages[] = $summary; + } elseif ( 'FRAME' === $type ) { + $frames[] = $summary; + } + } + + $selectedIds = $this->planGateNodeIds($nodes, $children, $pages, $options); + $dependencyGraph = $this->buildGateDependencyGraph($nodeChanges, $nodes, $selectedIds); + $blockers = array(); + if ( $missingIds > 0 ) { + $blockers[] = 'NodeChange.guid is missing on at least one node; stable filtering cannot preserve identities.'; + } + if ( count($nodes) > 0 && $missingParents === count($nodes) ) { + $blockers[] = 'NodeChange.parentIndex.guid is absent on every decoded node; page/frame subtree filtering cannot be computed from raw Kiwi identity fields.'; + } + + return array( + 'gate' => array( + 'schema' => 'blocks-engine/figma-transformer/kiwi-node-gate/v1', + 'root_type' => $rootType, + 'required_raw_fields' => array( + 'Message.nodeChanges', + 'NodeChange.guid.sessionID', + 'NodeChange.guid.localID', + 'NodeChange.type', + 'NodeChange.name', + 'NodeChange.parentIndex.guid.sessionID', + 'NodeChange.parentIndex.guid.localID', + 'NodeChange.parentIndex.position', + ), + 'options' => array_filter( + array( + 'max_pages' => isset($options['max_pages']) && is_numeric($options['max_pages']) ? (int) $options['max_pages'] : null, + 'max_nodes' => isset($options['max_nodes']) && is_numeric($options['max_nodes']) ? (int) $options['max_nodes'] : null, + 'frame_id' => isset($options['frame_id']) && is_scalar($options['frame_id']) ? (string) $options['frame_id'] : null, + 'frame_ids' => is_array($options['frame_ids'] ?? null) ? array_values(array_filter($options['frame_ids'], 'is_scalar')) : null, + ), + static fn (mixed $value): bool => null !== $value && array() !== $value + ), + 'node_count' => count($nodes), + 'page_count' => count($pages), + 'frame_count' => count($frames), + 'missing_id_count' => $missingIds, + 'missing_parent_count' => $missingParents, + 'pages_sample' => array_slice($pages, 0, 25), + 'frames_sample' => array_slice($frames, 0, 25), + 'nodes_sample' => array_slice(array_values($nodes), 0, 25), + 'gate_plan' => array( + 'feasible' => empty($blockers) && count($nodes) > 0, + 'selected_node_count' => count($selectedIds), + 'selected_node_ids' => $selectedIds, + 'selected_node_ids_sample' => array_slice($selectedIds, 0, 50), + 'blockers' => $blockers, + ), + 'dependency_graph' => $dependencyGraph['graph'], + ), + 'diagnostics' => array_merge($messageResult['diagnostics'], $dependencyGraph['diagnostics']), + ); + } + + /** + * @param array $nodeChanges + * @param array> $nodes + * @param array $selectedIds + * @return array{graph: array, diagnostics: array>} + */ + private function buildGateDependencyGraph(array $nodeChanges, array $nodes, array $selectedIds): array + { + $selected = array_flip($selectedIds); + $references = array( + 'component_node' => array(), + 'symbol_node' => array(), + 'style' => array(), + 'asset' => array(), + 'variable' => array(), + ); + $derivedSymbolDataNodes = array(); + + foreach ( $nodeChanges as $node ) { + if ( ! is_array($node) ) { + continue; + } + + $nodeId = $this->readGateNodeId($node); + if ( null === $nodeId || ! isset($selected[$nodeId]) ) { + continue; + } + + if ( isset($node['derivedSymbolData']) ) { + $derivedSymbolDataNodes[$nodeId] = $nodeId; + } + + $this->collectGateDependencyReferences($node, $nodeId, array(), $references); + } + + $nodeDependencies = $this->gateNodeDependencyReport($references, $nodes, $selected); + $diagnostics = array(); + if ( ! empty($nodeDependencies['external_to_gate']) ) { + $diagnostics[] = $this->diagnostic( + 'figma_transformer_kiwi_gate_selected_subtree_missing_node_dependencies', + 'Selected Kiwi gate subtree references component or symbol nodes outside the selected set.', + array( + 'external_reference_count' => count($nodeDependencies['external_to_gate']), + 'references_sample' => array_slice($nodeDependencies['external_to_gate'], 0, 25), + ) + ); + } + foreach ( array('asset', 'style', 'variable') as $kind ) { + $ids = $this->uniqueGateDependencyReferenceIds($references[$kind]); + if ( empty($ids) ) { + continue; + } + $diagnostics[] = $this->diagnostic( + 'figma_transformer_kiwi_gate_selected_subtree_external_' . $kind . '_references', + 'Selected Kiwi gate subtree references external ' . $kind . ' dependencies that must be carried with the gated nodes.', + array( + 'reference_count' => count($references[$kind]), + 'unique_reference_count' => count($ids), + 'ids_sample' => array_slice($ids, 0, 25), + ) + ); + } + + return array( + 'graph' => array( + 'schema' => 'blocks-engine/figma-transformer/kiwi-gate-dependency-graph/v1', + 'selected_node_count' => count($selectedIds), + 'reference_counts' => array( + 'component_node' => count($references['component_node']), + 'symbol_node' => count($references['symbol_node']), + 'style' => count($references['style']), + 'asset' => count($references['asset']), + 'variable' => count($references['variable']), + 'derived_symbol_data_node' => count($derivedSymbolDataNodes), + ), + 'unique_reference_counts' => array( + 'component_node' => count($this->uniqueGateDependencyReferenceIds($references['component_node'])), + 'symbol_node' => count($this->uniqueGateDependencyReferenceIds($references['symbol_node'])), + 'style' => count($this->uniqueGateDependencyReferenceIds($references['style'])), + 'asset' => count($this->uniqueGateDependencyReferenceIds($references['asset'])), + 'variable' => count($this->uniqueGateDependencyReferenceIds($references['variable'])), + ), + 'references_sample' => array( + 'component_node' => array_slice(array_values($references['component_node']), 0, 25), + 'symbol_node' => array_slice(array_values($references['symbol_node']), 0, 25), + 'style' => array_slice(array_values($references['style']), 0, 25), + 'asset' => array_slice(array_values($references['asset']), 0, 25), + 'variable' => array_slice(array_values($references['variable']), 0, 25), + 'derived_symbol_data_node_ids' => array_slice(array_values($derivedSymbolDataNodes), 0, 25), + ), + 'node_dependencies' => $nodeDependencies, + ), + 'diagnostics' => $diagnostics, + ); + } + + /** + * @param array> $references + * @return array + */ + private function uniqueGateDependencyReferenceIds(array $references): array + { + $ids = array(); + foreach ( $references as $reference ) { + $id = isset($reference['id']) && is_scalar($reference['id']) ? (string) $reference['id'] : ''; + if ( '' !== $id ) { + $ids[$id] = $id; + } + } + + return array_values($ids); + } + + /** + * @param array $value + * @param array $path + * @param array> $references + */ + private function collectGateDependencyReferences(mixed $value, string $sourceNodeId, array $path, array &$references): void + { + if ( ! is_array($value) ) { + return; + } + + foreach ( $value as $key => $child ) { + $field = is_string($key) ? $key : ''; + $childPath = '' === $field ? $path : array_merge($path, array($field)); + $kind = $this->gateDependencyKind($field, $childPath); + if ( null !== $kind ) { + $referenceId = $this->readGateDependencyReferenceId($child); + if ( null !== $referenceId ) { + $this->recordGateDependencyReference($references, $kind, $referenceId, $sourceNodeId, implode('.', $childPath)); + } + } + + if ( is_array($child) ) { + $this->collectGateDependencyReferences($child, $sourceNodeId, $childPath, $references); + } + } + } + + /** + * @param array $path + */ + private function gateDependencyKind(string $field, array $path): ?string + { + $normalized = strtolower($field); + $pathText = strtolower(implode('.', $path)); + if ( in_array($normalized, array('componentid', 'maincomponentid', 'component', 'maincomponent'), true) ) { + return 'component_node'; + } + if ( in_array($normalized, array('symbolid', 'symbolidvalue', 'detachedsymbolid', 'overriddensymbolid'), true) ) { + return 'symbol_node'; + } + if ( str_contains($normalized, 'styleid') || 'styleid' === $normalized ) { + return 'style'; + } + if ( in_array($normalized, array('assetref', 'image', 'imagethumbnail', 'sourceimage', 'hash'), true) || str_contains($pathText, 'assetref') ) { + return 'asset'; + } + if ( str_contains($normalized, 'variable') || str_contains($normalized, 'colorvar') || str_contains($normalized, 'stopsvar') || in_array($normalized, array('alias', 'variablesetid', 'modeid'), true) ) { + return 'variable'; + } + + return null; + } + + private function readGateDependencyReferenceId(mixed $value): ?string + { + $guid = $this->readGateGuid($value); + if ( null !== $guid ) { + return $guid; + } + if ( is_array($value) ) { + foreach ( array('id', 'key', 'nodeID', 'nodeId', 'node_id', 'hash', 'fileKey', 'libraryKey', 'publishID', 'sourceLibraryKey') as $key ) { + if ( isset($value[$key]) && is_scalar($value[$key]) ) { + $id = $this->normalizeGateDependencyScalarId($value[$key]); + if ( null !== $id ) { + return $id; + } + } + } + foreach ( array('guid', 'assetRef', 'alias') as $key ) { + $nested = $this->readGateDependencyReferenceId($value[$key] ?? null); + if ( null !== $nested ) { + return $nested; + } + } + } + + return null; + } + + private function normalizeGateDependencyScalarId(mixed $value): ?string + { + if ( ! is_scalar($value) ) { + return null; + } + + $id = (string) $value; + if ( '' === $id || ! preg_match('/\A[\x20-\x7e]+\z/', $id) ) { + return null; + } + + return $id; + } + + /** + * @param array> $references + */ + private function recordGateDependencyReference(array &$references, string $kind, string $referenceId, string $sourceNodeId, string $path): void + { + $key = $kind . '|' . $referenceId . '|' . $sourceNodeId . '|' . $path; + $references[$kind][$key] = array( + 'id' => $referenceId, + 'source_node_id' => $sourceNodeId, + 'path' => $path, + ); + } + + /** + * @param array> $references + * @param array> $nodes + * @param array $selected + * @return array + */ + private function gateNodeDependencyReport(array $references, array $nodes, array $selected): array + { + $external = array(); + foreach ( array('component_node', 'symbol_node') as $kind ) { + foreach ( $references[$kind] as $reference ) { + $id = (string) ($reference['id'] ?? ''); + if ( '' === $id || isset($selected[$id]) ) { + continue; + } + $external[] = array( + 'kind' => $kind, + 'id' => $id, + 'source_node_id' => (string) ($reference['source_node_id'] ?? ''), + 'path' => (string) ($reference['path'] ?? ''), + 'available_in_file' => isset($nodes[$id]), + 'referenced_node' => isset($nodes[$id]) ? $nodes[$id] : null, + ); + } + } + + return array( + 'external_to_gate_count' => count($external), + 'external_to_gate' => array_slice($external, 0, 100), + ); + } + /** * @param array $definition * @param array> $definitions @@ -194,11 +627,11 @@ private function decodeDefinition(FigKiwiByteReader $reader, array $definition, * @param array> $definitions * @param array> $fieldPolicy */ - private function decodeDefinitionSelective(FigKiwiByteReader $reader, array $definition, array $definitions, array $fieldPolicy): array + private function decodeDefinitionSelective(FigKiwiByteReader $reader, array $definition, array $definitions, array $fieldPolicy, ?array &$gate = null): array { $result = array(); $typeName = (string) ($definition['name'] ?? ''); - $allowed = array_flip($fieldPolicy[$typeName] ?? array()); + $allowed = $this->allowedFields($typeName, $fieldPolicy); if ( 'MESSAGE' === ($definition['kind'] ?? null) ) { $fieldsByValue = $this->schemaFields->fieldsByValue($definition); @@ -215,7 +648,7 @@ private function decodeDefinitionSelective(FigKiwiByteReader $reader, array $def $field = $fieldsByValue[$fieldValue]; $fieldName = $this->schemaFields->fieldName($field); if ( isset($allowed[$fieldName]) ) { - $this->decodeFieldSelective($reader, $field, $definitions, $fieldPolicy, $result); + $this->decodeFieldSelective($reader, $field, $definitions, $fieldPolicy, $result, $typeName, $gate); } else { $this->skipField($reader, $field, $definitions); } @@ -225,7 +658,7 @@ private function decodeDefinitionSelective(FigKiwiByteReader $reader, array $def foreach ( $this->schemaFields->fields($definition) as $field ) { $fieldName = $this->schemaFields->fieldName($field); if ( isset($allowed[$fieldName]) ) { - $this->decodeFieldSelective($reader, $field, $definitions, $fieldPolicy, $result); + $this->decodeFieldSelective($reader, $field, $definitions, $fieldPolicy, $result, $typeName, $gate); } else { $this->skipField($reader, $field, $definitions); } @@ -234,14 +667,31 @@ private function decodeDefinitionSelective(FigKiwiByteReader $reader, array $def return $result; } + /** + * @param array> $fieldPolicy + * @return array + */ + private function allowedFields(string $typeName, array $fieldPolicy): array + { + $fieldNames = $fieldPolicy[$typeName] ?? array(); + $cacheKey = $typeName . '|' . implode(',', $fieldNames); + if ( isset($this->allowedFieldCache[$cacheKey]) ) { + return $this->allowedFieldCache[$cacheKey]; + } + + $this->allowedFieldCache[$cacheKey] = array_flip($fieldNames); + return $this->allowedFieldCache[$cacheKey]; + } + /** * @param array $field * @param array> $definitions * @param array> $fieldPolicy * @param array $result */ - private function decodeFieldSelective(FigKiwiByteReader $reader, array $field, array $definitions, array $fieldPolicy, array &$result): void + private function decodeFieldSelective(FigKiwiByteReader $reader, array $field, array $definitions, array $fieldPolicy, array &$result, string $parentType = '', ?array &$gate = null): void { + $fieldName = $this->schemaFields->fieldName($field); $type = $this->schemaFields->fieldType($field); if ( $this->schemaFields->isArrayField($field) ) { if ( 'byte' === $type ) { @@ -250,15 +700,18 @@ private function decodeFieldSelective(FigKiwiByteReader $reader, array $field, a $length = $reader->readVarUint(); $value = array(); for ( $i = 0; $i < $length; $i++ ) { - $value[] = $this->decodeValueSelective($reader, $type, $definitions, $fieldPolicy); + $item = $this->decodeValueSelective($reader, $type, $definitions, $fieldPolicy, $gate); + if ( $this->shouldRetainDecodedArrayItem($parentType, $fieldName, $type, $item, $gate) ) { + $value[] = $item; + } } } } else { - $value = $this->decodeValueSelective($reader, $type, $definitions, $fieldPolicy); + $value = $this->decodeValueSelective($reader, $type, $definitions, $fieldPolicy, $gate); } if ( ! $this->schemaFields->isDeprecatedField($field) && isset($field['name']) ) { - $result[$this->schemaFields->fieldName($field)] = $value; + $result[$fieldName] = $value; } } @@ -266,7 +719,7 @@ private function decodeFieldSelective(FigKiwiByteReader $reader, array $field, a * @param array> $definitions * @param array> $fieldPolicy */ - private function decodeValueSelective(FigKiwiByteReader $reader, string $type, array $definitions, array $fieldPolicy): mixed + private function decodeValueSelective(FigKiwiByteReader $reader, string $type, array $definitions, array $fieldPolicy, ?array &$gate = null): mixed { return match ( $type ) { 'bool' => 0 !== $reader->readByte(), @@ -277,7 +730,7 @@ private function decodeValueSelective(FigKiwiByteReader $reader, string $type, a 'string' => $reader->readString(), 'int64' => $reader->readVarInt64(), 'uint64' => $reader->readVarUint64(), - default => $this->decodeNamedValueSelective($reader, $type, $definitions, $fieldPolicy), + default => $this->decodeNamedValueSelective($reader, $type, $definitions, $fieldPolicy, $gate), }; } @@ -285,7 +738,7 @@ private function decodeValueSelective(FigKiwiByteReader $reader, string $type, a * @param array> $definitions * @param array> $fieldPolicy */ - private function decodeNamedValueSelective(FigKiwiByteReader $reader, string $type, array $definitions, array $fieldPolicy): mixed + private function decodeNamedValueSelective(FigKiwiByteReader $reader, string $type, array $definitions, array $fieldPolicy, ?array &$gate = null): mixed { $definition = $definitions[$type] ?? null; if ( ! is_array($definition) ) { @@ -302,7 +755,53 @@ private function decodeNamedValueSelective(FigKiwiByteReader $reader, string $ty return $value; } - return $this->decodeDefinitionSelective($reader, $definition, $definitions, $fieldPolicy); + return $this->decodeDefinitionSelective($reader, $definition, $definitions, $fieldPolicy, $gate); + } + + /** + * @param array $options + * @return array{selected_node_ids: array, decoded_node_count: int, retained_node_count: int, skipped_node_count: int}|null + */ + private function decodeNodeGateOptions(array $options): ?array + { + if ( ! is_array($options['selected_node_ids'] ?? null) ) { + return null; + } + + $selected = array(); + foreach ( $options['selected_node_ids'] as $id ) { + if ( is_scalar($id) && '' !== (string) $id ) { + $selected[(string) $id] = true; + } + } + + if ( empty($selected) ) { + return null; + } + + return array( + 'selected_node_ids' => $selected, + 'decoded_node_count' => 0, + 'retained_node_count' => 0, + 'skipped_node_count' => 0, + ); + } + + private function shouldRetainDecodedArrayItem(string $parentType, string $fieldName, string $type, mixed $item, ?array &$gate): bool + { + if ( null === $gate || 'Message' !== $parentType || 'nodeChanges' !== $fieldName || 'NodeChange' !== $type || ! is_array($item) ) { + return true; + } + + $gate['decoded_node_count']++; + $nodeId = $this->readGateNodeId($item); + if ( null !== $nodeId && isset($gate['selected_node_ids'][$nodeId]) ) { + $gate['retained_node_count']++; + return true; + } + + $gate['skipped_node_count']++; + return false; } /** @@ -411,9 +910,11 @@ public function scenegraphFieldPolicyGroups(): array * @param array $inventory * @param array $context */ - private function inventoryDefinitionSelective(FigKiwiByteReader $reader, array $definition, array $definitions, array $fieldPolicy, array &$inventory, array $context): void + private function inventoryDefinitionSelective(FigKiwiByteReader $reader, array $definition, array $definitions, array $fieldPolicy, array &$inventory, array $context): array { + $result = array(); $typeName = (string) ($definition['name'] ?? ''); + $collectResult = in_array($typeName, array('Paint', 'ColorStop'), true); $allowed = array_flip($fieldPolicy[$typeName] ?? array()); $context['parent_type'] = $typeName; @@ -423,7 +924,7 @@ private function inventoryDefinitionSelective(FigKiwiByteReader $reader, array $ while ( true ) { $fieldValue = $reader->readVarUint(); if ( 0 === $fieldValue ) { - return; + return $result; } if ( ! isset($fieldsByValue[$fieldValue]) ) { throw new \RuntimeException('Attempted to inventory invalid message field ' . $fieldValue . '.'); @@ -432,7 +933,10 @@ private function inventoryDefinitionSelective(FigKiwiByteReader $reader, array $ $field = $fieldsByValue[$fieldValue]; $fieldName = $this->schemaFields->fieldName($field); if ( isset($allowed[$fieldName]) ) { - $this->inventoryDecodeFieldSelective($reader, $field, $definitions, $fieldPolicy, $inventory, $context); + $value = $this->inventoryDecodeFieldSelective($reader, $field, $definitions, $fieldPolicy, $inventory, $context); + if ( $collectResult ) { + $result[$fieldName] = $value; + } } else { $sample = $this->readFieldValue($reader, $field, $definitions); $this->recordSkippedField($inventory, $field, $definitions, $context, $sample); @@ -443,12 +947,17 @@ private function inventoryDefinitionSelective(FigKiwiByteReader $reader, array $ foreach ( $this->schemaFields->fields($definition) as $field ) { $fieldName = $this->schemaFields->fieldName($field); if ( isset($allowed[$fieldName]) ) { - $this->inventoryDecodeFieldSelective($reader, $field, $definitions, $fieldPolicy, $inventory, $context); + $value = $this->inventoryDecodeFieldSelective($reader, $field, $definitions, $fieldPolicy, $inventory, $context); + if ( $collectResult ) { + $result[$fieldName] = $value; + } } else { $sample = $this->readFieldValue($reader, $field, $definitions); $this->recordSkippedField($inventory, $field, $definitions, $context, $sample); } } + + return $result; } /** @@ -458,7 +967,7 @@ private function inventoryDefinitionSelective(FigKiwiByteReader $reader, array $ * @param array $inventory * @param array $context */ - private function inventoryDecodeFieldSelective(FigKiwiByteReader $reader, array $field, array $definitions, array $fieldPolicy, array &$inventory, array &$context): void + private function inventoryDecodeFieldSelective(FigKiwiByteReader $reader, array $field, array $definitions, array $fieldPolicy, array &$inventory, array &$context): mixed { $fieldName = $this->schemaFields->fieldName($field); $type = $this->schemaFields->fieldType($field); @@ -487,6 +996,11 @@ private function inventoryDecodeFieldSelective(FigKiwiByteReader $reader, array $context['node_id'] = $this->decodePolicy->formatInventoryNodeId($value); } } + if ( isset(self::INVENTORY_DECODED_FIELD_NAMES[$fieldName]) ) { + $this->recordInventoryField($inventory, 'decoded_fields', $field, $definitions, $context, $value); + } + + return $value; } /** @@ -533,7 +1047,7 @@ private function inventoryDecodeNamedValueSelective(FigKiwiByteReader $reader, s return $value; } - if ( 'STRUCT' === ($definition['kind'] ?? null) ) { + if ( 'STRUCT' === ($definition['kind'] ?? null) && ! in_array($type, array('Paint', 'ColorStop'), true) ) { return $this->decodeDefinitionSelective($reader, $definition, $definitions, $fieldPolicy); } @@ -544,8 +1058,7 @@ private function inventoryDecodeNamedValueSelective(FigKiwiByteReader $reader, s $childContext['node_id'] = null; } - $this->inventoryDefinitionSelective($reader, $definition, $definitions, $fieldPolicy, $inventory, $childContext); - return array(); + return $this->inventoryDefinitionSelective($reader, $definition, $definitions, $fieldPolicy, $inventory, $childContext); } /** @@ -554,6 +1067,16 @@ private function inventoryDecodeNamedValueSelective(FigKiwiByteReader $reader, s * @param array $context */ private function recordSkippedField(array &$inventory, array $field, array $definitions, array $context, mixed $sample): void + { + $this->recordInventoryField($inventory, 'fields', $field, $definitions, $context, $sample); + } + + /** + * @param array $inventory + * @param array $field + * @param array $context + */ + private function recordInventoryField(array &$inventory, string $bucket, array $field, array $definitions, array $context, mixed $sample): void { $fieldName = $this->schemaFields->fieldName($field); $type = $this->schemaFields->fieldType($field); @@ -561,10 +1084,12 @@ private function recordSkippedField(array &$inventory, array $field, array $defi $path = $this->schemaFields->fieldPath((string) ($context['path'] ?? $parentType), $fieldName); $role = $this->decodePolicy->classifySkippedFieldRole($fieldName, $type, $parentType); $key = $this->schemaFields->inventoryKey($parentType, $path, $fieldName, $type); - $typeDefinition = $this->schemaFields->typeDefinition($type, $definitions); + $typeDefinition = in_array($type, array('NodeChange'), true) + ? array('name' => $type, 'kind' => 'MESSAGE', 'fields_omitted' => 'large_recursive_definition') + : $this->schemaFields->typeDefinition($type, $definitions); - if ( ! isset($inventory['fields'][$key]) ) { - $inventory['fields'][$key] = array( + if ( ! isset($inventory[$bucket][$key]) ) { + $inventory[$bucket][$key] = array( 'path' => $path, 'field' => $fieldName, 'type' => $type, @@ -583,30 +1108,30 @@ private function recordSkippedField(array &$inventory, array $field, array $defi ); } - $inventory['fields'][$key]['occurrences']++; + $inventory[$bucket][$key]['occurrences']++; $nodeType = is_scalar($context['node_type'] ?? null) ? (string) $context['node_type'] : 'unknown'; - $inventory['fields'][$key]['node_types'][$nodeType] = ($inventory['fields'][$key]['node_types'][$nodeType] ?? 0) + 1; + $inventory[$bucket][$key]['node_types'][$nodeType] = ($inventory[$bucket][$key]['node_types'][$nodeType] ?? 0) + 1; $nodeId = is_scalar($context['node_id'] ?? null) ? (string) $context['node_id'] : ''; - if ( '' !== $nodeId && count($inventory['fields'][$key]['sample_node_ids']) < 5 && ! in_array($nodeId, $inventory['fields'][$key]['sample_node_ids'], true) ) { - $inventory['fields'][$key]['sample_node_ids'][] = $nodeId; + if ( '' !== $nodeId && count($inventory[$bucket][$key]['sample_node_ids']) < 5 && ! in_array($nodeId, $inventory[$bucket][$key]['sample_node_ids'], true) ) { + $inventory[$bucket][$key]['sample_node_ids'][] = $nodeId; } $normalized = $this->normalizeInventorySample($sample); - if ( count($inventory['fields'][$key]['sample_nodes']) < self::INVENTORY_SAMPLE_LIMIT ) { + if ( count($inventory[$bucket][$key]['sample_nodes']) < self::INVENTORY_SAMPLE_LIMIT ) { $nodeSample = array_filter(array( 'node_id' => '' !== $nodeId ? $nodeId : null, 'node_type' => $nodeType, 'path' => $path, 'raw_value' => $normalized, ), static fn (mixed $value): bool => null !== $value); - if ( ! in_array($nodeSample, $inventory['fields'][$key]['sample_nodes'], true) ) { - $inventory['fields'][$key]['sample_nodes'][] = $nodeSample; + if ( ! in_array($nodeSample, $inventory[$bucket][$key]['sample_nodes'], true) ) { + $inventory[$bucket][$key]['sample_nodes'][] = $nodeSample; } } - if ( count($inventory['fields'][$key]['sample_raw_values']) < self::INVENTORY_SAMPLE_LIMIT ) { - if ( ! in_array($normalized, $inventory['fields'][$key]['sample_raw_values'], true) ) { - $inventory['fields'][$key]['sample_raw_values'][] = $normalized; + if ( count($inventory[$bucket][$key]['sample_raw_values']) < self::INVENTORY_SAMPLE_LIMIT ) { + if ( ! in_array($normalized, $inventory[$bucket][$key]['sample_raw_values'], true) ) { + $inventory[$bucket][$key]['sample_raw_values'][] = $normalized; } } } @@ -685,6 +1210,107 @@ private function normalizeInventorySample(mixed $value): mixed return array('kind' => get_debug_type($value)); } + /** + * @param array $node + */ + private function readGateNodeId(array $node): ?string + { + if ( isset($node['id']) && is_scalar($node['id']) ) { + return (string) $node['id']; + } + + return $this->readGateGuid($node['guid'] ?? null); + } + + /** + * @param array $node + */ + private function readGateParentId(array $node): ?string + { + $parentIndex = $node['parentIndex'] ?? null; + if ( ! is_array($parentIndex) ) { + return null; + } + + return $this->readGateGuid($parentIndex['guid'] ?? null); + } + + private function readGateGuid(mixed $guid): ?string + { + if ( is_array($guid) && isset($guid['sessionID'], $guid['localID']) && is_scalar($guid['sessionID']) && is_scalar($guid['localID']) ) { + return (string) $guid['sessionID'] . ':' . (string) $guid['localID']; + } + + return is_scalar($guid) ? $this->normalizeGateDependencyScalarId($guid) : null; + } + + /** + * @param array> $nodes + * @param array> $children + * @param array> $pages + * @param array $options + * @return array + */ + private function planGateNodeIds(array $nodes, array $children, array $pages, array $options): array + { + $roots = array(); + $frameIds = array(); + if ( isset($options['frame_id']) && is_scalar($options['frame_id']) ) { + $frameIds[] = (string) $options['frame_id']; + } + if ( is_array($options['frame_ids'] ?? null) ) { + foreach ( $options['frame_ids'] as $frameId ) { + if ( is_scalar($frameId) ) { + $frameIds[] = (string) $frameId; + } + } + } + + foreach ( array_values(array_unique($frameIds)) as $frameId ) { + if ( isset($nodes[$frameId]) ) { + $roots[] = $frameId; + } + } + + if ( empty($roots) && isset($options['max_pages']) && is_numeric($options['max_pages']) && (int) $options['max_pages'] > 0 ) { + foreach ( array_slice($pages, 0, (int) $options['max_pages']) as $page ) { + if ( isset($page['id']) && is_scalar($page['id']) ) { + $roots[] = (string) $page['id']; + } + } + } + + $selected = empty($roots) ? array_keys($nodes) : $this->gateSubtreeIds($roots, $children); + if ( isset($options['max_nodes']) && is_numeric($options['max_nodes']) && (int) $options['max_nodes'] > 0 ) { + $selected = array_slice($selected, 0, (int) $options['max_nodes']); + } + + return array_values(array_unique($selected)); + } + + /** + * @param array $roots + * @param array> $children + * @return array + */ + private function gateSubtreeIds(array $roots, array $children): array + { + $selected = array(); + $queue = array_values($roots); + while ( ! empty($queue) ) { + $id = array_shift($queue); + if ( ! is_string($id) || isset($selected[$id]) ) { + continue; + } + $selected[$id] = $id; + foreach ( $children[$id] ?? array() as $childId ) { + $queue[] = $childId; + } + } + + return array_values($selected); + } + /** * @param array $field * @param array> $definitions @@ -756,9 +1382,9 @@ private function decodeNamedValue(FigKiwiByteReader $reader, string $type, array /** * @return array */ - private function diagnostic(string $code, string $message, string $error): array + private function diagnostic(string $code, string $message, mixed $error): array { - return array('code' => $code, 'message' => $message, 'source' => 'FigKiwiDecoder', 'context' => array('error' => $error)); + return array('code' => $code, 'message' => $message, 'source' => 'FigKiwiDecoder', 'context' => is_array($error) ? $error : array('error' => (string) $error)); } } diff --git a/figma-transformer/src/FigFile/FigKiwiParser.php b/figma-transformer/src/FigFile/FigKiwiParser.php index 5165a3e0..a578f1ef 100644 --- a/figma-transformer/src/FigFile/FigKiwiParser.php +++ b/figma-transformer/src/FigFile/FigKiwiParser.php @@ -19,6 +19,8 @@ final class FigKiwiParser private const WIRE_TYPE_FIXED32 = 5; private const WIRE_RECORD_LIMIT = 64; private const DEFAULT_MAX_KIWI_MESSAGE_DECODE_BYTES = 16777216; + private const DEFAULT_MAX_KIWI_SELECTIVE_MESSAGE_DECODE_BYTES = 33554432; + private const DEFAULT_MAX_ZSTD_INFLATED_BYTES = 67108864; public function __construct( private readonly ZstdCapability $zstdCapability = new ZstdCapability(), @@ -103,7 +105,12 @@ public function parse(string $raw, array $options = array()): array $chunk['payload'] = $this->classifyPayload($inflated, $kiwiSchema, $diagnostics, $options); } } elseif ( 'zstd' === $chunk['compression'] ) { - $zstdResult = $this->zstdCapability->uncompress($payload, 'FigKiwiParser', $index); + $zstdResult = $this->zstdCapability->uncompress( + $payload, + 'FigKiwiParser', + $index, + array('max_decoded_bytes' => $this->optionBytes($options, 'max_zstd_inflated_bytes', self::DEFAULT_MAX_ZSTD_INFLATED_BYTES)) + ); $diagnostics = array_merge($diagnostics, $zstdResult['diagnostics']); if ( null !== $zstdResult['data'] ) { $chunk['inflated_bytes'] = strlen($zstdResult['data']); @@ -134,6 +141,18 @@ private function uint32(string $bytes): int return is_array($value) ? (int) $value[1] : 0; } + /** + * @param array $options + */ + private function optionBytes(array $options, string $key, int $default): int + { + if ( isset($options[$key]) && is_numeric($options[$key]) ) { + return max(0, (int) $options[$key]); + } + + return $default; + } + private function detectCompression(string $payload): string { if ( str_starts_with($payload, self::ZSTD_MAGIC) ) { @@ -174,22 +193,55 @@ private function classifyPayload(string $payload, ?array &$kiwiSchema, array &$d } } else { $maxMessageDecodeBytes = (int) ($options['max_kiwi_message_decode_bytes'] ?? self::DEFAULT_MAX_KIWI_MESSAGE_DECODE_BYTES); + $nodeGate = $this->inspectNodeGate($payload, $kiwiSchema, $diagnostics, $options); + if ( true === ($options['kiwi_gate_only'] ?? false) ) { + $metadata['classification'] = null !== $nodeGate ? 'kiwi_message_gate' : 'kiwi_message_skipped'; + if ( null !== $nodeGate ) { + $metadata['kiwi_node_gate'] = $nodeGate; + } + return $metadata; + } if ( $maxMessageDecodeBytes > 0 && strlen($payload) > $maxMessageDecodeBytes ) { + $maxSelectiveMessageDecodeBytes = (int) ($options['max_kiwi_selective_message_decode_bytes'] ?? self::DEFAULT_MAX_KIWI_SELECTIVE_MESSAGE_DECODE_BYTES); + $gateDecodeOptions = $this->gateDecodeOptions($nodeGate); + if ( empty($gateDecodeOptions) && $maxSelectiveMessageDecodeBytes > 0 && strlen($payload) > $maxSelectiveMessageDecodeBytes ) { + $diagnostics[] = $this->diagnostic( + 'figma_transformer_kiwi_message_decode_skipped_preflight', + 'Kiwi message chunk exceeds the configured selective decode safety limit and was not decoded.', + array( + 'bytes' => strlen($payload), + 'max_decode_bytes' => $maxMessageDecodeBytes, + 'max_selective_decode_bytes' => $maxSelectiveMessageDecodeBytes, + 'recommended_next_step' => 'Expand bounded selective Kiwi decoding before raising this limit; full decoded arrays can exceed PHP memory on fatal-scale files.', + ) + ); + $metadata['classification'] = 'kiwi_message_skipped'; + if ( null !== $nodeGate ) { + $metadata['kiwi_node_gate'] = $nodeGate; + } + $metadata['kiwi_message_decode'] = 'skipped_preflight'; + return $metadata; + } + $fieldPolicy = true === ($options['render_text_glyph_paths'] ?? false) ? $this->kiwiDecoder->scenegraphFieldPolicyWithTextGlyphs() : array(); - $messageResult = $this->kiwiDecoder->decodeMessageSelective($payload, $kiwiSchema, 'Message', $fieldPolicy); + $messageResult = $this->kiwiDecoder->decodeMessageSelective($payload, $kiwiSchema, 'Message', $fieldPolicy, $gateDecodeOptions); $diagnostics = array_merge($diagnostics, $messageResult['diagnostics']); if ( null !== $messageResult['message'] ) { $diagnostics[] = $this->diagnostic( - 'figma_transformer_kiwi_message_selective_decode_used', - 'Kiwi message chunk exceeded the eager decode byte limit and was selectively decoded for scenegraph fields.', + empty($gateDecodeOptions) ? 'figma_transformer_kiwi_message_selective_decode_used' : 'figma_transformer_kiwi_message_gate_selective_decode_used', + empty($gateDecodeOptions) ? 'Kiwi message chunk exceeded the eager decode byte limit and was selectively decoded for scenegraph fields.' : 'Kiwi message chunk exceeded the eager decode byte limit and was selectively decoded through a bounded gate plan.', array( - 'bytes' => strlen($payload), - 'max_decode_bytes' => $maxMessageDecodeBytes, + 'bytes' => strlen($payload), + 'max_decode_bytes' => $maxMessageDecodeBytes, + 'selected_node_count' => count($gateDecodeOptions['selected_node_ids'] ?? array()), ) ); $metadata['classification'] = 'kiwi_message'; $metadata['kiwi_message'] = $messageResult['message']; - $metadata['kiwi_message_decode'] = 'selective'; + if ( null !== $nodeGate ) { + $metadata['kiwi_node_gate'] = $nodeGate; + } + $metadata['kiwi_message_decode'] = empty($gateDecodeOptions) ? 'selective' : 'gate_selective'; return $metadata; } @@ -211,6 +263,9 @@ private function classifyPayload(string $payload, ?array &$kiwiSchema, array &$d if ( null !== $messageResult['message'] ) { $metadata['classification'] = 'kiwi_message'; $metadata['kiwi_message'] = $messageResult['message']; + if ( null !== $nodeGate ) { + $metadata['kiwi_node_gate'] = $nodeGate; + } return $metadata; } } @@ -237,6 +292,42 @@ private function classifyPayload(string $payload, ?array &$kiwiSchema, array &$d return $metadata; } + /** + * @param array $schema + * @param array> $diagnostics + * @param array $options + */ + private function inspectNodeGate(string $payload, array $schema, array &$diagnostics, array $options): ?array + { + if ( true !== ($options['inspect_kiwi_gate'] ?? false) ) { + return null; + } + + $gateResult = $this->kiwiDecoder->inspectNodeGate($payload, $schema, 'Message', $options); + $diagnostics = array_merge($diagnostics, $gateResult['diagnostics']); + return $gateResult['gate']; + } + + /** + * @return array + */ + private function gateDecodeOptions(?array $nodeGate): array + { + if ( ! is_array($nodeGate['gate_plan'] ?? null) || true !== ($nodeGate['gate_plan']['feasible'] ?? null) ) { + return array(); + } + + $selected = is_array($nodeGate['gate_plan']['selected_node_ids'] ?? null) + ? $nodeGate['gate_plan']['selected_node_ids'] + : array(); + + if ( empty($selected) ) { + return array(); + } + + return array('selected_node_ids' => $selected); + } + /** * @param array $schema */ diff --git a/figma-transformer/src/FigFile/FigKiwiSchemaFields.php b/figma-transformer/src/FigFile/FigKiwiSchemaFields.php index 2662ffb8..6bef51e3 100644 --- a/figma-transformer/src/FigFile/FigKiwiSchemaFields.php +++ b/figma-transformer/src/FigFile/FigKiwiSchemaFields.php @@ -11,6 +11,11 @@ final class FigKiwiSchemaFields { public const PRIMITIVE_TYPES = array('bool', 'byte', 'int', 'uint', 'float', 'string', 'int64', 'uint64'); + /** + * @var array>> + */ + private array $fieldsByValueCache = array(); + /** * @param array $schema * @return array> @@ -33,6 +38,11 @@ public function definitionsByName(array $schema): array */ public function fieldsByValue(array $definition): array { + $cacheKey = $this->definitionCacheKey($definition); + if ( isset($this->fieldsByValueCache[$cacheKey]) ) { + return $this->fieldsByValueCache[$cacheKey]; + } + $fields = array(); foreach ( $definition['fields'] ?? array() as $field ) { if ( is_array($field) ) { @@ -40,9 +50,42 @@ public function fieldsByValue(array $definition): array } } + $this->fieldsByValueCache[$cacheKey] = $fields; return $fields; } + /** + * @param array $definition + */ + private function definitionCacheKey(array $definition): string + { + $fields = is_array($definition['fields'] ?? null) ? $definition['fields'] : array(); + $first = $fields[0] ?? array(); + $last = $fields[array_key_last($fields)] ?? array(); + + return implode('|', array( + (string) ($definition['name'] ?? ''), + (string) ($definition['kind'] ?? ''), + (string) count($fields), + is_array($first) ? $this->fieldCacheKeyPart($first) : '', + is_array($last) ? $this->fieldCacheKeyPart($last) : '', + )); + } + + /** + * @param array $field + */ + private function fieldCacheKeyPart(array $field): string + { + return implode(':', array( + (string) ($field['value'] ?? ''), + (string) ($field['name'] ?? ''), + (string) ($field['type'] ?? ''), + true === ($field['is_array'] ?? false) ? '1' : '0', + true === ($field['is_deprecated'] ?? false) ? '1' : '0', + )); + } + /** * @param array $definition * @return array> diff --git a/figma-transformer/src/FigmaTransformer.php b/figma-transformer/src/FigmaTransformer.php index 631b3681..b1e57671 100644 --- a/figma-transformer/src/FigmaTransformer.php +++ b/figma-transformer/src/FigmaTransformer.php @@ -10,6 +10,7 @@ use Automattic\BlocksEngine\FigmaTransformer\Diagnostics\RenderStyleMismatchReportBuilder; use Automattic\BlocksEngine\FigmaTransformer\FigFile\FigArchiveReader; use Automattic\BlocksEngine\FigmaTransformer\Html\FontResolver; +use Automattic\BlocksEngine\FigmaTransformer\Html\SourceLossCoverageBuilder; use Automattic\BlocksEngine\FigmaTransformer\Html\StaticHtmlEmitter; use Automattic\BlocksEngine\FigmaTransformer\Parity\ParityReportBuilder; use Automattic\BlocksEngine\FigmaTransformer\Scenegraph\ScenegraphFrameInspector; @@ -86,6 +87,49 @@ public function inspectFramesScenegraph(array $scenegraph, array $options = arra return $this->frameInspector->inspect($scenegraph, $options); } + /** + * Inspect minimal Kiwi node-gating metadata without materializing full nodes. + * + * @param array $options Inspection options. + * @return array + */ + public function inspectKiwiGateFile(string $path, array $options = array()): array + { + $options['inspect_kiwi_gate'] = true; + $options['kiwi_gate_only'] = true; + $archive = $this->archiveReader->read($path, $options); + $chunks = is_array($archive['archive']['canvas']['chunks'] ?? null) ? $archive['archive']['canvas']['chunks'] : array(); + $reports = array(); + + foreach ( $chunks as $chunk ) { + if ( ! is_array($chunk) ) { + continue; + } + + $payload = $chunk['payload'] ?? array(); + if ( ! is_array($payload) || ! is_array($payload['kiwi_node_gate'] ?? null) ) { + continue; + } + + $reports[] = array( + 'chunk_index' => (int) ($chunk['index'] ?? count($reports)), + 'compressed_bytes' => (int) ($chunk['compressed_bytes'] ?? 0), + 'inflated_bytes' => (int) ($chunk['inflated_bytes'] ?? 0), + 'compression' => (string) ($chunk['compression'] ?? ''), + 'kiwi_node_gate' => $payload['kiwi_node_gate'], + ); + } + + return array( + 'schema' => 'blocks-engine/figma-transformer/kiwi-gate-inspection/v1', + 'status' => empty($archive['diagnostics']) ? 'success' : 'success_with_warnings', + 'input' => $archive['input'] ?? array(), + 'report_count' => count($reports), + 'reports' => $reports, + 'diagnostics' => is_array($archive['diagnostics'] ?? null) ? $archive['diagnostics'] : array(), + ); + } + /** * Transform a .fig file or .fig wrapper archive into the canonical result envelope. * @@ -377,9 +421,11 @@ private function fallbackStatus(array $archive): string if ( in_array($code, array( 'figma_transformer_unreadable_file', 'figma_transformer_invalid_zip', + 'figma_transformer_nested_fig_preflight_failed', 'figma_transformer_nested_fig_unreadable', 'figma_transformer_tempfile_failed', 'figma_transformer_missing_canvas', + 'figma_transformer_canvas_decode_preflight_failed', 'figma_transformer_canvas_too_short', 'figma_transformer_kiwi_truncated_chunk_table', 'figma_transformer_kiwi_truncated_chunk', @@ -416,6 +462,7 @@ public function transformScenegraph(array $scenegraph, array $options = array()) $artifact = $this->withRenderStyleMismatchReport($artifact, $options); $diagnostics = array_merge($normalized['diagnostics'] ?? array(), $artifact['diagnostics']); $parity = $this->parityReportBuilder->build($options['parity'] ?? array()); + $transformDiagnostics = is_array($artifact['source_report']['transform_diagnostics'] ?? null) ? $artifact['source_report']['transform_diagnostics'] : array(); return FigmaTransformResult::create( $artifact['status'], @@ -437,6 +484,7 @@ public function transformScenegraph(array $scenegraph, array $options = array()) 'asset_count' => $artifact['metrics']['asset_count'] ?? 0, 'file_count' => count($artifact['files']), 'transform_duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), + 'vector_placeholder_count' => (int) ($transformDiagnostics['vectors']['placeholders'] ?? 0), ) ); } @@ -490,6 +538,9 @@ private function transformResponsivePage(array $scenegraph, array $variants, arr $primaryFrameId = (string) ($variants[0]['frame_id'] ?? ''); $pageName = isset($options['page_name']) && is_scalar($options['page_name']) ? (string) $options['page_name'] : ''; + $pagePath = isset($options['static_site_page_path']) && is_scalar($options['static_site_page_path']) && '' !== (string) $options['static_site_page_path'] + ? (string) $options['static_site_page_path'] + : 'index.html'; // Normalize the FULL scenegraph (drop the single-frame selection) so // every variant frame is present in the emitter node map. render_document @@ -511,7 +562,7 @@ private function transformResponsivePage(array $scenegraph, array $variants, arr array( 'frame_id' => $primaryFrameId, 'name' => '' !== $pageName ? $pageName : ($normalized['name'] ?? $primaryFrameId), - 'path' => 'index.html', + 'path' => $pagePath, 'entrypoint' => true, 'responsive' => true, 'variants' => $variants, @@ -528,6 +579,8 @@ private function transformResponsivePage(array $scenegraph, array $variants, arr $diagnostics = array_merge($normalized['diagnostics'] ?? array(), $artifact['diagnostics']); $parity = $this->parityReportBuilder->build($options['parity'] ?? array()); + $transformDiagnostics = is_array($artifact['source_report']['transform_diagnostics'] ?? null) ? $artifact['source_report']['transform_diagnostics'] : array(); + return FigmaTransformResult::create( $artifact['status'], $diagnostics, @@ -549,6 +602,7 @@ private function transformResponsivePage(array $scenegraph, array $variants, arr 'file_count' => count($artifact['files']), 'breakpoint_count' => count($variants), 'transform_duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), + 'vector_placeholder_count' => (int) ($transformDiagnostics['vectors']['placeholders'] ?? 0), ) ); } @@ -579,6 +633,7 @@ private function transformScenegraphPages(array $scenegraph, array $options = ar $cssChunks = array(); $cssChunkIndexesByPath = array(); $pageReports = array(); + $visualNodeMap = array(); $fontFamilies = array(); $fontUsage = array(); $fontCssSupplied = false; @@ -604,6 +659,9 @@ private function transformScenegraphPages(array $scenegraph, array $options = ar $pageOptions['layout_mismatch_options']['page_path'] = $path; $pageOptions['render_style_mismatch_options'] = is_array($pageOptions['render_style_mismatch_options'] ?? null) ? $pageOptions['render_style_mismatch_options'] : array(); $pageOptions['render_style_mismatch_options']['page_path'] = $path; + $pageOptions['static_site_page_path'] = $path; + $pageOptions['implicit_route_page_plan'] = $pagePlan; + $pageOptions['inline_css'] = false; unset($pageOptions['multi_page'], $pageOptions['include_all_pages'], $pageOptions['frame_ids'], $pageOptions['entry_frame_id'], $pageOptions['max_pages'], $pageOptions['frame_slug_map'], $pageOptions['responsive_variants'], $pageOptions['page_name']); $pageOptions['link_target_paths'] = $linkTargetPaths; @@ -629,13 +687,29 @@ private function transformScenegraphPages(array $scenegraph, array $options = ar $pageFontFamilies = is_array($pageHtmlReport['font_families'] ?? null) ? $pageHtmlReport['font_families'] : array(); $pageFontUsage = is_array($pageHtmlReport['font_usage'] ?? null) ? $pageHtmlReport['font_usage'] : array(); $pageTransformDiagnostics = is_array($pageHtmlReport['transform_diagnostics'] ?? null) ? $pageHtmlReport['transform_diagnostics'] : array(); + $pageIndex = count($pageReports); + $pageVisualNodeMap = $this->visualNodeMapWithPageTrace( + is_array($pageHtmlReport['visual_node_map'] ?? null) ? array_values($pageHtmlReport['visual_node_map']) : array(), + $pageIndex, + $frameId, + $path + ); + foreach ( $pageVisualNodeMap as $visualNode ) { + if ( is_array($visualNode) ) { + $visualNodeMap[] = $visualNode; + } + } $fontFamilies = $this->mergeFontFamilies($fontFamilies, $pageFontFamilies); $fontUsage = $this->mergeFontUsage($fontUsage, $pageFontUsage); $fontCssSupplied = $fontCssSupplied || true === ($pageHtmlReport['font_css_supplied'] ?? false); - $html = $this->fileContent($pageResult['files'] ?? array(), 'index.html'); + $html = $this->fileContent($pageResult['files'] ?? array(), $path); + if ( '' === $html ) { + $html = $this->fileContent($pageResult['files'] ?? array(), 'index.html'); + } $css = $this->fileContent($pageResult['files'] ?? array(), 'style.css'); if ( '' !== $css ) { + $css = $this->scopeRootCustomPropertiesToPage($css, $html); $cssChunkIndexesByPath[$path] = count($cssChunks); $cssChunks[] = $css; } @@ -672,6 +746,8 @@ private function transformScenegraphPages(array $scenegraph, array $options = ar 'font_families' => $pageFontFamilies, 'font_usage' => $pageFontUsage, 'font_css_supplied' => true === ($pageHtmlReport['font_css_supplied'] ?? false), + 'visual_node_count' => count($pageVisualNodeMap), + 'visual_node_map' => $pageVisualNodeMap, 'transform_diagnostics' => $pageTransformDiagnostics, 'diagnostic_codes' => $this->diagnosticCodeCounts($pageDiagnostics), ); @@ -703,7 +779,7 @@ private function transformScenegraphPages(array $scenegraph, array $options = ar } $assetReport = $this->assetReportFromFiles(array_values($assetsByPath)); - $transformDiagnostics = $this->mergePageTransformDiagnostics($pageReports, $assetReport); + $transformDiagnostics = $this->mergePageTransformDiagnostics($pageReports, $assetReport, $visualNodeMap); $parity = $this->parityReportBuilder->build($options['parity'] ?? array()); $artifact = array( 'files' => $files, @@ -711,6 +787,8 @@ private function transformScenegraphPages(array $scenegraph, array $options = ar 'source_report' => array( 'pages' => $pageReports, 'page_plan' => $pagePlan, + 'visual_node_count' => count($visualNodeMap), + 'visual_node_map' => $visualNodeMap, 'font_families' => $fontFamilies, 'font_usage' => $fontUsage, 'font_css_supplied' => $fontCssSupplied, @@ -743,10 +821,32 @@ private function transformScenegraphPages(array $scenegraph, array $options = ar 'file_count' => count($files), 'page_count' => count($pageReports), 'transform_duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), + 'vector_placeholder_count' => (int) ($transformDiagnostics['vectors']['placeholders'] ?? 0), ) ); } + /** + * @param array $visualNodeMap + * @return array> + */ + private function visualNodeMapWithPageTrace(array $visualNodeMap, int $pageIndex, string $frameId, string $path): array + { + $traced = array(); + foreach ( $visualNodeMap as $visualNode ) { + if ( ! is_array($visualNode) ) { + continue; + } + + $visualNode['source_page_index'] = $pageIndex; + $visualNode['source_page_frame_id'] = $frameId; + $visualNode['page_path'] = $path; + $traced[] = $visualNode; + } + + return $traced; + } + /** * @param mixed $files */ @@ -1023,11 +1123,12 @@ private function mapDescendantLinkTargets(string $rootId, string $path, array $c /** * @param array> $pageReports * @param array> $assetReport + * @param array> $visualNodeMap * @return array */ - private function mergePageTransformDiagnostics(array $pageReports, array $assetReport): array + private function mergePageTransformDiagnostics(array $pageReports, array $assetReport, array $visualNodeMap): array { - $images = array('paint_refs' => 0, 'node_refs' => 0, 'resolved_assets' => 0, 'image_block_count' => 0, 'total_node_count' => 0, 'image_block_nodes' => array(), 'missing_assets' => array()); + $images = array('paint_refs' => 0, 'node_refs' => 0, 'resolved_assets' => 0, 'image_block_count' => 0, 'total_node_count' => 0, 'image_block_nodes' => array(), 'missing_assets' => array(), 'asset_nodes' => array()); $vectors = array('nodes' => 0, 'rendered_paths' => 0, 'rendered_asset_fallbacks' => 0, 'vector_network_decoded' => 0, 'boolean_operations_composed' => 0, 'placeholders' => 0, 'placeholder_nodes' => array(), 'placeholder_reasons' => array()); $layout = array( 'large_negative_left_count' => 0, @@ -1086,9 +1187,46 @@ private function mergePageTransformDiagnostics(array $pageReports, array $assetR 'anchors_emitted' => 0, 'url_links' => 0, 'node_links' => 0, + 'toc_links' => 0, + 'implicit_route_links' => 0, + 'implicit_route_self_suppressed' => 0, + 'route_targets' => array(), 'unresolved' => 0, 'unresolved_targets' => array(), ); + $css = array( + 'schema' => 'blocks-engine/figma-transformer/css-diagnostics/v1', + 'invalid_numeric_token_count' => 0, + 'invalid_numeric_tokens' => array(), + ); + $htmlArtifact = array( + 'schema' => 'blocks-engine/figma-transformer/html-artifact-diagnostics/v1', + 'media_query_count' => 0, + 'fixed_width_over_desktop_count' => 0, + 'large_fixed_canvas_height' => false, + 'desktop_canvas_without_responsive_breakpoints' => false, + ); + $decisionTraces = array( + 'schema' => 'blocks-engine/figma-transformer/decision-traces/v1', + 'trace_count' => 0, + 'reason_counts' => array(), + 'domain_counts' => array(), + 'samples' => array(), + ); + $positionalParity = array( + 'schema' => 'blocks-engine/figma-transformer/positional-parity/v1', + 'full_bleed_viewport_width_count' => 0, + 'full_bleed_breakout_count' => 0, + 'mirrored_transform_count' => 0, + 'reflected_full_bleed_count' => 0, + 'fixed_over_root_width_underlay_count' => 0, + 'fixed_over_root_width_underlays' => array(), + 'chrome_overflow_count' => 0, + 'chrome_overflow_nodes' => array(), + 'root_stacking_trace_count' => 0, + 'root_stacking_reason_counts' => array(), + 'decision_trace_samples' => array(), + ); foreach ( $pageReports as $page ) { $diagnostics = is_array($page['transform_diagnostics'] ?? null) ? $page['transform_diagnostics'] : array(); @@ -1103,6 +1241,7 @@ private function mergePageTransformDiagnostics(array $pageReports, array $assetR DiagnosticAggregation::addIntegerCounts($images, $pageImages, array('paint_refs', 'node_refs', 'resolved_assets', 'image_block_count', 'total_node_count')); DiagnosticAggregation::appendContextSamples($images, 'image_block_nodes', $pageImages, 'image_block_nodes', $pageContext); DiagnosticAggregation::appendContextSamples($images, 'missing_assets', $pageImages, 'missing_assets', $pageContext); + DiagnosticAggregation::appendContextSamples($images, 'asset_nodes', $pageImages, 'asset_nodes', $pageContext); $pageVectors = is_array($diagnostics['vectors'] ?? null) ? $diagnostics['vectors'] : array(); DiagnosticAggregation::addIntegerCounts($vectors, $pageVectors, array('nodes', 'rendered_paths', 'rendered_asset_fallbacks', 'vector_network_decoded', 'boolean_operations_composed', 'placeholders')); @@ -1116,11 +1255,22 @@ private function mergePageTransformDiagnostics(array $pageReports, array $assetR $fontMaterialized = $fontMaterialized || true === ($pageFonts['materialized'] ?? false); $pageLinks = is_array($diagnostics['links'] ?? null) ? $diagnostics['links'] : array(); - DiagnosticAggregation::addIntegerCounts($links, $pageLinks, array('sources_found', 'anchors_emitted', 'url_links', 'node_links', 'unresolved')); + DiagnosticAggregation::addIntegerCounts($links, $pageLinks, array('sources_found', 'anchors_emitted', 'url_links', 'node_links', 'toc_links', 'implicit_route_links', 'implicit_route_self_suppressed', 'unresolved')); + DiagnosticAggregation::appendContextSamples($links, 'route_targets', $pageLinks, 'route_targets', $pageContext); DiagnosticAggregation::appendContextSamples($links, 'unresolved_targets', $pageLinks, 'unresolved_targets', $pageContext); + $pageCss = is_array($diagnostics['css'] ?? null) ? $diagnostics['css'] : array(); + DiagnosticAggregation::addIntegerCounts($css, $pageCss, array('invalid_numeric_token_count')); + DiagnosticAggregation::appendContextSamples($css, 'invalid_numeric_tokens', $pageCss, 'invalid_numeric_tokens', $pageContext); + $pageHtmlArtifact = is_array($diagnostics['html_artifact'] ?? null) ? $diagnostics['html_artifact'] : array(); + DiagnosticAggregation::addIntegerCounts($htmlArtifact, $pageHtmlArtifact, array('media_query_count', 'fixed_width_over_desktop_count')); + $htmlArtifact['large_fixed_canvas_height'] = ! empty($htmlArtifact['large_fixed_canvas_height']) || ! empty($pageHtmlArtifact['large_fixed_canvas_height']); + $htmlArtifact['desktop_canvas_without_responsive_breakpoints'] = ! empty($htmlArtifact['desktop_canvas_without_responsive_breakpoints']) || ! empty($pageHtmlArtifact['desktop_canvas_without_responsive_breakpoints']); + $this->mergeDecisionTraceDiagnostics($decisionTraces, is_array($diagnostics['decision_traces'] ?? null) ? $diagnostics['decision_traces'] : array(), $pageContext); + $pageLayout = is_array($diagnostics['layout'] ?? null) ? $diagnostics['layout'] : array(); DiagnosticAggregation::addIntegerCounts($layout, $pageLayout, array('large_negative_left_count', 'large_css_offset_count', 'off_canvas_visual_node_count', 'large_absolute_offset_count', 'empty_visible_container_count', 'empty_visible_container_blocker_count')); + $this->mergePositionalParityDiagnostics($positionalParity, is_array($pageLayout['positional_parity'] ?? null) ? $pageLayout['positional_parity'] : array(), $pageContext); DiagnosticAggregation::appendContextSamples($layout, 'large_css_offset_nodes', $pageLayout, 'large_css_offset_nodes', $pageContext); DiagnosticAggregation::appendContextSamples($layout, 'off_canvas_visual_nodes', $pageLayout, 'off_canvas_visual_nodes', $pageContext); DiagnosticAggregation::appendContextSamples($layout, 'large_absolute_offset_nodes', $pageLayout, 'large_absolute_offset_nodes', $pageContext); @@ -1209,6 +1359,7 @@ private function mergePageTransformDiagnostics(array $pageReports, array $assetR $layout['sticky_ghosts']['count'] = count($layout['sticky_ghosts']['candidates']); $layout['large_css_offset_nodes'] = array_values($layout['large_css_offset_nodes']); $layout['off_canvas_visual_nodes'] = array_values($layout['off_canvas_visual_nodes']); + $links['route_targets'] = array_values($links['route_targets']); $links['unresolved_targets'] = array_values($links['unresolved_targets']); $layout['empty_visible_containers'] = array_values($layout['empty_visible_containers']); ksort($layout['empty_visible_container_categories']); @@ -1218,6 +1369,13 @@ private function mergePageTransformDiagnostics(array $pageReports, array $assetR $layout['render_style']['status'] = 'not_run'; $layout['render_style_mismatch_status'] = 'not_run'; } + ksort($decisionTraces['reason_counts']); + ksort($decisionTraces['domain_counts']); + ksort($positionalParity['root_stacking_reason_counts']); + $positionalParity['fixed_over_root_width_underlays'] = array_slice(array_values($positionalParity['fixed_over_root_width_underlays']), 0, 25); + $positionalParity['chrome_overflow_nodes'] = array_slice(array_values($positionalParity['chrome_overflow_nodes']), 0, 25); + $positionalParity['decision_trace_samples'] = array_slice(array_values($positionalParity['decision_trace_samples']), 0, 100); + $layout['positional_parity'] = $positionalParity; ksort($diagnosticCodes); $fontResolution = ( new FontResolver() )->resolve($fontUsage, $fontCssSupplied ? 'operator-supplied' : ''); $fonts = array( @@ -1241,6 +1399,7 @@ private function mergePageTransformDiagnostics(array $pageReports, array $assetR 'schema' => 'blocks-engine/figma-transformer/transform-diagnostics/v1', 'scope' => 'multi_page', 'selection' => $this->multiPageSelectionDiagnostics($pageReports), + 'visual_node_map_summary' => $this->visualNodeMapSummary($visualNodeMap), 'pages' => $pages, 'images' => $images, 'vectors' => $vectors, @@ -1248,12 +1407,51 @@ private function mergePageTransformDiagnostics(array $pageReports, array $assetR 'assets' => $assets, 'generated_svg_assets' => $generatedSvgAssets, 'layout' => $layout, + 'decision_traces' => $decisionTraces, 'links' => $links, - 'artifact_quality' => $this->artifactQualityDiagnostics($images, $vectors, $fonts, $assets, $generatedSvgAssets, $layout, $links), + 'css' => $css, + 'html_artifact' => $htmlArtifact, + 'artifact_quality' => $this->artifactQualityDiagnostics($images, $vectors, $fonts, $assets, $generatedSvgAssets, $layout, $links, $css, $htmlArtifact), 'diagnostic_codes' => $diagnosticCodes, ); } + /** + * @param array $target + * @param array $source + * @param array $pageContext + */ + private function mergeDecisionTraceDiagnostics(array &$target, array $source, array $pageContext): void + { + $target['trace_count'] = (int) ($target['trace_count'] ?? 0) + (int) ($source['trace_count'] ?? 0); + DiagnosticAggregation::addCounterMap($target['reason_counts'], is_array($source['reason_counts'] ?? null) ? $source['reason_counts'] : array()); + DiagnosticAggregation::addCounterMap($target['domain_counts'], is_array($source['domain_counts'] ?? null) ? $source['domain_counts'] : array()); + DiagnosticAggregation::appendContextSamples($target, 'samples', $source, 'samples', $pageContext); + $target['samples'] = array_slice($target['samples'], 0, 100); + } + + /** + * @param array $target + * @param array $source + * @param array $pageContext + */ + private function mergePositionalParityDiagnostics(array &$target, array $source, array $pageContext): void + { + DiagnosticAggregation::addIntegerCounts($target, $source, array( + 'full_bleed_viewport_width_count', + 'full_bleed_breakout_count', + 'mirrored_transform_count', + 'reflected_full_bleed_count', + 'fixed_over_root_width_underlay_count', + 'chrome_overflow_count', + 'root_stacking_trace_count', + )); + DiagnosticAggregation::addCounterMap($target['root_stacking_reason_counts'], is_array($source['root_stacking_reason_counts'] ?? null) ? $source['root_stacking_reason_counts'] : array()); + DiagnosticAggregation::appendContextSamples($target, 'fixed_over_root_width_underlays', $source, 'fixed_over_root_width_underlays', $pageContext); + DiagnosticAggregation::appendContextSamples($target, 'chrome_overflow_nodes', $source, 'chrome_overflow_nodes', $pageContext); + DiagnosticAggregation::appendContextSamples($target, 'decision_trace_samples', $source, 'decision_trace_samples', $pageContext); + } + /** * @param array $images * @param array $vectors @@ -1262,9 +1460,11 @@ private function mergePageTransformDiagnostics(array $pageReports, array $assetR * @param array $generatedSvgAssets * @param array $layout * @param array $links + * @param array $css + * @param array $htmlArtifact * @return array */ - private function artifactQualityDiagnostics(array $images, array $vectors, array $fonts, array $assets, array $generatedSvgAssets, array $layout, array $links = array()): array + private function artifactQualityDiagnostics(array $images, array $vectors, array $fonts, array $assets, array $generatedSvgAssets, array $layout, array $links = array(), array $css = array(), array $htmlArtifact = array()): array { $signals = array(); @@ -1323,6 +1523,33 @@ private function artifactQualityDiagnostics(array $images, array $vectors, array 'sample_nodes' => array_slice(is_array($links['unresolved_targets'] ?? null) ? $links['unresolved_targets'] : array(), 0, 10), ); } + if ( ! empty($css['invalid_numeric_token_count']) ) { + $signals[] = array( + 'severity' => 'warning', + 'code' => 'invalid_css_numeric_token', + 'count' => (int) $css['invalid_numeric_token_count'], + 'sample_tokens' => array_slice(is_array($css['invalid_numeric_tokens'] ?? null) ? $css['invalid_numeric_tokens'] : array(), 0, 10), + ); + } + if ( ! empty($htmlArtifact['desktop_canvas_without_responsive_breakpoints']) ) { + $signals[] = array( + 'severity' => 'warning', + 'code' => 'desktop_canvas_without_responsive_breakpoints', + 'media_query_count' => (int) ($htmlArtifact['media_query_count'] ?? 0), + 'fixed_width_over_desktop_count' => (int) ($htmlArtifact['fixed_width_over_desktop_count'] ?? 0), + 'large_fixed_canvas_height' => (bool) ($htmlArtifact['large_fixed_canvas_height'] ?? false), + ); + } + $sourceLossCoverage = $this->sourceLossCoverage($images, $vectors); + if ( ! empty($sourceLossCoverage['not_emitted_source_nodes']) ) { + $signals[] = array( + 'severity' => 'warning', + 'code' => 'source_loss_coverage_gap', + 'count' => (int) $sourceLossCoverage['not_emitted_source_nodes'], + 'coverage_ratio' => (float) $sourceLossCoverage['coverage_ratio'], + 'domains' => $sourceLossCoverage['domains'], + ); + } $imageBlockCount = (int) ($images['image_block_count'] ?? 0); $totalNodeCount = max(0, (int) ($images['total_node_count'] ?? 0)); $imageNodeDensity = $totalNodeCount > 0 ? $imageBlockCount / $totalNodeCount : 0.0; @@ -1348,7 +1575,7 @@ private function artifactQualityDiagnostics(array $images, array $vectors, array ); } - $failCodes = array('missing_render_assets', 'vector_placeholders'); + $failCodes = array('missing_render_assets', 'vector_placeholders', 'invalid_css_numeric_token'); $failCount = count(array_filter($signals, static fn (array $signal): bool => in_array((string) ($signal['code'] ?? ''), $failCodes, true))); $warningCount = count(array_filter($signals, static fn (array $signal): bool => 'warning' === ($signal['severity'] ?? null))); $qualityStatus = $failCount > 0 ? 'fail' : (empty($signals) ? 'pass' : 'warn'); @@ -1383,16 +1610,93 @@ private function artifactQualityDiagnostics(array $images, array $vectors, array 'link_sources_found' => (int) ($links['sources_found'] ?? 0), 'anchors_emitted' => (int) ($links['anchors_emitted'] ?? 0), 'link_targets_unresolved' => (int) ($links['unresolved'] ?? 0), + 'invalid_css_numeric_tokens' => (int) ($css['invalid_numeric_token_count'] ?? 0), + 'media_query_count' => (int) ($htmlArtifact['media_query_count'] ?? 0), + 'fixed_width_over_desktop_count' => (int) ($htmlArtifact['fixed_width_over_desktop_count'] ?? 0), + 'desktop_canvas_without_responsive_breakpoints' => (bool) ($htmlArtifact['desktop_canvas_without_responsive_breakpoints'] ?? false), 'large_absolute_offset_count' => (int) ($layout['large_absolute_offset_count'] ?? 0), 'empty_visible_container_count' => (int) ($layout['empty_visible_container_count'] ?? 0), 'empty_visible_container_blocker_count' => (int) ($layout['empty_visible_container_blocker_count'] ?? 0), 'image_heavy_landmark_candidates' => count($layout['image_heavy_landmark_candidates'] ?? array()), 'layout_mismatch_count' => (int) ($layout['layout_mismatch_count'] ?? 0), 'layout_mismatch_status' => (string) ($layout['layout_mismatch_status'] ?? 'not_evaluated'), + 'source_loss_coverage' => $sourceLossCoverage, ), ); } + /** + * @param array $images + * @param array $vectors + * @return array + */ + private function sourceLossCoverage(array $images, array $vectors): array + { + $sourceLossCoverageBuilder = new SourceLossCoverageBuilder(); + $domains = array( + 'images' => $sourceLossCoverageBuilder->imageDomain($images), + 'vectors' => $sourceLossCoverageBuilder->vectorDomain($vectors), + ); + + return $sourceLossCoverageBuilder->aggregate($domains); + } + + /** + * @param array> $visualNodeMap + * @return array + */ + private function visualNodeMapSummary(array $visualNodeMap): array + { + $pagePathCounts = array(); + $sourcePageCounts = array(); + $emittedClassSamples = array(); + $withEmittedMetadata = 0; + $withPagePath = 0; + + foreach ( $visualNodeMap as $visualNode ) { + if ( ! is_array($visualNode) ) { + continue; + } + + $pagePath = isset($visualNode['page_path']) && is_scalar($visualNode['page_path']) ? (string) $visualNode['page_path'] : ''; + if ( '' !== $pagePath ) { + ++$withPagePath; + $pagePathCounts[$pagePath] = ($pagePathCounts[$pagePath] ?? 0) + 1; + } + + if ( isset($visualNode['source_page_index']) && is_numeric($visualNode['source_page_index']) ) { + $sourcePageIndex = (string) ((int) $visualNode['source_page_index']); + $sourcePageCounts[$sourcePageIndex] = ($sourcePageCounts[$sourcePageIndex] ?? 0) + 1; + } + + $emittedClass = isset($visualNode['emitted_class']) && is_scalar($visualNode['emitted_class']) ? (string) $visualNode['emitted_class'] : ''; + $emittedTag = isset($visualNode['emitted_tag']) && is_scalar($visualNode['emitted_tag']) ? (string) $visualNode['emitted_tag'] : ''; + if ( '' !== $emittedClass || '' !== $emittedTag ) { + ++$withEmittedMetadata; + } + if ( '' !== $emittedClass && count($emittedClassSamples) < 10 ) { + $emittedClassSamples[] = array( + 'node_id' => isset($visualNode['id']) && is_scalar($visualNode['id']) ? (string) $visualNode['id'] : '', + 'class' => $emittedClass, + 'page_path' => '' !== $pagePath ? $pagePath : null, + ); + } + } + + ksort($pagePathCounts); + ksort($sourcePageCounts); + + return array( + 'schema' => 'blocks-engine/figma-transformer/visual-node-map-summary/v1', + 'visual_node_count' => count($visualNodeMap), + 'nodes_with_emitted_metadata' => $withEmittedMetadata, + 'nodes_with_page_path' => $withPagePath, + 'page_path_counts' => $pagePathCounts, + 'source_page_index_counts' => $sourcePageCounts, + 'emitted_class_samples' => $emittedClassSamples, + ); + } + /** * @param array> $pageReports * @return array @@ -1594,6 +1898,36 @@ private function mergeCssChunks(array $chunks): array ); } + /** + * Multi-page output merges independently-emitted page stylesheets. Leaving + * per-page design tokens on `:root` lets later pages override earlier page + * typography/color variables, so scope custom properties to the emitted page + * frame when a concrete root node class is available. + */ + private function scopeRootCustomPropertiesToPage(string $css, string $html): string + { + if ( '' === $css || '' === $html || ! str_contains($css, ':root{') ) { + return $css; + } + + if ( 1 !== preg_match('/]*data-figma-root="true"[^>]*>\s*<[^>]+class="([^"]*)"/s', $html, $matches) ) { + return $css; + } + + $rootClass = ''; + foreach ( preg_split('/\s+/', trim((string) $matches[1])) ?: array() as $class ) { + if ( str_starts_with($class, 'figma-node-') ) { + $rootClass = $class; + break; + } + } + if ( '' === $rootClass ) { + return $css; + } + + return (string) preg_replace('/(^|\n):root\{/m', '$1.' . $rootClass . '{', $css); + } + /** * @return array{class: string, body: string}|null */ @@ -1639,10 +1973,57 @@ private function splitCssStatements(string $css): array $statements = array(); $buffer = ''; $depth = 0; + $parenDepth = 0; + $quote = null; + $escaped = false; + $inComment = false; $length = strlen($css); for ( $i = 0; $i < $length; $i++ ) { $char = $css[$i]; $buffer .= $char; + + if ( $inComment ) { + if ( '*' === $char && '/' === ($css[$i + 1] ?? '') ) { + $buffer .= '/'; + ++$i; + $inComment = false; + } + continue; + } + + if ( null !== $quote ) { + if ( $escaped ) { + $escaped = false; + continue; + } + if ( '\\' === $char ) { + $escaped = true; + continue; + } + if ( $quote === $char ) { + $quote = null; + } + continue; + } + + if ( '/' === $char && '*' === ($css[$i + 1] ?? '') ) { + $buffer .= '*'; + ++$i; + $inComment = true; + continue; + } + if ( '"' === $char || "'" === $char ) { + $quote = $char; + continue; + } + if ( '(' === $char ) { + ++$parenDepth; + continue; + } + if ( ')' === $char ) { + $parenDepth = max(0, $parenDepth - 1); + continue; + } if ( '{' === $char ) { ++$depth; continue; @@ -1655,7 +2036,7 @@ private function splitCssStatements(string $css): array } continue; } - if ( ';' === $char && 0 === $depth ) { + if ( ';' === $char && 0 === $depth && 0 === $parenDepth ) { // Top-level statement with no block body (e.g. `@import …;`). $statements[] = trim($buffer); $buffer = ''; diff --git a/figma-transformer/src/Html/AbsolutePositioningDecision.php b/figma-transformer/src/Html/AbsolutePositioningDecision.php new file mode 100644 index 00000000..b8b4eeca --- /dev/null +++ b/figma-transformer/src/Html/AbsolutePositioningDecision.php @@ -0,0 +1,21 @@ + $declarations + */ + public function __construct( + public readonly string $reasonCode, + public readonly array $declarations, + public readonly bool $suppressedFullBleedHorizontalOffsets, + ) { + } +} diff --git a/figma-transformer/src/Html/BreakpointDimensionPolicy.php b/figma-transformer/src/Html/BreakpointDimensionPolicy.php new file mode 100644 index 00000000..eab5b204 --- /dev/null +++ b/figma-transformer/src/Html/BreakpointDimensionPolicy.php @@ -0,0 +1,307 @@ +} + */ + public function rootFillDecision(): array + { + return array('reason_code' => 'root_fluid_canvas_width', 'declarations' => array('width:100%')); + } + + /** + * Keep a root breakpoint frame fluid instead of freezing it to the source canvas. + * + * @return array + */ + public function rootFillDeclarations(): array + { + return $this->rootFillDecision()['declarations']; + } + + /** + * @return array{reason_code: string, declarations: array} + */ + public function fluidFillDecision(): array + { + return array('reason_code' => 'fluid_fill_width', 'declarations' => array('width:100%', 'max-width:100%')); + } + + /** + * Fill the available responsive inline size without preserving a source max width. + * + * @return array + */ + public function fluidFillDeclarations(): array + { + return $this->fluidFillDecision()['declarations']; + } + + /** + * @return array{reason_code: string, declarations: array} + */ + public function sourceMaxWidthDecision(float $sourceMaxWidth, float $gutter, string $placement): array + { + $declarations = array( + 'width:calc(100% - ' . $this->formatNumber($gutter * 2.0) . 'px)', + 'max-width:' . $this->formatNumber($sourceMaxWidth) . 'px', + ); + + if ( 'absolute' === $placement ) { + $declarations[] = 'left:' . $this->formatNumber($gutter) . 'px'; + $declarations[] = 'right:auto'; + return array('reason_code' => 'source_max_width_absolute_gutter', 'declarations' => $declarations); + } + + if ( 'centered' === $placement ) { + $declarations[] = 'margin-left:auto'; + $declarations[] = 'margin-right:auto'; + return array('reason_code' => 'source_max_width_centered_gutter', 'declarations' => $declarations); + } + + return array('reason_code' => 'source_max_width_fixed_gutter', 'declarations' => $declarations); + } + + /** + * Fill the breakpoint viewport with symmetric gutters while preserving source max width. + * + * @return array + */ + public function sourceMaxWidthDeclarations(float $sourceMaxWidth, float $gutter, string $placement): array + { + return $this->sourceMaxWidthDecision($sourceMaxWidth, $gutter, $placement)['declarations']; + } + + /** + * Resolve base canvas width declarations for root, full-bleed children, and centered shells. + * + * @return array{reason_code: string, declarations: array} + */ + public function canvasWidthDecision(CanvasShellDecision $canvasShell, bool $isFluidPageWidth, ?float $sourceWidth): array + { + if ( $canvasShell->fullBleedCanvasChild ) { + return array('reason_code' => 'full_bleed_canvas_child_viewport_width', 'declarations' => array('width:100vw')); + } + + if ( $isFluidPageWidth ) { + return array('reason_code' => 'fluid_page_canvas_width', 'declarations' => $this->rootFillDeclarations()); + } + + if ( $canvasShell->fluidStretchCanvasChild ) { + return array('reason_code' => 'fluid_stretch_canvas_child_auto_width', 'declarations' => array('width:auto')); + } + + if ( $canvasShell->responsiveCenteredFlowShell && $canvasShell->responsiveCenteredFlowWidth && null !== $sourceWidth ) { + return array( + 'reason_code' => 'responsive_centered_flow_source_max_width', + 'declarations' => array('width:100%', 'max-width:' . $this->formatNumber($sourceWidth) . 'px'), + ); + } + + return array('reason_code' => '', 'declarations' => array()); + } + + /** + * @return array{reason_code: string, declarations: array, evidence?: array} + */ + public function fullBleedViewportBreakoutDecision(CanvasShellDecision $canvasShell): array + { + if ( ! $canvasShell->fullBleedCanvasChild ) { + return array('reason_code' => '', 'declarations' => array()); + } + + $evidence = $this->fullBleedViewportBreakoutEvidence($canvasShell); + + return array( + 'reason_code' => 'full_bleed_canvas_child_viewport_breakout', + 'declarations' => array('left:50%', 'margin-left:-50vw'), + 'evidence' => $evidence, + ); + } + + /** + * Explain why viewport breakout uses the mirrored-safe start anchor. + * + * @return array + */ + private function fullBleedViewportBreakoutEvidence(CanvasShellDecision $canvasShell): array + { + return array( + 'frame_width_role' => $canvasShell->frameWidthRole, + 'canvas_child_role' => $canvasShell->canvasChildRole, + 'parent_renders_fluid_canvas' => $canvasShell->parentRendersFluidCanvas, + 'parent_uses_fluid_canvas_coordinates' => $canvasShell->parentUsesFluidCanvasCoordinates, + 'full_bleed_canvas_child' => $canvasShell->fullBleedCanvasChild, + 'full_bleed_canvas_child_reflected' => $canvasShell->fullBleedCanvasChildReflected, + 'viewport_anchor_strategy' => 'mirrored_safe_start_edge', + 'viewport_anchor_declarations' => array('left:50%', 'margin-left:-50vw'), + ); + } + + /** + * Pair fluid responsive chrome with a source-height floor so headers can wrap + * without collapsing below their desktop visual rhythm. + * + * @return array + */ + public function headerChromeDeclarations(?float $sourceHeight): array + { + $declarations = array('width:100%', 'max-width:100%', 'height:auto', 'display:flex', 'flex-direction:column', 'align-items:stretch', 'justify-content:flex-start'); + if ( null !== $sourceHeight && $sourceHeight > 0.0 ) { + $declarations[] = 'min-height:' . ($this->number)($sourceHeight) . 'px'; + } + + return $declarations; + } + + /** + * Resolve a variant width override relative to its breakpoint parent. + * + * @param array $baseMap + * @param array $baseNode + * @param array $variantNode + * @param array|null $baseParentNode + * @param array|null $variantParentNode + * @return array|null + */ + public function breakpointWidthDeclarations(string $value, array $baseMap, array $baseNode, array $variantNode, ?array $baseParentNode, ?array $variantParentNode): ?array + { + $decision = $this->breakpointWidthDecision($value, $baseMap, $baseNode, $variantNode, $baseParentNode, $variantParentNode); + $declarations = is_array($decision['declarations'] ?? null) ? $decision['declarations'] : array(); + + return array() === $declarations ? null : $declarations; + } + + /** + * Resolve a variant width override and expose the policy branch that made the decision. + * + * @param array $baseMap + * @param array $baseNode + * @param array $variantNode + * @param array|null $baseParentNode + * @param array|null $variantParentNode + * @return array{reason_code: string, declarations: array} + */ + public function breakpointWidthDecision(string $value, array $baseMap, array $baseNode, array $variantNode, ?array $baseParentNode, ?array $variantParentNode): array + { + $variantWidth = $this->cssPixelValue($value); + if ( null === $variantWidth || empty($variantNode) ) { + return array('reason_code' => 'not_pixel_width', 'declarations' => array()); + } + + if ( $this->isViewportBreakoutBase($baseMap) ) { + return array('reason_code' => 'preserve_full_bleed_viewport_breakout', 'declarations' => array('width:100vw', 'left:50%', 'margin-left:-50vw')); + } + + $variantType = strtoupper((string) ($variantNode['type'] ?? 'FRAME')); + $variantSourceId = isset($variantNode['figma_component_source_id']) && is_scalar($variantNode['figma_component_source_id']) ? (string) $variantNode['figma_component_source_id'] : ''; + if ( '' === $variantSourceId && isset($variantNode['source_id']) && is_scalar($variantNode['source_id']) ) { + $variantSourceId = (string) $variantNode['source_id']; + } + if ( '' !== $variantSourceId && ! in_array($variantType, array('FRAME', 'GROUP', 'INSTANCE', 'COMPONENT', 'SYMBOL'), true) ) { + return array('reason_code' => 'component_leaf_width_preserved', 'declarations' => array()); + } + + if ( null === $variantParentNode ) { + return array('reason_code' => 'root_fill', 'declarations' => $this->rootFillDeclarations()); + } + + $variantParentBox = is_array($variantParentNode['box'] ?? null) ? $variantParentNode['box'] : array(); + if ( ! isset($variantParentBox['width']) || ! is_numeric($variantParentBox['width']) ) { + return array('reason_code' => 'missing_variant_parent_width', 'declarations' => array()); + } + + $variantParentWidth = (float) $variantParentBox['width']; + if ( $variantParentWidth <= 0.0 || $variantWidth > $variantParentWidth + 1.0 ) { + return array('reason_code' => 'invalid_variant_parent_width', 'declarations' => array()); + } + + $baseWidth = $this->nodeBoxWidth($baseNode); + if ( null !== $baseWidth && abs($variantWidth - $baseWidth) <= 1.0 ) { + return array('reason_code' => 'unchanged_width', 'declarations' => array()); + } + + $variantParentLayout = is_array($variantParentNode['layout'] ?? null) ? $variantParentNode['layout'] : array(); + $padding = is_array($variantParentLayout['padding'] ?? null) ? $variantParentLayout['padding'] : array(); + $paddingLeft = isset($padding['left']) && is_numeric($padding['left']) ? (float) $padding['left'] : 0.0; + $paddingRight = isset($padding['right']) && is_numeric($padding['right']) ? (float) $padding['right'] : 0.0; + $contentWidth = max(0.0, $variantParentWidth - $paddingLeft - $paddingRight); + if ( abs($variantWidth - $variantParentWidth) <= 1.0 || abs($variantWidth - $contentWidth) <= 1.0 ) { + return array('reason_code' => 'parent_fill', 'declarations' => array('width:100%')); + } + + $gutter = ($variantParentWidth - $variantWidth) / 2.0; + if ( $gutter <= 0.0 ) { + return array('reason_code' => 'invalid_gutter', 'declarations' => array()); + } + + $baseParentWidth = null === $baseParentNode ? null : $this->nodeBoxWidth($baseParentNode); + if ( null === $baseWidth || null === $baseParentWidth || $baseWidth > $baseParentWidth + 1.0 ) { + return array('reason_code' => 'missing_source_max_width', 'declarations' => array()); + } + + $placement = 'absolute' === ($baseMap['position'] ?? null) ? 'absolute' : 'fixed'; + if ( 'absolute' !== $placement && in_array((string) ($baseMap['display'] ?? ''), array('flex', 'inline-flex', 'grid', 'inline-grid'), true) ) { + $placement = 'centered'; + } + + return array('reason_code' => 'source_max_width_' . $placement, 'declarations' => $this->sourceMaxWidthDeclarations($baseWidth, $gutter, $placement)); + } + + private function cssPixelValue(string $value): ?float + { + if ( 1 !== preg_match('/^(-?\d+(?:\.\d+)?)px$/', trim($value), $matches) ) { + return null; + } + + return (float) $matches[1]; + } + + /** + * @param array $baseMap + */ + private function isViewportBreakoutBase(array $baseMap): bool + { + return '100vw' === ($baseMap['width'] ?? null) + && '50%' === ($baseMap['left'] ?? null) + && '-50vw' === ($baseMap['margin-left'] ?? null); + } + + /** + * @param array $node + */ + private function nodeBoxWidth(array $node): ?float + { + $box = is_array($node['box'] ?? null) ? $node['box'] : array(); + if ( ! isset($box['width']) || ! is_numeric($box['width']) ) { + return null; + } + + return (float) $box['width']; + } + + private function formatNumber(float $value): string + { + if ( is_callable($this->number) ) { + return ($this->number)($value); + } + + return rtrim(rtrim(sprintf('%.4F', $value), '0'), '.'); + } +} diff --git a/figma-transformer/src/Html/BreakpointMediaDiffBuilder.php b/figma-transformer/src/Html/BreakpointMediaDiffBuilder.php new file mode 100644 index 00000000..4711daee --- /dev/null +++ b/figma-transformer/src/Html/BreakpointMediaDiffBuilder.php @@ -0,0 +1,578 @@ +> + */ + private array $decisionTraces = array(); + + /** + * @param callable(array): array $nodeList + * @param callable(array, string, array|null, array|null): array $styleDeclarations + * @param callable(array, string, array|null): mixed $supportedVectorSvg + * @param callable(array, array): bool $isFullyClippedDecorativeChild + * @param callable(array): bool $isPaginationContainer + * @param callable(string): string $sanitizeAttribute + * @param callable(string): string $slug + * @param callable(float): string $number + */ + public function __construct( + private readonly StickyLayoutCoordinator $stickyLayoutCoordinator, + private readonly mixed $nodeList, + private readonly mixed $styleDeclarations, + private readonly mixed $supportedVectorSvg, + private readonly mixed $isFullyClippedDecorativeChild, + private readonly mixed $isPaginationContainer, + private readonly mixed $sanitizeAttribute, + private readonly mixed $slug, + private readonly mixed $number, + ?ResponsiveNodeMatcher $responsiveNodeMatcher = null, + ?BreakpointDimensionPolicy $breakpointDimensionPolicy = null, + ?LayoutIntentClassifier $layoutIntentClassifier = null, + ?ResponsiveBreakpointSafetyPolicy $responsiveBreakpointSafetyPolicy = null, + ) { + $this->responsiveNodeMatcher = $responsiveNodeMatcher ?? new ResponsiveNodeMatcher($this->slug); + $this->breakpointDimensionPolicy = $breakpointDimensionPolicy ?? new BreakpointDimensionPolicy($this->number); + $this->layoutIntentClassifier = $layoutIntentClassifier ?? new LayoutIntentClassifier(); + $this->responsiveBreakpointSafetyPolicy = $responsiveBreakpointSafetyPolicy ?? new ResponsiveBreakpointSafetyPolicy( + $this->nodeList, + $this->number, + $this->breakpointDimensionPolicy, + $this->layoutIntentClassifier + ); + } + + public function resetDecisionTraces(): void + { + $this->decisionTraces = array(); + } + + /** + * @return array> + */ + public function decisionTraces(): array + { + return array_values($this->decisionTraces); + } + + /** + * @param array $page + * @param array $baseNode + * @param array> $nodeMap + * @return array + */ + public function buildMediaBlocks(array $page, array $baseNode, array $nodeMap): array + { + $variants = is_array($page['variants'] ?? null) ? array_values($page['variants']) : array(); + if ( count($variants) < 2 ) { + return array(); + } + + $baseStyles = array(); + $this->collectVariantNodeStyles($baseNode, 0, null, null, 'r', $baseStyles); + + $primaryViewportWidth = null; + foreach ( $variants as $variant ) { + if ( is_array($variant) && true === ($variant['primary'] ?? false) && is_numeric($variant['viewport_width'] ?? null) ) { + $primaryViewportWidth = (float) $variant['viewport_width']; + break; + } + } + + $blocks = array(); + $prevViewportWidth = $primaryViewportWidth; + foreach ( $variants as $variant ) { + if ( ! is_array($variant) || true === ($variant['primary'] ?? false) ) { + continue; + } + + $variantId = isset($variant['frame_id']) && is_scalar($variant['frame_id']) ? (string) $variant['frame_id'] : ''; + $viewportWidth = $variant['viewport_width'] ?? null; + if ( '' === $variantId || ! isset($nodeMap[$variantId]) || ! is_numeric($viewportWidth) ) { + continue; + } + + $variantStyles = array(); + $this->collectVariantNodeStyles($nodeMap[$variantId], 0, null, null, 'r', $variantStyles); + + $breakpointPx = null !== $prevViewportWidth && $prevViewportWidth > (float) $viewportWidth + ? (int) round(($prevViewportWidth + (float) $viewportWidth) / 2) + : (int) round((float) $viewportWidth); + + $diffRules = $this->diffRules($baseStyles, $variantStyles); + if ( ! empty($diffRules) ) { + $blocks[] = $this->mediaBlock($breakpointPx, $diffRules); + } + + $safetyRules = $this->responsiveSafetyRules($baseStyles, $variantStyles, (float) $viewportWidth, $this->matchedBreakpointGeometryClasses($baseStyles, $variantStyles)); + if ( ! empty($safetyRules) ) { + $safetyBreakpointPx = (float) $viewportWidth <= 480.0 ? (int) round((float) $viewportWidth) : $breakpointPx; + $blocks[] = $this->mediaBlock($safetyBreakpointPx, $safetyRules); + } + + $prevViewportWidth = (float) $viewportWidth; + } + + return $blocks; + } + + /** + * @param array $rules + */ + private function mediaBlock(int $breakpointPx, array $rules): string + { + return '@media (max-width:' . ($this->number)((float) $breakpointPx) . 'px){' + . "\n" . implode("\n", $rules) . "\n}"; + } + + /** + * @param array $node + * @param array $map + */ + private function collectVariantNodeStyles(array $node, int $depth, ?array $parentNode, ?array $grandParentNode, string $pathKey, array &$map): void + { + if ( $this->stickyLayoutCoordinator->isSuppressedStickyGhost($node) ) { + return; + } + + $id = ($this->sanitizeAttribute)((string) ($node['id'] ?? '')); + $name = (string) ($node['name'] ?? ''); + $type = strtoupper((string) ($node['type'] ?? 'FRAME')); + $className = 'figma-node-' . ($this->slug)($id . '-' . $name); + $styles = $this->stickyLayoutCoordinator->stickyAwareStyleDeclarations($node, ($this->styleDeclarations)($node, $type, $parentNode, $grandParentNode)); + + $map[$pathKey] = array( + 'class' => $className, + 'styles' => $styles, + 'contains_sticky' => $this->stickyLayoutCoordinator->containsStickyPrimary($node), + 'node' => $node, + 'parent_node' => $parentNode, + 'grand_parent_node' => $grandParentNode, + 'depth' => $depth, + 'path_key' => $pathKey, + ); + + $vectorSvg = ($this->supportedVectorSvg)($node, $type, $parentNode); + if ( 'BOOLEAN_OPERATION' === $type && null !== $vectorSvg ) { + return; + } + + $children = array(); + foreach ( ($this->nodeList)($node) as $child ) { + if ( ! is_array($child) || $this->stickyLayoutCoordinator->isSuppressedStickyGhost($child) || ($this->isFullyClippedDecorativeChild)($child, $node) ) { + continue; + } + + $children[] = $child; + } + + $childOrdinal = 0; + $siblingSignatureCounts = $this->responsiveNodeMatcher->siblingSignatureCounts($children); + $siblingSourceIdentityCounts = $this->responsiveNodeMatcher->siblingSourceIdentityCounts($children); + foreach ( $children as $child ) { + foreach ( $this->responsiveNodeMatcher->childKeys($child, $childOrdinal, $siblingSignatureCounts, $siblingSourceIdentityCounts) as $childKeyPart ) { + $childKey = $pathKey . '/' . $childKeyPart; + $this->collectVariantNodeStyles($child, $depth + 1, $node, $parentNode, $childKey, $map); + } + ++$childOrdinal; + } + } + + /** + * @param array> $baseStyles + * @param array> $variantStyles + * @return array + */ + private function diffRules(array $baseStyles, array $variantStyles): array + { + $rules = array(); + foreach ( $baseStyles as $pathKey => $base ) { + if ( ! isset($variantStyles[$pathKey]) ) { + continue; + } + + $baseMap = $this->styleDeclarationMap(is_array($base['styles'] ?? null) ? $base['styles'] : array()); + $variantDeclarations = is_array($variantStyles[$pathKey]['styles'] ?? null) ? $variantStyles[$pathKey]['styles'] : array(); + + $changed = array(); + $baseContainsSticky = true === ($base['contains_sticky'] ?? false); + $preserveFullBleedBreakout = $this->usesFullBleedViewportBreakout($baseMap); + $baseNode = is_array($base['node'] ?? null) ? $base['node'] : array(); + $variantNode = is_array($variantStyles[$pathKey]['node'] ?? null) ? $variantStyles[$pathKey]['node'] : array(); + $baseParentNode = is_array($base['parent_node'] ?? null) ? $base['parent_node'] : null; + $variantParentNode = is_array($variantStyles[$pathKey]['parent_node'] ?? null) ? $variantStyles[$pathKey]['parent_node'] : null; + $preservePaginationRow = ! empty($baseNode) && ($this->isPaginationContainer)($baseNode); + $responsiveWidthHandledProperties = array(); + foreach ( $variantDeclarations as $declaration ) { + $parts = explode(':', (string) $declaration, 2); + if ( 2 !== count($parts) ) { + continue; + } + + $property = trim($parts[0]); + $value = trim($parts[1]); + if ( $baseContainsSticky && 'overflow' === $property ) { + continue; + } + if ( $preservePaginationRow && in_array($property, array('height', 'flex-wrap', 'align-content'), true) ) { + continue; + } + if ( $preserveFullBleedBreakout && in_array($property, array('width', 'left', 'right', 'margin-left'), true) ) { + continue; + } + if ( 'height' === $property && $this->shouldUseResponsiveAutoHeight($value, $baseMap, $baseNode, $variantNode, $variantParentNode) ) { + if ( ! array_key_exists('height', $baseMap) || 'auto' !== $baseMap['height'] ) { + $changed[] = 'height:auto'; + } + continue; + } + $responsiveWidthDeclarations = 'width' === $property + ? $this->breakpointDimensionPolicy->breakpointWidthDeclarations($value, $baseMap, $baseNode, $variantNode, $baseParentNode, $variantParentNode) + : null; + if ( null !== $responsiveWidthDeclarations ) { + foreach ( $responsiveWidthDeclarations as $responsiveWidthDeclaration ) { + $responsiveParts = explode(':', $responsiveWidthDeclaration, 2); + if ( 2 !== count($responsiveParts) ) { + continue; + } + $responsiveProperty = trim($responsiveParts[0]); + $responsiveValue = trim($responsiveParts[1]); + $responsiveWidthHandledProperties[$responsiveProperty] = true; + if ( ! array_key_exists($responsiveProperty, $baseMap) || $baseMap[$responsiveProperty] !== $responsiveValue ) { + $changed[] = $responsiveProperty . ':' . $responsiveValue; + } + } + continue; + } + if ( isset($responsiveWidthHandledProperties[$property]) ) { + continue; + } + if ( ! array_key_exists($property, $baseMap) || $baseMap[$property] !== $value ) { + $changed[] = $property . ':' . $value; + } + } + + if ( empty($changed) ) { + continue; + } + + $rules[] = '.' . (string) $base['class'] . '{' . implode(';', $changed) . '}'; + } + + return array_values(array_unique($rules)); + } + + /** + * @param array $baseMap + */ + private function usesFullBleedViewportBreakout(array $baseMap): bool + { + return '100vw' === ($baseMap['width'] ?? null) + && '50%' === ($baseMap['left'] ?? null) + && '-50vw' === ($baseMap['margin-left'] ?? null); + } + + /** + * Figma exports often keep headers, footer rows, newsletter panels, and card + * grids as page-specific absolute/freeform nodes. When a breakpoint variant + * changes clone/source identity, structural diffing cannot match those nodes, + * so desktop widths survive into mobile. These class-scoped fallbacks only + * target already-emitted base nodes whose normalized names and styles identify + * those responsive shells. + * + * @param array> $baseStyles + * @param array> $variantStyles + * @return array + */ + private function responsiveSafetyRules(array $baseStyles, array $variantStyles, float $viewportWidth, array $matchedBreakpointGeometryClasses): array + { + $rules = array(); + foreach ( $baseStyles as $base ) { + $node = is_array($base['node'] ?? null) ? $base['node'] : array(); + $parentNode = is_array($base['parent_node'] ?? null) ? $base['parent_node'] : null; + $grandParentNode = is_array($base['grand_parent_node'] ?? null) ? $base['grand_parent_node'] : null; + $class = isset($base['class']) && is_scalar($base['class']) ? (string) $base['class'] : ''; + $baseMap = $this->styleDeclarationMap(is_array($base['styles'] ?? null) ? $base['styles'] : array()); + if ( '' === $class || empty($node) || empty($baseMap) ) { + continue; + } + + $depth = isset($base['depth']) && is_numeric($base['depth']) ? (int) $base['depth'] : 0; + $pathKey = is_string($base['path_key'] ?? null) ? (string) $base['path_key'] : ''; + $variantNode = '' !== $pathKey && is_array($variantStyles[$pathKey]['node'] ?? null) ? $variantStyles[$pathKey]['node'] : null; + $decision = $this->responsiveBreakpointSafetyPolicy->responsiveSafetyDecision($node, $parentNode, $baseMap, $viewportWidth, $depth, $grandParentNode, $variantNode); + $declarations = is_array($decision['declarations'] ?? null) ? $decision['declarations'] : array(); + if ( empty($declarations) ) { + continue; + } + + $reasonCode = (string) ($decision['reason_code'] ?? 'responsive_safety_override'); + if ( isset($matchedBreakpointGeometryClasses[$class]) && $this->isGenericResponsiveFlowSafetyReason($reasonCode) ) { + continue; + } + + $changed = array(); + foreach ( $declarations as $declaration ) { + $parts = explode(':', $declaration, 2); + if ( 2 !== count($parts) ) { + continue; + } + $property = trim($parts[0]); + $value = trim($parts[1]); + if ( ! array_key_exists($property, $baseMap) || $baseMap[$property] !== $value ) { + $changed[] = $property . ':' . $value; + } + } + if ( ! empty($changed) ) { + $rules[] = '.' . $class . '{' . implode(';', $changed) . '}'; + $this->recordResponsiveDecisionTrace($node, $parentNode, $reasonCode, $viewportWidth, $changed, $class, $baseMap, $variantNode, isset($matchedBreakpointGeometryClasses[$class])); + } + } + + return array_values(array_unique($rules)); + } + + /** + * @param array> $baseStyles + * @param array> $variantStyles + * @return array + */ + private function matchedBreakpointGeometryClasses(array $baseStyles, array $variantStyles): array + { + $classes = array(); + foreach ( $baseStyles as $pathKey => $base ) { + if ( ! isset($variantStyles[$pathKey]) ) { + continue; + } + + $class = isset($base['class']) && is_scalar($base['class']) ? (string) $base['class'] : ''; + if ( '' === $class ) { + continue; + } + + $variantMap = $this->styleDeclarationMap(is_array($variantStyles[$pathKey]['styles'] ?? null) ? $variantStyles[$pathKey]['styles'] : array()); + foreach ( array('position', 'left', 'right', 'top', 'bottom', 'width', 'height') as $property ) { + if ( array_key_exists($property, $variantMap) ) { + $classes[$class] = true; + break; + } + } + } + + return $classes; + } + + private function isGenericResponsiveFlowSafetyReason(string $reasonCode): bool + { + return in_array($reasonCode, array( + 'responsive_header_child_chrome_safety', + 'responsive_footer_child_chrome_safety', + 'responsive_generic_mobile_safety', + ), true); + } + + /** + * @param array $node + * @param array|null $parentNode + * @param array $declarations + */ + private function recordResponsiveDecisionTrace(array $node, ?array $parentNode, string $reasonCode, float $viewportWidth, array $declarations, string $class, array $baseMap, ?array $variantNode, bool $matchedBreakpointGeometry): void + { + DecisionTraceBuilder::recordResponsiveTrace($this->decisionTraces, $node, $parentNode, $reasonCode, $viewportWidth, $declarations, $class, $this->responsiveDecisionEvidence($baseMap, $variantNode, $matchedBreakpointGeometry, $declarations)); + } + + /** + * @param array $baseMap + * @param array|null $variantNode + * @param array $declarations + * @return array + */ + private function responsiveDecisionEvidence(array $baseMap, ?array $variantNode, bool $matchedBreakpointGeometry, array $declarations): array + { + $variantBox = is_array($variantNode['box'] ?? null) ? $variantNode['box'] : array(); + $variantLayout = is_array($variantNode['layout'] ?? null) ? $variantNode['layout'] : array(); + + return array_filter(array( + 'source' => null === $variantNode ? 'class_safety_fallback' : 'matched_breakpoint_variant', + 'matched_breakpoint_geometry' => $matchedBreakpointGeometry, + 'absolute_to_flow_conversion' => $this->responsiveDeclarationsConvertAbsoluteToFlow($baseMap, $declarations), + 'base_position' => $baseMap['position'] ?? null, + 'base_left' => $baseMap['left'] ?? null, + 'base_top' => $baseMap['top'] ?? null, + 'base_width' => $baseMap['width'] ?? null, + 'variant_node_id' => is_array($variantNode) && is_scalar($variantNode['id'] ?? null) ? (string) $variantNode['id'] : null, + 'variant_positioning' => is_scalar($variantLayout['positioning'] ?? null) ? (string) $variantLayout['positioning'] : null, + 'variant_box' => array_intersect_key($variantBox, array('x' => true, 'y' => true, 'width' => true, 'height' => true, 'coordinate_space' => true)), + ), static fn (mixed $value): bool => null !== $value && '' !== $value && array() !== $value); + } + + /** + * @param array $baseMap + * @param array $declarations + */ + private function responsiveDeclarationsConvertAbsoluteToFlow(array $baseMap, array $declarations): bool + { + if ( 'absolute' !== ($baseMap['position'] ?? null) ) { + return false; + } + + $map = $this->styleDeclarationMap($declarations); + return 'relative' === ($map['position'] ?? null) + || 'auto' === ($map['left'] ?? null) + || 'auto' === ($map['right'] ?? null) + || 'auto' === ($map['top'] ?? null) + || 'auto' === ($map['bottom'] ?? null); + } + + private function cssPixelValue(string $value): ?float + { + if ( 1 !== preg_match('/^(-?\d+(?:\.\d+)?)px$/', trim($value), $matches) ) { + return null; + } + + return (float) $matches[1]; + } + + /** + * @param array $baseMap + * @param array $baseNode + * @param array $variantNode + */ + private function shouldUseResponsiveAutoHeight(string $value, array $baseMap, array $baseNode, array $variantNode, ?array $variantParentNode): bool + { + if ( null === $this->cssPixelValue($value) || null === $this->cssPixelValue($baseMap['height'] ?? '') ) { + return false; + } + + if ( empty($baseNode) || empty($variantNode) ) { + return false; + } + + $type = strtoupper((string) ($baseNode['type'] ?? '')); + if ( in_array($type, array('TEXT', 'VECTOR', 'BOOLEAN_OPERATION', 'LINE', 'ELLIPSE', 'STAR', 'POLYGON', 'REGULAR_POLYGON', 'RECTANGLE', 'ROUNDED_RECTANGLE'), true) ) { + return false; + } + + $layout = is_array($baseNode['layout'] ?? null) ? $baseNode['layout'] : array(); + if ( 'absolute' === ($layout['positioning'] ?? null) || 'absolute' === ($baseMap['position'] ?? null) ) { + return false; + } + + if ( in_array($type, array('INSTANCE', 'COMPONENT', 'SYMBOL'), true) && $this->variantFillsParentWidth($variantNode, $variantParentNode) ) { + return true; + } + + $display = (string) ($layout['display'] ?? ''); + if ( ! in_array($display, array('flex', 'inline-flex', 'grid', 'inline-grid'), true) ) { + return false; + } + + if ( ($this->hasStableComponentIdentity($baseNode) || $this->hasStableComponentIdentity($variantNode)) && ! $this->variantFlowTopologyChanged($baseNode, $variantNode) ) { + return false; + } + + return ! empty(($this->nodeList)($baseNode)) && ! empty(($this->nodeList)($variantNode)); + } + + /** + * @param array $baseNode + * @param array $variantNode + */ + private function variantFlowTopologyChanged(array $baseNode, array $variantNode): bool + { + $baseLayout = is_array($baseNode['layout'] ?? null) ? $baseNode['layout'] : array(); + $variantLayout = is_array($variantNode['layout'] ?? null) ? $variantNode['layout'] : array(); + foreach ( array('display', 'flex_direction', 'flex_wrap', 'grid_template_columns', 'grid_template_rows') as $layoutKey ) { + if ( ($baseLayout[$layoutKey] ?? null) !== ($variantLayout[$layoutKey] ?? null) ) { + return true; + } + } + + return false; + } + + /** + * @param array $node + */ + private function variantFillsParentWidth(array $node, ?array $parentNode): bool + { + $box = is_array($node['box'] ?? null) ? $node['box'] : array(); + $parentBox = is_array($parentNode['box'] ?? null) ? $parentNode['box'] : array(); + if ( ! isset($box['width'], $parentBox['width']) || ! is_numeric($box['width']) || ! is_numeric($parentBox['width']) ) { + return false; + } + + return abs((float) $box['width'] - (float) $parentBox['width']) <= 1.0; + } + + /** + * @param array $node + */ + private function hasStableComponentIdentity(array $node): bool + { + foreach ( array('figma_component_source_id', 'source_id', 'componentId', 'component_id') as $identityKey ) { + if ( isset($node[$identityKey]) && is_scalar($node[$identityKey]) && '' !== (string) $node[$identityKey] ) { + return true; + } + } + + return false; + } + + /** + * @param array $node + */ + private function nodeBoxWidth(array $node): ?float + { + $box = is_array($node['box'] ?? null) ? $node['box'] : array(); + if ( ! isset($box['width']) || ! is_numeric($box['width']) ) { + return null; + } + + return (float) $box['width']; + } + + /** + * @param array $node + */ + private function nodeBoxHeight(array $node): ?float + { + $box = is_array($node['box'] ?? null) ? $node['box'] : array(); + if ( ! isset($box['height']) || ! is_numeric($box['height']) ) { + return null; + } + + return (float) $box['height']; + } + + /** + * @param array $styles + * @return array + */ + private function styleDeclarationMap(array $styles): array + { + $map = array(); + foreach ( $styles as $style ) { + $parts = explode(':', $style, 2); + if ( 2 !== count($parts) ) { + continue; + } + $map[trim($parts[0])] = trim($parts[1]); + } + + return $map; + } +} diff --git a/figma-transformer/src/Html/CanvasShellDecision.php b/figma-transformer/src/Html/CanvasShellDecision.php new file mode 100644 index 00000000..282b8a09 --- /dev/null +++ b/figma-transformer/src/Html/CanvasShellDecision.php @@ -0,0 +1,25 @@ +): bool $isFreeformContainer + * @param callable(array): bool $freeformContainerShouldUseFlow + * @param callable(array): bool $hasAbsoluteChild + * @param callable(array): bool $hasDecorativeFlexUnderlayChild + */ + public function __construct( + private readonly LayoutFrameRoleClassifier $layoutFrameRoleClassifier, + private readonly mixed $isFreeformContainer, + private readonly mixed $freeformContainerShouldUseFlow, + private readonly mixed $hasAbsoluteChild, + private readonly mixed $hasDecorativeFlexUnderlayChild, + private readonly VisualGeometryResolver $visualGeometryResolver, + private readonly ?BreakpointDimensionPolicy $breakpointDimensionPolicy = null, + ) { + } + + /** + * @param array $node + * @param array|null $parentNode + * @param array|null $grandParentNode + */ + public function resolve(array $node, ?array $parentNode, ?array $grandParentNode): CanvasShellDecision + { + $box = is_array($node['box'] ?? null) ? $node['box'] : array(); + $layout = is_array($node['layout'] ?? null) ? $node['layout'] : array(); + $parentRendersFluidCanvas = null !== $parentNode && $this->nodeRendersFluidCanvas($parentNode, $grandParentNode); + $parentUsesFluidCanvasCoordinates = null !== $parentNode && $this->nodeUsesFluidCanvasCoordinates($parentNode, $grandParentNode); + $frameWidthRole = $this->layoutFrameRoleClassifier->frameWidthRole($box, $layout, $parentNode); + $canvasChildRole = null !== $parentNode + ? $this->layoutFrameRoleClassifier->canvasChildRole($box, $layout, $parentNode, $parentUsesFluidCanvasCoordinates, $this->isFreeformContainer($parentNode)) + : LayoutFrameRoleClassifier::ROLE_INTRINSIC; + if ( + null !== $parentNode + && $parentUsesFluidCanvasCoordinates + && LayoutFrameRoleClassifier::ROLE_INTRINSIC === $canvasChildRole + && $this->visualGeometryResolver->isVisualFullWidthCanvasChild($node, $parentNode, $this->isFreeformContainer($parentNode)) + ) { + $canvasChildRole = LayoutFrameRoleClassifier::ROLE_FULL_BLEED_CANVAS_CHILD; + } + if ( + null !== $parentNode + && ! $parentUsesFluidCanvasCoordinates + && $parentRendersFluidCanvas + && LayoutFrameRoleClassifier::ROLE_INTRINSIC === $canvasChildRole + && $this->isRootEdgeOverscanCanvasChild($box, $layout, $parentNode) + ) { + $canvasChildRole = LayoutFrameRoleClassifier::ROLE_FULL_BLEED_CANVAS_CHILD; + } + $fullBleedCanvasChild = LayoutFrameRoleClassifier::ROLE_FULL_BLEED_CANVAS_CHILD === $canvasChildRole; + $centeredWithinParentFluidCanvas = LayoutFrameRoleClassifier::ROLE_CENTERED_SHELL === $canvasChildRole; + $responsiveCenteredFlowWidth = $this->centeredShellShouldUseResponsiveFlowWidth($layout, $parentNode); + $responsiveCenteredFlowShell = $centeredWithinParentFluidCanvas || ( + $parentRendersFluidCanvas + && null !== $parentNode + && $responsiveCenteredFlowWidth + && $this->layoutFrameRoleClassifier->isCenteredCanvasShell($box, $parentNode) + ); + $fluidStretchCanvasChild = null !== $parentNode + && $parentUsesFluidCanvasCoordinates + && $this->layoutFrameRoleClassifier->isFluidStretchAbsoluteChild($box, $layout, $parentNode, $this->isFreeformContainer($parentNode)); + + return new CanvasShellDecision( + $frameWidthRole, + $canvasChildRole, + $parentRendersFluidCanvas, + $parentUsesFluidCanvasCoordinates, + $fullBleedCanvasChild, + $centeredWithinParentFluidCanvas, + $responsiveCenteredFlowShell, + $fluidStretchCanvasChild, + $responsiveCenteredFlowWidth, + $fullBleedCanvasChild && $this->visualGeometryResolver->isHorizontallyReflected($node), + ); + } + + /** + * @param array $layout + */ + public function nodeShouldUseFlowHeight(string $type, array $layout, CanvasShellDecision $decision): bool + { + if ( ! in_array($type, array('FRAME', 'COMPONENT', 'INSTANCE', 'SECTION'), true) ) { + return false; + } + + if ( $this->layoutFrameRoleClassifier->roleUsesFlowHeight($decision->frameWidthRole, $layout) ) { + return true; + } + + return $this->layoutFrameRoleClassifier->roleUsesFlowHeight($decision->canvasChildRole, $layout); + } + + /** + * @return array + */ + public function fullBleedViewportBreakoutStyles(CanvasShellDecision $decision): array + { + return $this->fullBleedViewportBreakoutDecision($decision)['declarations']; + } + + /** + * @return array{reason_code: string, declarations: array, evidence?: array} + */ + public function fullBleedViewportBreakoutDecision(CanvasShellDecision $decision): array + { + return $this->dimensionPolicy()->fullBleedViewportBreakoutDecision($decision); + } + + private function dimensionPolicy(): BreakpointDimensionPolicy + { + return $this->breakpointDimensionPolicy ?? new BreakpointDimensionPolicy(); + } + + /** + * @param array $box + * @param array $layout + * @param array $parentNode + */ + private function isRootEdgeOverscanCanvasChild(array $box, array $layout, array $parentNode): bool + { + if ( ! $this->layoutFrameRoleClassifier->isAbsoluteFullWidthCanvasChild($box, $layout, $parentNode, $this->isFreeformContainer($parentNode)) ) { + return false; + } + + $parentBox = is_array($parentNode['box'] ?? null) ? $parentNode['box'] : array(); + if ( ! isset($box['x'], $box['width'], $parentBox['width']) || ! is_numeric($box['x']) || ! is_numeric($box['width']) || ! is_numeric($parentBox['width']) ) { + return false; + } + + return (float) $box['x'] < -1.0 || (float) $box['width'] > (float) $parentBox['width'] + 1.0; + } + + /** + * @param array $layout + * @param array|null $parentNode + */ + private function centeredShellShouldUseResponsiveFlowWidth(array $layout, ?array $parentNode): bool + { + if ( null === $parentNode || 'absolute' === ($layout['positioning'] ?? null) ) { + return false; + } + + return ! $this->isFreeformContainer($parentNode) || $this->freeformContainerShouldUseFlow($parentNode); + } + + /** + * @param array $node + * @param array|null $parentNode + */ + private function nodeRendersFluidCanvas(array $node, ?array $parentNode): bool + { + $box = is_array($node['box'] ?? null) ? $node['box'] : array(); + $layout = is_array($node['layout'] ?? null) ? $node['layout'] : array(); + return $this->layoutFrameRoleClassifier->isFluidPageWidth($box, $layout, $parentNode); + } + + /** + * @param array $node + * @param array|null $parentNode + */ + private function nodeUsesFluidCanvasCoordinates(array $node, ?array $parentNode): bool + { + if ( ! $this->nodeRendersFluidCanvas($node, $parentNode) ) { + return false; + } + + $layout = is_array($node['layout'] ?? null) ? $node['layout'] : array(); + if ( 'FILL' === strtoupper((string) ($layout['sizing_horizontal'] ?? '')) ) { + return true; + } + + $type = strtoupper((string) ($node['type'] ?? '')); + if ( 'COMPONENT' === $type ) { + return false; + } + + if ( 'INSTANCE' === $type ) { + return $this->isFreeformContainer($node); + } + + if ( null === $parentNode && ('flex' !== ($layout['display'] ?? null) || 'column' !== ($layout['flex_direction'] ?? null)) ) { + return false; + } + + return $this->hasAbsoluteChild($node) || $this->hasDecorativeFlexUnderlayChild($node) || $this->isFreeformContainer($node); + } + + /** + * @param array $node + */ + private function isFreeformContainer(array $node): bool + { + return ($this->isFreeformContainer)($node); + } + + /** + * @param array $node + */ + private function freeformContainerShouldUseFlow(array $node): bool + { + return ($this->freeformContainerShouldUseFlow)($node); + } + + /** + * @param array $node + */ + private function hasAbsoluteChild(array $node): bool + { + return ($this->hasAbsoluteChild)($node); + } + + /** + * @param array $node + */ + private function hasDecorativeFlexUnderlayChild(array $node): bool + { + return ($this->hasDecorativeFlexUnderlayChild)($node); + } +} diff --git a/figma-transformer/src/Html/ChildLayerCompositionResolver.php b/figma-transformer/src/Html/ChildLayerCompositionResolver.php new file mode 100644 index 00000000..b4eb9beb --- /dev/null +++ b/figma-transformer/src/Html/ChildLayerCompositionResolver.php @@ -0,0 +1,471 @@ +): ?string */ + private Closure $nodeAssetPath; + + /** @var Closure(float): string */ + private Closure $number; + + /** + * @param Closure(array): ?string $nodeAssetPath + * @param Closure(float): string $number + */ + public function __construct(Closure $nodeAssetPath, Closure $number) + { + $this->nodeAssetPath = $nodeAssetPath; + $this->number = $number; + } + + /** + * @param array $children + * @return array{clip_paths: array, image_mask_paths: array, suppressed_child_ids: array} + */ + public function resolveChildMaps(array $children): array + { + $imageMaskComposition = $this->imageMaskComposition($children); + + return array( + 'clip_paths' => $this->simpleMaskClipPathsByTargetId($children), + 'image_mask_paths' => $imageMaskComposition['mask_paths'], + 'suppressed_child_ids' => array_merge( + $imageMaskComposition['source_ids'], + $this->samePathVectorStateDuplicateSuppressedChildIds($children) + ), + ); + } + + /** + * @param array $child + * @param array{clip_paths: array, image_mask_paths: array, suppressed_child_ids: array} $compositionMaps + * @return array + */ + public function applyToChild(array $child, string $childId, array $compositionMaps): array + { + if ( '' !== $childId && isset($compositionMaps['clip_paths'][$childId]) ) { + $child['_figma_css_clip_path'] = $compositionMaps['clip_paths'][$childId]; + } + if ( '' !== $childId && isset($compositionMaps['image_mask_paths'][$childId]) ) { + $child['_figma_css_mask_image_path'] = $compositionMaps['image_mask_paths'][$childId]; + } + + return $child; + } + + /** @param array $node */ + public function isMaskOperatorNode(array $node): bool + { + $mask = is_array($node['figma_mask'] ?? null) ? $node['figma_mask'] : array(); + + return true === ($mask['is_mask'] ?? null) || true === ($node['isMask'] ?? null) || true === ($node['mask'] ?? null); + } + + /** + * @param array $children + * @return array + */ + private function simpleMaskClipPathsByTargetId(array $children): array + { + $nodes = array_values(array_filter($children, 'is_array')); + $clipPaths = array(); + foreach ( $nodes as $maskNode ) { + if ( ! $this->isMaskOperatorNode($maskNode) ) { + continue; + } + $targetNode = $this->simpleMaskTargetNode($maskNode, $nodes); + if ( null === $targetNode ) { + continue; + } + $targetId = isset($targetNode['id']) && is_scalar($targetNode['id']) ? (string) $targetNode['id'] : ''; + $clipPath = $this->simpleMaskClipPath($maskNode, $targetNode); + if ( '' !== $targetId && null !== $clipPath ) { + $clipPaths[$targetId] = $clipPath; + } + } + + return $clipPaths; + } + + /** + * @param array $children + * @return array{mask_paths: array, source_ids: array} + */ + private function imageMaskComposition(array $children): array + { + $nodes = array_values(array_filter($children, 'is_array')); + $maskPaths = array(); + $sourceIds = array(); + foreach ( $nodes as $node ) { + if ( $this->isMaskOperatorNode($node) ) { + continue; + } + + $nodeId = isset($node['id']) && is_scalar($node['id']) ? (string) $node['id'] : ''; + if ( '' === $nodeId || null !== ($this->nodeAssetPath)($node) || ! $this->hasVisibleSolidFill($node) ) { + continue; + } + + foreach ( $nodes as $candidate ) { + if ( $candidate === $node || $this->isMaskOperatorNode($candidate) ) { + continue; + } + + $assetPath = ($this->nodeAssetPath)($candidate); + if ( null !== $assetPath && $this->isSameBoxNode($node, $candidate) && $this->isIconRecolorMaskComposition($node, $candidate, $assetPath) ) { + $maskPaths[$nodeId] = $assetPath; + $sourceId = isset($candidate['id']) && is_scalar($candidate['id']) ? (string) $candidate['id'] : ''; + if ( '' !== $sourceId ) { + $sourceIds[$sourceId] = 'image_mask_alpha_source_suppressed'; + } + break; + } + } + } + + return array('mask_paths' => $maskPaths, 'source_ids' => $sourceIds); + } + + /** + * @param array $solidOverlay + * @param array $assetSource + */ + private function isIconRecolorMaskComposition(array $solidOverlay, array $assetSource, string $assetPath): bool + { + $box = is_array($solidOverlay['box'] ?? null) ? $solidOverlay['box'] : array(); + $width = isset($box['width']) && is_numeric($box['width']) ? (float) $box['width'] : null; + $height = isset($box['height']) && is_numeric($box['height']) ? (float) $box['height'] : null; + if ( null === $width || null === $height || $width > 128.0 || $height > 128.0 ) { + return false; + } + + $name = strtolower((string) ($solidOverlay['name'] ?? '') . ' ' . (string) ($assetSource['name'] ?? '') . ' ' . basename($assetPath)); + return 1 === preg_match('/\b(icon|social|facebook|instagram|linkedin|twitter|youtube|tiktok|pinterest|logo|mask)\b/', $name) + || 'svg' === strtolower(pathinfo($assetPath, PATHINFO_EXTENSION)); + } + + /** + * @param array $children + * @return array + */ + private function samePathVectorStateDuplicateSuppressedChildIds(array $children): array + { + $groups = array(); + foreach ( array_values(array_filter($children, 'is_array')) as $child ) { + if ( false === ($child['visible'] ?? true) || $this->isMaskOperatorNode($child) ) { + continue; + } + + $id = isset($child['id']) && is_scalar($child['id']) ? (string) $child['id'] : ''; + $candidate = $this->samePathVectorStateCandidate($child); + if ( '' === $id || null === $candidate ) { + continue; + } + + $candidate['id'] = $id; + $groups[$candidate['key']][] = $candidate; + } + + $suppressed = array(); + foreach ( $groups as $candidates ) { + if ( count($candidates) < 2 ) { + continue; + } + + $emitted = array(); + foreach ( $candidates as $candidate ) { + $duplicate = false; + foreach ( $emitted as $kept ) { + if ( $this->isNearSameVectorStateBox($candidate['box'], $kept['box']) ) { + $duplicate = true; + break; + } + } + + if ( $duplicate ) { + $suppressed[$candidate['id']] = 'same_path_vector_state_duplicate_suppressed'; + } else { + $emitted[] = $candidate; + } + } + } + + return $suppressed; + } + + /** + * @param array $node + * @return array{key: string, box: array{x: float, y: float, width: float, height: float}}|null + */ + private function samePathVectorStateCandidate(array $node): ?array + { + $type = strtoupper((string) ($node['type'] ?? '')); + $pathSignature = $this->vectorPathSignature($node); + if ( ! $this->isUnsupportedVectorType($type) ) { + $pathSignature = $this->wrappedSingleVectorPathSignature($node, 0); + } elseif ( ! $this->hasVisiblePaintCollection($node) ) { + return null; + } + if ( null === $pathSignature ) { + return null; + } + + $box = $this->vectorStateBox($node); + if ( null === $box || $box['width'] > 128.0 || $box['height'] > 128.0 ) { + return null; + } + + return array( + 'key' => $type . '|width:' . ($this->number)(round($box['width'], 1)) . '|height:' . ($this->number)(round($box['height'], 1)) . '|' . $pathSignature, + 'box' => $box, + ); + } + + /** + * @param array $node + * @return array{x: float, y: float, width: float, height: float}|null + */ + private function vectorStateBox(array $node): ?array + { + $box = is_array($node['box'] ?? null) ? $node['box'] : array(); + $resolved = array(); + foreach ( array('x', 'y', 'width', 'height') as $key ) { + $value = isset($box[$key]) && is_numeric($box[$key]) ? (float) $box[$key] : (in_array($key, array('x', 'y'), true) ? 0.0 : null); + if ( null === $value ) { + return null; + } + $resolved[$key] = $value; + } + + return $resolved; + } + + /** + * @param array{x: float, y: float, width: float, height: float} $box + * @param array{x: float, y: float, width: float, height: float} $candidateBox + */ + private function isNearSameVectorStateBox(array $box, array $candidateBox): bool + { + return abs($box['x'] - $candidateBox['x']) <= 1.5 + && abs($box['y'] - $candidateBox['y']) <= 1.5 + && abs($box['width'] - $candidateBox['width']) <= 0.5 + && abs($box['height'] - $candidateBox['height']) <= 0.5; + } + + /** @param array $node */ + private function wrappedSingleVectorPathSignature(array $node, int $depth): ?string + { + if ( $depth > 3 ) { + return null; + } + + $children = array_values(array_filter(is_array($node['children'] ?? null) ? $node['children'] : array(), 'is_array')); + if ( 1 !== count($children) ) { + return null; + } + + $child = $children[0]; + $childType = strtoupper((string) ($child['type'] ?? '')); + if ( $this->isUnsupportedVectorType($childType) && $this->hasVisiblePaintCollection($child) ) { + return $this->vectorPathSignature($child); + } + + return $this->wrappedSingleVectorPathSignature($child, $depth + 1); + } + + /** @param array $node */ + private function vectorPathSignature(array $node): ?string + { + $paths = array(); + foreach ( array('pathData', 'path_data', 'd') as $key ) { + if ( isset($node[$key]) && is_scalar($node[$key]) && '' !== trim((string) $node[$key]) ) { + $paths[] = trim((string) $node[$key]); + } + } + + foreach ( array('fillGeometry', 'strokeGeometry', 'figma_vector_paths') as $key ) { + if ( ! is_array($node[$key] ?? null) ) { + continue; + } + foreach ( $node[$key] as $entry ) { + $path = is_array($entry) ? ($entry['data'] ?? $entry['pathData'] ?? $entry['path'] ?? $entry['d'] ?? null) : $entry; + if ( is_scalar($path) && '' !== trim((string) $path) ) { + $paths[] = trim((string) $path); + } + } + } + + if ( empty($paths) ) { + return null; + } + + return hash('sha256', implode('|', array_values(array_unique($paths)))); + } + + /** + * @param array $node + * @param array $candidate + */ + private function isSameBoxNode(array $node, array $candidate): bool + { + $box = is_array($node['box'] ?? null) ? $node['box'] : array(); + $candidateBox = is_array($candidate['box'] ?? null) ? $candidate['box'] : array(); + foreach ( array('x', 'y', 'width', 'height') as $key ) { + $value = isset($box[$key]) && is_numeric($box[$key]) ? (float) $box[$key] : ( in_array($key, array('x', 'y'), true) ? 0.0 : null ); + $candidateValue = isset($candidateBox[$key]) && is_numeric($candidateBox[$key]) ? (float) $candidateBox[$key] : ( in_array($key, array('x', 'y'), true) ? 0.0 : null ); + if ( null === $value || null === $candidateValue || abs($value - $candidateValue) > 0.5 ) { + return false; + } + } + + return true; + } + + /** @param array $node */ + private function hasVisibleSolidFill(array $node): bool + { + $paintCollections = array(); + if ( is_array($node['figma_paints']['fills'] ?? null) ) { + $paintCollections[] = $node['figma_paints']['fills']; + } + foreach ( array('fillPaints', 'paints') as $key ) { + if ( is_array($node[$key] ?? null) ) { + $paintCollections[] = $node[$key]; + } + } + + foreach ( $paintCollections as $fills ) { + foreach ( $fills as $fill ) { + if ( ! is_array($fill) || 'SOLID' !== strtoupper((string) ($fill['type'] ?? '')) ) { + continue; + } + if ( false === ($fill['visible'] ?? null) || (isset($fill['opacity']) && is_numeric($fill['opacity']) && (float) $fill['opacity'] <= 0.0) ) { + continue; + } + + return true; + } + } + + return false; + } + + /** + * @param array $maskNode + * @param array> $nodes + * @return array|null + */ + private function simpleMaskTargetNode(array $maskNode, array $nodes): ?array + { + if ( ! isset($maskNode['_source_order']) || ! is_numeric($maskNode['_source_order']) ) { + return null; + } + + $maskOrder = (int) $maskNode['_source_order']; + $targetNode = null; + $targetOrder = PHP_INT_MAX; + foreach ( $nodes as $node ) { + if ( $this->isMaskOperatorNode($node) || ! isset($node['_source_order']) || ! is_numeric($node['_source_order']) ) { + continue; + } + $nodeOrder = (int) $node['_source_order']; + if ( $nodeOrder > $maskOrder && $nodeOrder < $targetOrder ) { + $targetNode = $node; + $targetOrder = $nodeOrder; + } + } + + return $targetNode; + } + + /** + * @param array $maskNode + * @param array $targetNode + */ + private function simpleMaskClipPath(array $maskNode, array $targetNode): ?string + { + $maskType = strtoupper((string) ($maskNode['type'] ?? '')); + if ( ! in_array($maskType, array('RECTANGLE', 'FRAME', 'ELLIPSE'), true) ) { + return null; + } + + $maskBox = is_array($maskNode['box'] ?? null) ? $maskNode['box'] : array(); + $targetBox = is_array($targetNode['box'] ?? null) ? $targetNode['box'] : array(); + foreach ( array('width', 'height') as $dimension ) { + if ( ! isset($maskBox[$dimension], $targetBox[$dimension]) || ! is_numeric($maskBox[$dimension]) || ! is_numeric($targetBox[$dimension]) || 0.0 >= (float) $targetBox[$dimension] ) { + return null; + } + } + + $maskLeft = isset($maskBox['x']) && is_numeric($maskBox['x']) ? (float) $maskBox['x'] : 0.0; + $maskTop = isset($maskBox['y']) && is_numeric($maskBox['y']) ? (float) $maskBox['y'] : 0.0; + $targetLeft = isset($targetBox['x']) && is_numeric($targetBox['x']) ? (float) $targetBox['x'] : 0.0; + $targetTop = isset($targetBox['y']) && is_numeric($targetBox['y']) ? (float) $targetBox['y'] : 0.0; + $maskWidth = (float) $maskBox['width']; + $maskHeight = (float) $maskBox['height']; + $targetWidth = (float) $targetBox['width']; + $targetHeight = (float) $targetBox['height']; + $relativeLeft = $maskLeft - $targetLeft; + $relativeTop = $maskTop - $targetTop; + + if ( 'ELLIPSE' === $maskType ) { + return 'ellipse(' . ($this->number)($maskWidth / 2.0) . 'px ' . ($this->number)($maskHeight / 2.0) . 'px at ' . ($this->number)($relativeLeft + ($maskWidth / 2.0)) . 'px ' . ($this->number)($relativeTop + ($maskHeight / 2.0)) . 'px)'; + } + + $clip = 'inset(' + . ($this->number)($relativeTop) . 'px ' + . ($this->number)($targetWidth - ($relativeLeft + $maskWidth)) . 'px ' + . ($this->number)($targetHeight - ($relativeTop + $maskHeight)) . 'px ' + . ($this->number)($relativeLeft) . 'px'; + $radius = $this->simpleMaskRadius($maskNode); + if ( null !== $radius && $radius > 0.0 ) { + $clip .= ' round ' . ($this->number)($radius) . 'px'; + } + + return $clip . ')'; + } + + /** @param array $maskNode */ + private function simpleMaskRadius(array $maskNode): ?float + { + $box = is_array($maskNode['figma_box'] ?? null) ? $maskNode['figma_box'] : array(); + if ( isset($box['corner_radius']) && is_numeric($box['corner_radius']) ) { + return (float) $box['corner_radius']; + } + if ( isset($maskNode['cornerRadius']) && is_numeric($maskNode['cornerRadius']) ) { + return (float) $maskNode['cornerRadius']; + } + + return null; + } + + private function isUnsupportedVectorType(string $type): bool + { + return in_array($type, array('VECTOR', 'BOOLEAN_OPERATION', 'LINE', 'ELLIPSE', 'STAR', 'POLYGON', 'REGULAR_POLYGON'), true); + } + + /** @param array $node */ + private function hasVisiblePaintCollection(array $node): bool + { + foreach ( array('fills', 'strokes', 'background') as $key ) { + $paints = is_array($node['figma_paints'][$key] ?? null) ? $node['figma_paints'][$key] : array(); + foreach ( $paints as $paint ) { + if ( is_array($paint) && false !== ($paint['visible'] ?? true) && ((isset($paint['opacity']) && is_numeric($paint['opacity'])) ? (float) $paint['opacity'] > 0.0 : true) ) { + return true; + } + } + } + + return false; + } +} diff --git a/figma-transformer/src/Html/ClipMaskStyleResolver.php b/figma-transformer/src/Html/ClipMaskStyleResolver.php new file mode 100644 index 00000000..7881c1ef --- /dev/null +++ b/figma-transformer/src/Html/ClipMaskStyleResolver.php @@ -0,0 +1,49 @@ +): bool $containsStickyPrimary + */ + public function __construct( + private readonly EffectOverflowPolicy $effectOverflowPolicy, + private readonly mixed $containsStickyPrimary, + ) { + } + + /** + * @param array $node + * @return array + */ + public function resolve(array $node): array + { + $styles = array(); + + if ( $this->effectOverflowPolicy->shouldHideOverflow($node, ($this->containsStickyPrimary)($node)) ) { + $styles[] = 'overflow:hidden'; + } + + if ( isset($node['_figma_css_clip_path']) && is_scalar($node['_figma_css_clip_path']) && '' !== (string) $node['_figma_css_clip_path'] ) { + $styles[] = 'clip-path:' . (string) $node['_figma_css_clip_path']; + } + + if ( isset($node['_figma_css_mask_image_path']) && is_scalar($node['_figma_css_mask_image_path']) && '' !== (string) $node['_figma_css_mask_image_path'] ) { + $maskPath = (string) $node['_figma_css_mask_image_path']; + $styles[] = '-webkit-mask-image:url("' . $maskPath . '")'; + $styles[] = 'mask-image:url("' . $maskPath . '")'; + $styles[] = '-webkit-mask-size:100% 100%'; + $styles[] = 'mask-size:100% 100%'; + $styles[] = '-webkit-mask-repeat:no-repeat'; + $styles[] = 'mask-repeat:no-repeat'; + } + + return $styles; + } +} diff --git a/figma-transformer/src/Html/CssPositioningResolver.php b/figma-transformer/src/Html/CssPositioningResolver.php index fca71f49..2fbdc766 100644 --- a/figma-transformer/src/Html/CssPositioningResolver.php +++ b/figma-transformer/src/Html/CssPositioningResolver.php @@ -29,9 +29,15 @@ public function styles(array $box, array $layout, ?array $parentNode, ?array $no { $styles = array(); $parentBox = is_array($parentNode['box'] ?? null) ? $parentNode['box'] : array(); + $parentLayout = is_array($parentNode['layout'] ?? null) ? $parentNode['layout'] : array(); + $parentIsFreeform = true === ($parentLayout['freeform'] ?? false); $left = $this->layoutIntentClassifier->positionOffset($box, $parentBox, 'x', $parentNode); $top = $this->layoutIntentClassifier->positionOffset($box, $parentBox, 'y', $parentNode); $centerInsetVisualChild = null !== $node && null !== $parentNode && $this->isInsetSingleVisualChild($node, $parentNode); + if ( null !== $node ) { + $left = $this->componentSourceCloneScalarOffset($node, $box, $parentBox, 'x', $left); + $top = $this->componentSourceCloneScalarOffset($node, $box, $parentBox, 'y', $top); + } if ( null !== $node && $this->hasComponentCloneGeometry($node) ) { $left = $this->componentCloneSourceOffset($node, $box, $parentBox, 'x', $left); $top = $this->componentCloneSourceOffset($node, $box, $parentBox, 'y', $top); @@ -43,11 +49,14 @@ public function styles(array $box, array $layout, ?array $parentNode, ?array $no $constraints['horizontal'] = 'CENTER'; $constraints['vertical'] = 'CENTER'; } + if ( null !== $node && $this->hasComponentCloneGeometry($node) && 'local' === ($box['coordinate_space'] ?? null) && 'absolute' !== ($layout['positioning'] ?? null) ) { + unset($constraints['horizontal'], $constraints['vertical']); + } - foreach ( $this->axisConstraintStyles('horizontal', is_scalar($constraints['horizontal'] ?? null) ? (string) $constraints['horizontal'] : null, $left, $parentBox, $box, $centerWithinFluidCanvas) as $style ) { + foreach ( $this->axisConstraintStyles('horizontal', is_scalar($constraints['horizontal'] ?? null) ? (string) $constraints['horizontal'] : null, $left, $parentBox, $box, $layout, $parentNode, $centerWithinFluidCanvas, $parentIsFreeform) as $style ) { $styles[] = $style; } - foreach ( $this->axisConstraintStyles('vertical', is_scalar($constraints['vertical'] ?? null) ? (string) $constraints['vertical'] : null, $top, $parentBox, $box) as $style ) { + foreach ( $this->axisConstraintStyles('vertical', is_scalar($constraints['vertical'] ?? null) ? (string) $constraints['vertical'] : null, $top, $parentBox, $box, $layout, $parentNode) as $style ) { $styles[] = $style; } @@ -136,6 +145,38 @@ private function hasComponentCloneGeometry(array $node): bool return false; } + /** + * @param array $node + * @param array $box + * @param array $parentBox + */ + private function componentSourceCloneScalarOffset(array $node, array $box, array $parentBox, string $dimension, ?float $offset): ?float + { + if ( null === $offset || ! isset($node['figma_component_source_id']) || ! is_scalar($node['figma_component_source_id']) || '' === (string) $node['figma_component_source_id'] ) { + return $offset; + } + + if ( 'page' !== ($box['local_origin'] ?? null) || ! isset($node[$dimension]) || ! is_numeric($node[$dimension]) ) { + return $offset; + } + + $scalar = (float) $node[$dimension]; + $sizeKey = 'x' === $dimension ? 'width' : 'height'; + if ( isset($parentBox[$sizeKey], $box[$sizeKey]) && is_numeric($parentBox[$sizeKey]) && is_numeric($box[$sizeKey]) ) { + $parentSize = (float) $parentBox[$sizeKey]; + $boxSize = (float) $box[$sizeKey]; + if ( $parentSize > 0.0 && $boxSize > 0.0 ) { + $offsetFitsParent = $offset >= -0.5 && $offset + $boxSize <= $parentSize + 0.5; + $scalarFitsParent = $scalar >= -0.5 && $scalar + $boxSize <= $parentSize + 0.5; + if ( $offsetFitsParent || ! $scalarFitsParent ) { + return $offset; + } + } + } + + return abs($offset - $scalar) > 0.5 ? $scalar : $offset; + } + /** * @param array $node * @param array $box @@ -181,9 +222,10 @@ private function componentCloneSourceOffset(array $node, array $box, array $pare * * @param array $parentBox * @param array $box + * @param array $layout * @return array */ - private function axisConstraintStyles(string $axis, ?string $constraint, ?float $offset, array $parentBox, array $box, bool $centerWithinFluidCanvas = false): array + private function axisConstraintStyles(string $axis, ?string $constraint, ?float $offset, array $parentBox, array $box, array $layout, ?array $parentNode, bool $centerWithinFluidCanvas = false, bool $parentIsFreeform = false): array { $isHorizontal = 'horizontal' === $axis; $startProp = $isHorizontal ? 'left' : 'top'; @@ -204,6 +246,30 @@ private function axisConstraintStyles(string $axis, ?string $constraint, ?float return $styles; } + if ( $isHorizontal && $parentIsFreeform && $centerWithinFluidCanvas && null !== $offset && null !== $parentSize && null !== $boxSize && $this->hasFluidStretchIntent($layout) ) { + $trailing = $parentSize - $offset - $boxSize; + if ( $trailing >= -0.5 ) { + $styles[] = $startProp . ':' . $this->number(max(0.0, $offset)) . 'px'; + $styles[] = $endProp . ':' . $this->number(max(0.0, $trailing)) . 'px'; + return $styles; + } + } + + if ( $isHorizontal && null !== $offset && null !== $parentSize && null !== $boxSize && $this->parentIsHeaderChrome($parentNode ?? null) ) { + $trailing = $parentSize - $offset - $boxSize; + if ( $trailing >= -0.5 && $trailing <= 64.0 && $offset > 64.0 ) { + if ( $boxSize >= $parentSize * 0.5 ) { + $styles[] = $startProp . ':' . $this->number($offset) . 'px'; + $styles[] = $endProp . ':' . $this->number(max(0.0, $trailing)) . 'px'; + $styles[] = $sizeKey . ':auto'; + return $styles; + } + + $styles[] = $endProp . ':' . $this->number(max(0.0, $trailing)) . 'px'; + return $styles; + } + } + // Center pin: keep the child center at a constant offset from the parent // center. Emit the leading edge directly so node transforms remain free. if ( 'CENTER' === $constraint && null !== $offset && null !== $parentSize ) { @@ -244,8 +310,31 @@ private function hasSymmetricFluidCanvasGutters(float $offset, float $parentSize return abs($offset - $trailing) <= 1.0; } + /** + * @param array $layout + */ + private function hasFluidStretchIntent(array $layout): bool + { + return isset($layout['grow']) && is_numeric($layout['grow']) && (float) $layout['grow'] > 0.0; + } + private function number(float $value): string { return ($this->numberFormatter)($value); } + + /** + * @param array|null $parentNode + */ + private function parentIsHeaderChrome(?array $parentNode): bool + { + if ( null === $parentNode ) { + return false; + } + + $name = strtolower(trim((string) ($parentNode['name'] ?? ''))); + return LayoutIntentClassifier::CHROME_GROUP_ROLE_HEADER === $this->layoutIntentClassifier->chromeGroupRole($parentNode, null, 1) + || 'header' === $name + || str_contains($name, 'top bar'); + } } diff --git a/figma-transformer/src/Html/DecisionTraceBuilder.php b/figma-transformer/src/Html/DecisionTraceBuilder.php new file mode 100644 index 00000000..88f4993b --- /dev/null +++ b/figma-transformer/src/Html/DecisionTraceBuilder.php @@ -0,0 +1,152 @@ +> $traces + * @param array $node + * @param array|null $parentNode + * @param array $evidence + * @param callable(array): string|null $classResolver + */ + public static function recordEmitterTrace(array &$traces, string $domain, string $reasonCode, array $node, string $decision, ?array $parentNode, array $evidence, string $currentPagePath, ?callable $classResolver = null): void + { + if ( '' === $reasonCode ) { + $reasonCode = 'unknown'; + } + + $nodeId = (string) ($node['id'] ?? ''); + $parentId = null === $parentNode ? '' : (string) ($parentNode['id'] ?? ''); + $pagePath = (string) ($evidence['page_path'] ?? $currentPagePath); + $key = implode('|', array($domain, $reasonCode, $decision, $nodeId, $parentId, $pagePath)); + if ( isset($traces[$key]) ) { + $traces[$key]['count'] = (int) ($traces[$key]['count'] ?? 1) + 1; + return; + } + + $class = null; + if ( null !== $classResolver && ('' !== $nodeId || ! empty($node['name'] ?? '')) ) { + $class = $classResolver($node); + } + + $traces[$key] = array_filter(array( + 'domain' => $domain, + 'reason_code' => $reasonCode, + 'decision' => $decision, + 'node_id' => $nodeId, + 'name' => (string) ($node['name'] ?? ''), + 'type' => strtoupper((string) ($node['type'] ?? '')), + 'class' => $class, + 'parent_id' => $parentId, + 'page_path' => $pagePath, + 'evidence' => self::boundedEvidence($evidence), + 'count' => 1, + ), static fn (mixed $value): bool => null !== $value && '' !== $value && array() !== $value); + } + + /** + * @param array> $traces + * @param array $node + * @param array|null $parentNode + * @param array $declarations + * @param array $evidence + */ + public static function recordResponsiveTrace(array &$traces, array $node, ?array $parentNode, string $reasonCode, float $viewportWidth, array $declarations, string $class = '', array $evidence = array()): void + { + if ( '' === $reasonCode ) { + $reasonCode = 'responsive_safety_override'; + } + + $nodeId = (string) ($node['id'] ?? ''); + $key = implode('|', array($reasonCode, $nodeId, (string) ($parentNode['id'] ?? ''), (string) $viewportWidth)); + if ( isset($traces[$key]) ) { + $traces[$key]['count'] = (int) ($traces[$key]['count'] ?? 1) + 1; + return; + } + + $traces[$key] = array_filter(array( + 'domain' => 'responsive_decision', + 'reason_code' => $reasonCode, + 'decision' => 'emit_media_override', + 'node_id' => $nodeId, + 'name' => (string) ($node['name'] ?? ''), + 'type' => strtoupper((string) ($node['type'] ?? '')), + 'class' => $class, + 'parent_id' => null === $parentNode ? null : (string) ($parentNode['id'] ?? ''), + 'viewport_width' => $viewportWidth, + 'declarations' => array_values($declarations), + 'evidence' => self::boundedEvidence($evidence), + 'count' => 1, + ), static fn (mixed $value): bool => null !== $value && '' !== $value && array() !== $value); + } + + /** + * @param array> $traces + * @return array + */ + public static function summary(array $traces): array + { + $countsByReason = array(); + $countsByDomain = array(); + $samplesByDomain = array(); + foreach ( $traces as $trace ) { + $reason = (string) ($trace['reason_code'] ?? 'unknown'); + $domain = (string) ($trace['domain'] ?? 'unknown'); + $countsByReason[$reason] = (int) ($countsByReason[$reason] ?? 0) + 1; + $countsByDomain[$domain] = (int) ($countsByDomain[$domain] ?? 0) + 1; + if ( ! isset($samplesByDomain[$domain]) ) { + $samplesByDomain[$domain] = array(); + } + if ( count($samplesByDomain[$domain]) < 20 ) { + $samplesByDomain[$domain][] = $trace; + } + } + ksort($countsByReason); + ksort($countsByDomain); + ksort($samplesByDomain); + + $samplesByReason = array(); + foreach ( $traces as $trace ) { + $reason = (string) ($trace['reason_code'] ?? 'unknown'); + if ( isset($samplesByReason[$reason]) ) { + continue; + } + + $samplesByReason[$reason] = $trace; + } + ksort($samplesByReason); + + return array( + 'schema' => 'blocks-engine/figma-transformer/decision-traces/v1', + 'trace_count' => count($traces), + 'reason_counts' => $countsByReason, + 'domain_counts' => $countsByDomain, + 'samples' => array_slice(array_values($traces), 0, 100), + 'samples_by_reason' => $samplesByReason, + 'samples_by_domain' => $samplesByDomain, + ); + } + + /** + * @param array $evidence + * @return array + */ + public static function boundedEvidence(array $evidence): array + { + unset($evidence['domain'], $evidence['reason_code'], $evidence['decision'], $evidence['node_id'], $evidence['name'], $evidence['type']); + foreach ( $evidence as $key => $value ) { + if ( is_array($value) ) { + $evidence[$key] = array_slice($value, 0, 10); + } + } + + return array_filter($evidence, static fn (mixed $value): bool => null !== $value && '' !== $value && array() !== $value); + } +} diff --git a/figma-transformer/src/Html/DesignSystemExtractor.php b/figma-transformer/src/Html/DesignSystemExtractor.php index a5c0bbf8..3568c115 100644 --- a/figma-transformer/src/Html/DesignSystemExtractor.php +++ b/figma-transformer/src/Html/DesignSystemExtractor.php @@ -23,6 +23,13 @@ */ final class DesignSystemExtractor { + private TypographyModel $typographyModel; + + public function __construct(?FontResolver $fontResolver = null) + { + $this->typographyModel = new TypographyModel($fontResolver ?? new FontResolver()); + } + /** * Frame-name signals that mark a frame as the design-system source. */ @@ -52,7 +59,7 @@ final class DesignSystemExtractor * many tokens of each kind were extracted. * * @param array $scenegraph Normalized Figma scenegraph. - * @return array{css: string, coverage: array, frame_names: array} + * @return array{css: string, coverage: array, frame_names: array, type_token_map: array, materialized_node_classes: array} */ public function extract(array $scenegraph): array { @@ -67,6 +74,8 @@ public function extract(array $scenegraph): array 'css' => '', 'coverage' => array('color_tokens' => 0, 'type_tokens' => 0, 'spacing_tokens' => 0, 'frame_count' => 0), 'frame_names' => array(), + 'type_token_map' => array(), + 'materialized_node_classes' => array(), ); if ( empty($frames) ) { return $empty; @@ -96,8 +105,11 @@ public function extract(array $scenegraph): array 'type_tokens' => count($typeTokens['classes']), 'spacing_tokens' => count($spacingTokens), 'frame_count' => count($frames), + 'materialized_type_nodes' => count($typeTokens['materialized_node_classes']), ), 'frame_names' => array_values(array_filter($frameNames, static fn (string $name): bool => '' !== $name)), + 'type_token_map' => $typeTokens['token_map'], + 'materialized_node_classes' => $typeTokens['materialized_node_classes'], ); } @@ -294,6 +306,7 @@ private function collectTextStyleTokens(array $node, array &$textStyles): void $key = $this->textStyleKey($style); if ( '' !== $key && ! isset($textStyles[$key]) ) { $style['label'] = trim((string) ($node['name'] ?? '')); + $style['node_class'] = $this->nodeClass($node); $textStyles[$key] = $style; } } @@ -406,12 +419,12 @@ private function colorTokenBaseName(string $label): string * the body class. * * @param array> $textStyles - * @return array{variables: array, classes: array}>} + * @return array{variables: array, classes: array, node_classes: array}>, token_map: array, materialized_node_classes: array} */ private function buildTypeTokens(array $textStyles): array { if ( empty($textStyles) ) { - return array('variables' => array(), 'classes' => array()); + return array('variables' => array(), 'classes' => array(), 'token_map' => array(), 'materialized_node_classes' => array()); } $styles = array_values($textStyles); @@ -428,6 +441,8 @@ private function buildTypeTokens(array $textStyles): array $count = count($styles); $variables = array(); $classes = array(); + $tokenMap = array(); + $materializedNodeClasses = array(); $usedVarNames = array(); $headingLevel = 1; @@ -443,8 +458,10 @@ private function buildTypeTokens(array $textStyles): array $size = isset($style['font_size']) && is_numeric($style['font_size']) ? (float) $style['font_size'] : null; $declarations = array(); - if ( isset($style['font_family']) && is_scalar($style['font_family']) && '' !== (string) $style['font_family'] ) { - $declarations[] = 'font-family:' . $this->fallbackFontStack((string) $style['font_family']); + foreach ( $this->typographyModel->declarations($style) as $declaration ) { + if ( ! str_starts_with($declaration, 'font-size:') ) { + $declarations[] = $declaration; + } } if ( null !== $size ) { $varName = 'font-size-' . $role; @@ -456,23 +473,27 @@ private function buildTypeTokens(array $textStyles): array } $usedVarNames[$unique] = true; $variables[] = array('name' => $unique, 'value' => $this->number($size) . 'px'); + $tokenMap[$this->textStyleKey($style)] = $unique; $declarations[] = 'font-size:var(--' . $unique . ')'; } - if ( isset($style['font_weight']) && is_numeric($style['font_weight']) ) { - $declarations[] = 'font-weight:' . $this->number((float) $style['font_weight']); - } - if ( isset($style['line_height']) && is_numeric($style['line_height']) && (float) $style['line_height'] > 0.0 ) { - $declarations[] = 'line-height:' . $this->number((float) $style['line_height']) . 'px'; - } - if ( empty($declarations) ) { continue; } - $classes[] = array('name' => 'type-' . $role, 'declarations' => $declarations); + $nodeClasses = array(); + if ( isset($style['node_class']) && is_string($style['node_class']) && '' !== $style['node_class'] ) { + $nodeClasses[] = $style['node_class']; + $materializedNodeClasses[] = $style['node_class']; + } + $classes[] = array('name' => 'type-' . $role, 'declarations' => $declarations, 'node_classes' => $nodeClasses); } - return array('variables' => $variables, 'classes' => $classes); + return array( + 'variables' => $variables, + 'classes' => $classes, + 'token_map' => $tokenMap, + 'materialized_node_classes' => array_values(array_unique($materializedNodeClasses)), + ); } /** @@ -547,7 +568,13 @@ private function renderCss(array $colorTokens, array $typeVariables, array $spac $blocks[] = ':root{' . implode(';', $rootDeclarations) . '}'; } foreach ( $typeClasses as $class ) { - $blocks[] = '.' . $class['name'] . '{' . implode(';', $class['declarations']) . '}'; + $selectors = array('.' . $class['name']); + foreach ( is_array($class['node_classes'] ?? null) ? $class['node_classes'] : array() as $nodeClass ) { + if ( is_string($nodeClass) && '' !== $nodeClass ) { + $selectors[] = '.' . $nodeClass; + } + } + $blocks[] = implode(',', array_values(array_unique($selectors))) . '{' . implode(';', $class['declarations']) . '}'; } return empty($blocks) ? '' : implode("\n", $blocks) . "\n"; @@ -559,32 +586,7 @@ private function renderCss(array $colorTokens, array $typeVariables, array $spac */ private function textStyle(array $node): ?array { - $text = is_array($node['figma_text'] ?? null) ? $node['figma_text'] : array(); - $style = is_array($text['style'] ?? null) ? $text['style'] : array(); - if ( empty($style) ) { - return null; - } - - $result = array(); - if ( isset($style['font_family']) && is_scalar($style['font_family']) ) { - $result['font_family'] = (string) $style['font_family']; - } - if ( isset($style['font_size']) && is_numeric($style['font_size']) ) { - $result['font_size'] = (float) $style['font_size']; - } - if ( isset($style['font_weight']) && is_numeric($style['font_weight']) ) { - $result['font_weight'] = (float) $style['font_weight']; - } - if ( isset($style['line_height_px']) && is_numeric($style['line_height_px']) && (float) $style['line_height_px'] > 0.0 ) { - $result['line_height'] = (float) $style['line_height_px']; - } - - // A style with no usable typographic fields is not a specimen. - if ( ! isset($result['font_size']) && ! isset($result['font_family']) ) { - return null; - } - - return $result; + return $this->typographyModel->styleFromNode($node); } /** @@ -592,13 +594,7 @@ private function textStyle(array $node): ?array */ private function textStyleKey(array $style): string { - $family = isset($style['font_family']) ? strtolower((string) $style['font_family']) : ''; - $size = isset($style['font_size']) ? $this->number((float) $style['font_size']) : ''; - $weight = isset($style['font_weight']) ? $this->number((float) $style['font_weight']) : ''; - $lineHeight = isset($style['line_height']) ? $this->number((float) $style['line_height']) : ''; - $key = $family . '|' . $size . '|' . $weight . '|' . $lineHeight; - - return '|||' === $key ? '' : $key; + return $this->typographyModel->signature($style); } /** @@ -665,19 +661,11 @@ private function colorChannel(mixed $value): ?int } /** - * Build a CSS font-family stack from a single family name, mirroring the - * generic fallback that FontResolver applies so the type scale and the - * per-node text styles agree. + * @param array $node */ - private function fallbackFontStack(string $family): string + private function nodeClass(array $node): string { - $name = trim($family); - if ( '' === $name ) { - return 'sans-serif'; - } - $quoted = str_contains($name, ' ') ? '"' . str_replace('"', '\\"', $name) . '"' : $name; - - return $quoted . ',sans-serif'; + return 'figma-node-' . $this->slug((string) ($node['id'] ?? '') . '-' . (string) ($node['name'] ?? '')); } /** diff --git a/figma-transformer/src/Html/FontResolver.php b/figma-transformer/src/Html/FontResolver.php index b35df1d5..d7d10724 100644 --- a/figma-transformer/src/Html/FontResolver.php +++ b/figma-transformer/src/Html/FontResolver.php @@ -116,7 +116,9 @@ public function resolve(array $fontUsage, string $operatorFontCss = '', array $f $key = strtolower($family); $weights = $this->normalizeWeights(is_array($usage['weights'] ?? null) ? $usage['weights'] : array()); - if ( $operatorSupplied ) { + if ( $this->isStyleTokenOnlyUsage($usage) ) { + $resolution = 'style_token_only'; + } elseif ( $operatorSupplied ) { $resolution = 'operator_supplied'; } elseif ( isset($normalizedOverrides[$key]) ) { $resolution = 'operator_supplied_family'; @@ -229,6 +231,33 @@ private function genericFor(string $family): string return 'sans-serif'; } + /** + * Materialized design-token CSS can carry a source family label even when no + * emitted text uses that family. Treat those labels as metadata, not missing + * web-font requirements; real text usage still reports unresolved fonts. + * + * @param array $usage + */ + private function isStyleTokenOnlyUsage(array $usage): bool + { + if ( 0 !== (int) ($usage['text_node_count'] ?? 0) || 0 !== (int) ($usage['visible_text_area_px'] ?? 0) ) { + return false; + } + + $samples = is_array($usage['sample_nodes'] ?? null) ? $usage['sample_nodes'] : array(); + if ( empty($samples) ) { + return false; + } + + foreach ( $samples as $sample ) { + if ( ! is_array($sample) || 'materialized_css' !== ($sample['source'] ?? null) ) { + return false; + } + } + + return true; + } + /** * @param array> $cdnFamilies Canonical family => weights. */ diff --git a/figma-transformer/src/Html/HtmlArtifactAssembler.php b/figma-transformer/src/Html/HtmlArtifactAssembler.php index d04ea57c..cdcdc732 100644 --- a/figma-transformer/src/Html/HtmlArtifactAssembler.php +++ b/figma-transformer/src/Html/HtmlArtifactAssembler.php @@ -62,9 +62,62 @@ public function stylesheet(string $fontCss, string $designSystemCss, array $cssR return $css; } - public function htmlDocument(string $title, string $stylesheetHref, string $body): string + /** + * @param array $metadata + */ + public function htmlDocument(string $title, string $stylesheetHref, string $body, array $metadata = array()): string { - return "\n\n\n\n\n" . $title . "\nsanitizeAttribute($stylesheetHref) . "\">\n\n\n
\n" . $body . "
\n\n\n"; + $head = array( + '', + '', + '' . $title . '', + ); + + $description = $this->metadataValue($metadata, 'description'); + if ( null !== $description ) { + $head[] = ''; + } + + $canonicalUrl = $this->metadataValue($metadata, 'canonical_url'); + if ( null !== $canonicalUrl ) { + $head[] = ''; + } + + $faviconHref = $this->metadataValue($metadata, 'favicon_href'); + if ( null !== $faviconHref ) { + $head[] = ''; + } + + foreach ( array( + 'og_title' => array('property', 'og:title'), + 'og_description' => array('property', 'og:description'), + 'og_image' => array('property', 'og:image'), + 'twitter_card' => array('name', 'twitter:card'), + 'twitter_title' => array('name', 'twitter:title'), + 'twitter_description' => array('name', 'twitter:description'), + 'twitter_image' => array('name', 'twitter:image'), + ) as $key => $tag ) { + $value = $this->metadataValue($metadata, $key); + if ( null !== $value ) { + $head[] = ''; + } + } + + $head[] = ''; + + $mainAttributes = array( + 'class="figma-root"', + 'data-figma-root="true"', + 'data-page-title="' . $this->sanitizeAttribute(html_entity_decode($title, ENT_QUOTES | ENT_HTML5, 'UTF-8')) . '"', + 'aria-label="' . $this->sanitizeAttribute(html_entity_decode($title, ENT_QUOTES | ENT_HTML5, 'UTF-8')) . '"', + ); + + $pagePath = $this->metadataValue($metadata, 'page_path'); + if ( null !== $pagePath ) { + $mainAttributes[] = 'data-page-path="' . $this->sanitizeAttribute($pagePath) . '"'; + } + + return "\n\n\n" . implode("\n", $head) . "\n\n\n
\n" . $body . "
\n\n\n"; } /** @@ -87,4 +140,17 @@ private function sanitizeAttribute(string $value): string $sanitizeAttribute = $this->attributeSanitizer; return $sanitizeAttribute($value); } + + /** + * @param array $metadata + */ + private function metadataValue(array $metadata, string $key): ?string + { + if ( ! isset($metadata[$key]) || ! is_scalar($metadata[$key]) ) { + return null; + } + + $value = trim((string) $metadata[$key]); + return '' === $value ? null : $value; + } } diff --git a/figma-transformer/src/Html/LayoutFrameRoleClassifier.php b/figma-transformer/src/Html/LayoutFrameRoleClassifier.php new file mode 100644 index 00000000..bf1c861e --- /dev/null +++ b/figma-transformer/src/Html/LayoutFrameRoleClassifier.php @@ -0,0 +1,227 @@ + $box + * @param array $layout + * @param array|null $parentNode + */ + public function frameWidthRole(array $box, array $layout, ?array $parentNode): string + { + if ( ! $this->isFluidPageWidth($box, $layout, $parentNode) ) { + return self::ROLE_INTRINSIC; + } + + return null === $parentNode ? self::ROLE_FULL_BLEED_ROOT : self::ROLE_FULL_BLEED_BAND; + } + + /** + * @param array $box + * @param array $layout + * @param array $parentNode + */ + public function canvasChildRole(array $box, array $layout, array $parentNode, bool $parentUsesFluidCanvasCoordinates, bool $parentIsFreeform): string + { + if ( ! $parentUsesFluidCanvasCoordinates ) { + return self::ROLE_INTRINSIC; + } + + if ( $this->isAbsoluteFullWidthCanvasChild($box, $layout, $parentNode, $parentIsFreeform) ) { + return self::ROLE_FULL_BLEED_CANVAS_CHILD; + } + + if ( $this->isFluidStretchAbsoluteChild($box, $layout, $parentNode, $parentIsFreeform) ) { + return self::ROLE_CENTERED_SHELL; + } + + if ( $this->isCenteredCanvasShell($box, $parentNode) ) { + return self::ROLE_CENTERED_SHELL; + } + + return self::ROLE_INTRINSIC; + } + + /** + * @param array $layout + */ + public function roleUsesFlowHeight(string $role, array $layout): bool + { + if ( ! in_array($role, array(self::ROLE_FULL_BLEED_ROOT, self::ROLE_FULL_BLEED_BAND, self::ROLE_CENTERED_SHELL), true) ) { + return false; + } + + if ( true === ($layout['clips_content'] ?? false) ) { + return false; + } + + if ( 'FIXED' === strtoupper((string) ($layout['sizing_vertical'] ?? '')) ) { + return false; + } + + return 'flex' === ($layout['display'] ?? null) && 'column' === ($layout['flex_direction'] ?? null); + } + + /** + * @param array $box + * @param array $layout + * @param array|null $parentNode + */ + public function isFluidPageWidth(array $box, array $layout, ?array $parentNode): bool + { + if ( ! isset($box['width']) || ! is_numeric($box['width']) ) { + return false; + } + + $width = (float) $box['width']; + if ( null === $parentNode ) { + return $width >= self::FLUID_CANVAS_MIN_WIDTH; + } + + if ( 'absolute' === ($layout['positioning'] ?? null) ) { + return false; + } + + $parentBox = is_array($parentNode['box'] ?? null) ? $parentNode['box'] : array(); + if ( ! isset($parentBox['width']) || ! is_numeric($parentBox['width']) || (float) $parentBox['width'] < self::FLUID_CANVAS_MIN_WIDTH ) { + return false; + } + + $offset = isset($box['x']) && is_numeric($box['x']) ? abs((float) $box['x']) : 0.0; + $parentWidth = (float) $parentBox['width']; + return $offset <= self::FULL_BLEED_EDGE_TOLERANCE && abs($width - $parentWidth) <= self::FULL_BLEED_EDGE_TOLERANCE; + } + + /** + * @param array $box + * @param array $layout + * @param array $parentNode + */ + public function isAbsoluteFullWidthCanvasChild(array $box, array $layout, array $parentNode, bool $parentIsFreeform): bool + { + if ( 'absolute' !== ($layout['positioning'] ?? null) && ! $parentIsFreeform ) { + return false; + } + + $parentBox = is_array($parentNode['box'] ?? null) ? $parentNode['box'] : array(); + foreach ( array($box, $parentBox) as $candidateBox ) { + if ( ! isset($candidateBox['width']) || ! is_numeric($candidateBox['width']) ) { + return false; + } + } + + $offset = isset($box['x']) && is_numeric($box['x']) ? abs((float) $box['x']) : 0.0; + return $offset <= self::FULL_BLEED_EDGE_TOLERANCE && abs((float) $box['width'] - (float) $parentBox['width']) <= self::FULL_BLEED_EDGE_TOLERANCE; + } + + /** + * @param array $box + * @param array $layout + * @param array $parentNode + */ + public function isFluidStretchAbsoluteChild(array $box, array $layout, array $parentNode, bool $parentIsFreeform): bool + { + if ( 'absolute' !== ($layout['positioning'] ?? null) && ! $parentIsFreeform ) { + return false; + } + if ( 'FILL' !== strtoupper((string) ($layout['sizing_horizontal'] ?? '')) && (! isset($layout['grow']) || ! is_numeric($layout['grow']) || (float) $layout['grow'] <= 0.0) ) { + return false; + } + + $parentBox = is_array($parentNode['box'] ?? null) ? $parentNode['box'] : array(); + foreach ( array('x', 'width') as $dimension ) { + if ( ! isset($box[$dimension]) || ! is_numeric($box[$dimension]) ) { + return false; + } + } + if ( ! isset($parentBox['width']) || ! is_numeric($parentBox['width']) || (float) $parentBox['width'] < self::FLUID_CANVAS_MIN_WIDTH ) { + return false; + } + + $trailing = (float) $parentBox['width'] - (float) $box['x'] - (float) $box['width']; + return $trailing >= -0.5; + } + + /** + * @param array $box + * @param array $parentNode + */ + public function isCenteredCanvasShell(array $box, array $parentNode): bool + { + $parentBox = is_array($parentNode['box'] ?? null) ? $parentNode['box'] : array(); + foreach ( array('x', 'width') as $dimension ) { + if ( ! isset($box[$dimension]) || ! is_numeric($box[$dimension]) ) { + return false; + } + } + if ( ! isset($parentBox['width']) || ! is_numeric($parentBox['width']) ) { + return false; + } + + $parentWidth = (float) $parentBox['width']; + $width = (float) $box['width']; + if ( $parentWidth < self::FLUID_CANVAS_MIN_WIDTH || $width <= 0.0 || $width >= $parentWidth - 1.0 ) { + return false; + } + + if ( $this->isSymmetricPaddedContentShell($box, $parentNode, $parentWidth, $width) ) { + return true; + } + + $offset = (float) $box['x']; + if ( $offset < -0.5 || $offset + $width > $parentWidth + 0.5 ) { + return false; + } + + $trailing = $parentWidth - $offset - $width; + return abs($offset - $trailing) <= 1.0; + } + + /** + * @param array $box + * @param array $parentNode + */ + private function isSymmetricPaddedContentShell(array $box, array $parentNode, float $parentWidth, float $width): bool + { + $layout = is_array($parentNode['layout'] ?? null) ? $parentNode['layout'] : array(); + $padding = is_array($layout['padding'] ?? null) ? $layout['padding'] : array(); + if ( ! isset($padding['left'], $padding['right']) || ! is_numeric($padding['left']) || ! is_numeric($padding['right']) ) { + return false; + } + + $left = (float) $padding['left']; + $right = (float) $padding['right']; + if ( $left < 0.0 || $right < 0.0 || abs($left - $right) > 1.0 ) { + return false; + } + + $offset = (float) $box['x']; + $contentWidth = $parentWidth - $left - $right; + return abs($offset) <= 1.0 && $contentWidth > 0.0 && abs($width - $contentWidth) <= 1.0; + } +} diff --git a/figma-transformer/src/Html/LayoutGapResolver.php b/figma-transformer/src/Html/LayoutGapResolver.php new file mode 100644 index 00000000..5dd58728 --- /dev/null +++ b/figma-transformer/src/Html/LayoutGapResolver.php @@ -0,0 +1,53 @@ + $layout + * @return array{row: float, column: float}|null + */ + public function resolve(array $layout): ?array + { + $itemSpacing = $layout['item_spacing'] ?? ($layout['gap'] ?? null); + $mainGap = $this->cssGapValue($itemSpacing); + if ( null === $mainGap ) { + return null; + } + + if ( 'wrap' !== ($layout['flex_wrap'] ?? null) ) { + return array('row' => $mainGap, 'column' => $mainGap); + } + + $counterGap = isset($layout['counter_axis_spacing']) ? $this->cssGapValue($layout['counter_axis_spacing']) : $mainGap; + if ( null === $counterGap ) { + $counterGap = $mainGap; + } + + $isColumn = 'column' === ($layout['flex_direction'] ?? null); + return array( + 'row' => $isColumn ? $mainGap : $counterGap, + 'column' => $isColumn ? $counterGap : $mainGap, + ); + } + + private function cssGapValue(mixed $value): ?float + { + if ( ! is_numeric($value) ) { + return null; + } + + $gap = (float) $value; + if ( ! is_finite($gap) ) { + return null; + } + + return max(0.0, $gap); + } +} diff --git a/figma-transformer/src/Html/LayoutIntentClassifier.php b/figma-transformer/src/Html/LayoutIntentClassifier.php index fbb53bc1..b75bfce0 100644 --- a/figma-transformer/src/Html/LayoutIntentClassifier.php +++ b/figma-transformer/src/Html/LayoutIntentClassifier.php @@ -9,6 +9,25 @@ */ final class LayoutIntentClassifier { + public const STACK_REASON_ABSOLUTE_CHILD = StackingContextPolicy::STACK_REASON_ABSOLUTE_CHILD; + public const STACK_REASON_DECORATIVE_UNDERLAY = StackingContextPolicy::STACK_REASON_DECORATIVE_UNDERLAY; + public const STACK_REASON_FREEFORM_CONTAINER = StackingContextPolicy::STACK_REASON_FREEFORM_CONTAINER; + public const STACK_REASON_MIXED_POSITIONING_CHILDREN = StackingContextPolicy::STACK_REASON_MIXED_POSITIONING_CHILDREN; + public const STACK_REASON_OVERLAPPING_STACKED_CHILD = StackingContextPolicy::STACK_REASON_OVERLAPPING_STACKED_CHILD; + public const STACK_REASON_SOURCE_Z_INDEX = StackingContextPolicy::STACK_REASON_SOURCE_Z_INDEX; + public const STACK_REASON_SIBLING_LAYER_RANK = StackingContextPolicy::STACK_REASON_SIBLING_LAYER_RANK; + public const STACK_REASON_Z_INDEXED_CHILD = StackingContextPolicy::STACK_REASON_Z_INDEXED_CHILD; + + public const LAYER_ROLE_UNDERLAY = StackingContextPolicy::LAYER_ROLE_UNDERLAY; + public const LAYER_ROLE_CONTENT = StackingContextPolicy::LAYER_ROLE_CONTENT; + public const LAYER_ROLE_CHROME = StackingContextPolicy::LAYER_ROLE_CHROME; + + public const CHROME_GROUP_ROLE_HEADER = 'header'; + public const CHROME_GROUP_ROLE_FOOTER = 'footer'; + public const CHROME_GROUP_ROLE_NAVIGATION = 'navigation'; + public const CHROME_GROUP_ROLE_SOCIAL = 'social'; + public const CHROME_GROUP_ROLE_CTA = 'cta'; + /** @var array */ private const FREEFORM_CONTAINER_TYPES = array('FRAME', 'GROUP', 'COMPONENT', 'INSTANCE', 'SECTION'); @@ -27,6 +46,14 @@ final class LayoutIntentClassifier /** @var array */ private const PAINT_COLLECTION_KEYS = array('fills', 'strokes', 'background'); + /** @var array */ + private const CHROME_NAME_HINTS = array('header', 'footer', 'nav', 'navigation', 'menu', 'social', 'cta', 'call to action'); + + /** @var array */ + private const CONTROL_LIST_NAME_HINTS = array('pagination', 'page number'); + + private ?StackingContextPolicy $stackingContextPolicy = null; + /** * @param array> $assetsById */ @@ -60,6 +87,206 @@ public function isFreeformContainer(array $node): bool return $this->hasSingleChildOverflowingLayoutBox($node, $children); } + /** + * Returns the ids of a container's children when they form a semantic content + * list: repeated, structurally-similar, text-bearing siblings rather than a + * compact navigation/chrome cluster. + * + * @param array $container + * @return array + */ + public function semanticListItemIds(array $container): array + { + $name = strtolower((string) ($container['name'] ?? '')); + foreach ( array_merge(self::CHROME_NAME_HINTS, array('article')) as $hint ) { + if ( str_contains($name, $hint) ) { + return array(); + } + } + if ( str_contains($name, 'table of contents') || preg_match('/\btoc\b/', $name) ) { + return array(); + } + + $children = array_values(array_filter($this->nodeList($container), 'is_array')); + if ( 3 > count($children) ) { + return array(); + } + + $linkChildCount = $this->linkChildCount($children); + if ( $linkChildCount >= count($children) && ! $this->hasRichRepeatedContent($children) ) { + return array(); + } + + $type = strtoupper((string) ($children[0]['type'] ?? '')); + $heights = array(); + foreach ( $children as $child ) { + if ( strtoupper((string) ($child['type'] ?? '')) !== $type ) { + return array(); + } + if ( ! $this->subtreeHasText($child) ) { + return array(); + } + $height = $this->boxValue($child, 'height'); + if ( null !== $height ) { + $heights[] = $height; + } + } + + // Direct text-only lists are usually compact nav/legal rows. A larger + // text region with several text nodes is content, not a list. + if ( 'TEXT' === $type ) { + $containerHeight = $this->boxValue($container, 'height'); + if ( null === $containerHeight || empty($heights) ) { + return array(); + } + $maxChildHeight = max($heights); + if ( $maxChildHeight > 0.0 && $containerHeight > ( $maxChildHeight * 2.0 ) ) { + return array(); + } + } + + if ( count($heights) >= 2 ) { + $min = min($heights); + $max = max($heights); + if ( $min > 0.0 && ( $max / $min ) > 1.5 ) { + return array(); + } + } + + $ids = array(); + foreach ( $children as $child ) { + $ids[] = (string) ($child['id'] ?? ''); + } + + return $ids; + } + + /** + * @param array $node + * @param array|null $parentNode + * @param array|null $grandParentNode + */ + public function isChromeListContext(array $node, ?array $parentNode, ?array $grandParentNode): bool + { + foreach ( array($node, $parentNode, $grandParentNode) as $candidate ) { + if ( ! is_array($candidate) ) { + continue; + } + + if ( null !== $this->chromeGroupRole($candidate, null, 1) ) { + return true; + } + + $name = strtolower((string) ($candidate['name'] ?? '')); + foreach ( array_merge(self::CHROME_NAME_HINTS, self::CONTROL_LIST_NAME_HINTS) as $hint ) { + if ( str_contains($name, $hint) ) { + return true; + } + } + } + + return false; + } + + /** + * Classifies generic website chrome groups independently from the eventual + * HTML element chosen by the emitter. + * + * @param array $node + * @param array|null $parentNode + */ + public function chromeGroupRole(array $node, ?array $parentNode, int $depth): ?string + { + if ( $depth <= 0 ) { + return null; + } + + $name = strtolower((string) ($node['name'] ?? '')); + $children = array_values(array_filter($this->nodeList($node), 'is_array')); + + if ( str_contains($name, 'header') && (str_contains($name, 'menu') || str_contains($name, 'nav')) && $this->isNavigationContainer($children) ) { + return self::CHROME_GROUP_ROLE_NAVIGATION; + } + + if ( str_contains($name, 'social') && $this->isSocialIconCluster($node, $children) ) { + return self::CHROME_GROUP_ROLE_SOCIAL; + } + + if ( str_contains($name, 'header') ) { + return $this->isHeaderChromeCandidate($node, $children, $depth, $parentNode) ? self::CHROME_GROUP_ROLE_HEADER : null; + } + + if ( str_contains($name, 'footer') ) { + return $this->isFooterChromeCandidate($node, $depth, $parentNode) ? self::CHROME_GROUP_ROLE_FOOTER : null; + } + + if ( (str_contains($name, 'nav') || str_contains($name, 'menu')) && ! $this->isMenuItemName($name) ) { + return $this->isNavigationContainer($children) ? self::CHROME_GROUP_ROLE_NAVIGATION : null; + } + + if ( $this->isCtaGroup($node, $children) ) { + return self::CHROME_GROUP_ROLE_CTA; + } + + if ( empty($children) ) { + return null; + } + + $linkCount = $this->linkChildCount($children); + if ( $depth <= 1 && null !== $parentNode ) { + $region = $this->verticalRegion($node, $parentNode); + if ( 'top' === $region && $this->hasLogoChild($children) && ( $linkCount >= 1 || count($children) >= 2 ) ) { + return self::CHROME_GROUP_ROLE_HEADER; + } + if ( 'bottom' === $region && $this->hasLegalText($node) && $this->textDescendantCount($node) <= 12 ) { + return self::CHROME_GROUP_ROLE_FOOTER; + } + } + + if ( $this->isSocialIconCluster($node, $children) ) { + return self::CHROME_GROUP_ROLE_SOCIAL; + } + + if ( $linkCount >= 2 && $linkCount === count($children) ) { + return self::CHROME_GROUP_ROLE_NAVIGATION; + } + + return null; + } + + /** + * @param array $container + */ + public function semanticListLooksOrdered(array $container): bool + { + $itemIds = $this->semanticListItemIds($container); + if ( empty($itemIds) ) { + return false; + } + + $expected = 1; + foreach ( $this->nodeList($container) as $child ) { + if ( ! is_array($child) || ! in_array((string) ($child['id'] ?? ''), $itemIds, true) ) { + continue; + } + + $children = array_values(array_filter($this->nodeList($child), 'is_array')); + $hasExpectedMarker = false; + foreach ( $children as $itemChild ) { + if ( $this->isListMarkerTextChild($itemChild, $expected) ) { + $hasExpectedMarker = true; + break; + } + } + if ( ! $hasExpectedMarker ) { + return false; + } + ++$expected; + } + + return $expected > 2; + } + /** * @param array $node */ @@ -68,6 +295,168 @@ private function hasNoDeclaredDisplay(array $node): bool return empty($node['layout']['display'] ?? null); } + /** + * @param array> $children + */ + private function hasRichRepeatedContent(array $children): bool + { + foreach ( $children as $child ) { + if ( $this->textDescendantCount($child) >= 2 ) { + return true; + } + } + + return false; + } + + /** + * @param array> $children + */ + private function linkChildCount(array $children): int + { + $count = 0; + foreach ( $children as $child ) { + if ( $this->subtreeHasLink($child) ) { + $count++; + } + } + + return $count; + } + + /** + * @param array $node + */ + private function subtreeHasLink(array $node): bool + { + if ( ! empty($node['figma_link']) ) { + return true; + } + foreach ( $this->nodeList($node) as $child ) { + if ( is_array($child) && $this->subtreeHasLink($child) ) { + return true; + } + } + + return false; + } + + /** + * @param array $node + */ + private function subtreeHasText(array $node): bool + { + if ( 'TEXT' === strtoupper((string) ($node['type'] ?? '')) ) { + return true; + } + foreach ( $this->nodeList($node) as $child ) { + if ( is_array($child) && $this->subtreeHasText($child) ) { + return true; + } + } + + return false; + } + + /** + * @param array $node + */ + private function textDescendantCount(array $node): int + { + $count = 0; + foreach ( $this->nodeList($node) as $child ) { + if ( ! is_array($child) ) { + continue; + } + if ( 'TEXT' === strtoupper((string) ($child['type'] ?? '')) ) { + $count++; + } + $count += $this->textDescendantCount($child); + } + + return $count; + } + + /** + * @param array $node + */ + private function isListMarkerTextChild(array $node, ?int $expectedNumber = null): bool + { + if ( 'TEXT' !== strtoupper((string) ($node['type'] ?? '')) ) { + return false; + } + + $text = trim($this->subtreePlainText($node)); + if ( '' === $text ) { + return false; + } + + if ( null !== $expectedNumber ) { + return 1 === preg_match('/^' . preg_quote((string) $expectedNumber, '/') . '[.)]?$/', $text); + } + + return 1 === preg_match('/^\d+[.)]?$/', $text); + } + + /** + * @param array $node + */ + private function subtreePlainText(array $node): string + { + $parts = array(); + $text = $this->nodePlainText($node); + if ( '' !== $text ) { + $parts[] = $text; + } + foreach ( $this->nodeList($node) as $child ) { + if ( is_array($child) ) { + $childText = $this->subtreePlainText($child); + if ( '' !== $childText ) { + $parts[] = $childText; + } + } + } + + return trim(implode(' ', $parts)); + } + + /** + * @param array $node + */ + private function nodePlainText(array $node): string + { + foreach ( array('characters', 'text', 'content') as $key ) { + if ( isset($node[$key]) && is_scalar($node[$key]) ) { + return trim((string) $node[$key]); + } + } + if ( isset($node['textData']['characters']) && is_scalar($node['textData']['characters']) ) { + return trim((string) $node['textData']['characters']); + } + if ( isset($node['figma_text']['characters']) && is_scalar($node['figma_text']['characters']) ) { + return trim((string) $node['figma_text']['characters']); + } + + return ''; + } + + /** + * @param array $node + */ + private function boxValue(array $node, string $key): ?float + { + if ( isset($node[$key]) && is_numeric($node[$key]) ) { + return (float) $node[$key]; + } + + $box = is_array($node['box'] ?? null) ? $node['box'] : array(); + if ( isset($box[$key]) && is_numeric($box[$key]) ) { + return (float) $box[$key]; + } + + return null; + } + /** * @param array $node * @param array $children @@ -164,13 +553,20 @@ public function hasAbsoluteChild(array $node): bool */ public function overlappingSiblingZIndex(array $node, array $parentNode): ?int { - if ( ! $this->isFreeformContainer($parentNode) && ! $this->hasAbsoluteChild($parentNode) ) { - return null; - } + $stackPlan = $this->siblingLayerStackPlan($node, $parentNode); + return isset($stackPlan['z_index']) && is_int($stackPlan['z_index']) ? $stackPlan['z_index'] : null; + } + /** + * @param array $node + * @param array $parentNode + * @return array{role: string, overlaps_sibling: bool, z_index: int|null} + */ + public function siblingLayerStackPlan(array $node, array $parentNode): array + { $nodeId = (string) ($node['id'] ?? ''); if ( '' === $nodeId ) { - return null; + return array('role' => $this->siblingLayerRole($node, $parentNode), 'overlaps_sibling' => false, 'z_index' => null); } $siblings = array_values(array_filter($this->nodeList($parentNode), 'is_array')); @@ -189,12 +585,12 @@ public function overlappingSiblingZIndex(array $node, array $parentNode): ?int $stackedSiblings[] = array( 'id' => $siblingId, 'index' => $index, - 'key' => $this->nodePaintOrderKey($sibling, $index), + 'key' => $this->nodeSiblingStackKey($sibling, $parentNode, $index), ); } if ( ! $nodeOverlapsSibling ) { - return null; + return array('role' => $this->siblingLayerRole($node, $parentNode), 'overlaps_sibling' => false, 'z_index' => null); } usort( @@ -204,56 +600,280 @@ public function overlappingSiblingZIndex(array $node, array $parentNode): ?int foreach ( $stackedSiblings as $rank => $sibling ) { if ( $sibling['id'] === $nodeId ) { - return $rank + 1; + return array('role' => $this->siblingLayerRole($node, $parentNode), 'overlaps_sibling' => true, 'z_index' => $rank + 1); } } - return null; + return array('role' => $this->siblingLayerRole($node, $parentNode), 'overlaps_sibling' => true, 'z_index' => null); } /** * @param array $node + * @param array|null $parentNode + * @return array{manages_local_stacking: bool, needs_isolation: bool, local_reasons: array, sibling_role: string|null, overlaps_sibling: bool, z_index: int|null, z_index_reason: string|null} */ - public function isAbsoluteChild(array $node): bool + public function stackingContextPlan(array $node, ?array $parentNode = null): array { - return 'absolute' === ($node['layout']['positioning'] ?? null); + $localReasons = $this->localStackingReasons($node); + $isolationReasons = $this->localStackIsolationReasons($node); + $siblingStackPlan = null !== $parentNode ? $this->siblingLayerStackPlan($node, $parentNode) : array('role' => null, 'overlaps_sibling' => false, 'z_index' => null); + $isDecorativeUnderlay = null !== $parentNode && ($this->isDecorativeFlexUnderlay($node, $parentNode) || $this->isDecorativeTrackUnderlay($node, $parentNode)); + + $sourceZIndex = $this->nodeZIndex($node); + if ( 'reverse_child_order' === ($node['layout']['z_index_source'] ?? null) && true !== ($siblingStackPlan['overlaps_sibling'] ?? false) && (null === $parentNode || ! $this->hasNegativeAutoLayoutSpacing($parentNode)) ) { + $sourceZIndex = null; + } + + return $this->stackingContextPolicy()->plan($localReasons, $isolationReasons, $siblingStackPlan, $isDecorativeUnderlay, $sourceZIndex); + } + + private function stackingContextPolicy(): StackingContextPolicy + { + return $this->stackingContextPolicy ??= new StackingContextPolicy(); } /** * @param array $node + * @param array $parentNode */ - public function hasDecorativeFlexUnderlayChild(array $node): bool + public function siblingLayerRole(array $node, array $parentNode): string { - foreach ( $this->nodeList($node) as $child ) { - if ( is_array($child) && $this->isDecorativeFlexUnderlay($child, $node) ) { - return true; - } + if ( $this->hasProtrudingDecorativeUnderlay($node, $parentNode) || $this->isDecorativeFlexUnderlay($node, $parentNode) || $this->isDecorativeTrackUnderlay($node, $parentNode) ) { + return self::LAYER_ROLE_UNDERLAY; } - return false; + return $this->isTopChromeLayer($node, $parentNode) ? self::LAYER_ROLE_CHROME : self::LAYER_ROLE_CONTENT; } /** * @param array $node - * @param array $parentNode */ - public function isDecorativeFlexUnderlay(array $node, array $parentNode): bool + public function hasOverlappingStackedChild(array $node): bool { - $parentLayout = is_array($parentNode['layout'] ?? null) ? $parentNode['layout'] : array(); - if ( ! $this->parentSupportsDecorativeFlexUnderlay($parentLayout) ) { + $children = array_values(array_filter($this->nodeList($node), 'is_array')); + $count = count($children); + if ( $count < 2 ) { return false; } - if ( ! $this->hasDecorativeUnderlayForegroundEvidence($node, $parentNode) ) { - return false; + for ( $left = 0; $left < $count; $left++ ) { + for ( $right = $left + 1; $right < $count; $right++ ) { + if ( $this->nodesOverlapInParent($children[$left], $children[$right], $node) ) { + return true; + } + } } - return $this->isOversizedAgainstParent($node, $parentNode) || $this->isAbsoluteBackgroundBleed($node, $parentNode, $parentLayout); + return false; } /** - * @param array $layout - * @param array|null $parentNode + * @param array $node + */ + public function managesLocalStacking(array $node): bool + { + return ! empty($this->localStackingReasons($node)); + } + + /** + * @param array $node + */ + public function needsLocalStackIsolation(array $node): bool + { + return ! empty($this->localStackIsolationReasons($node)); + } + + /** + * @param array $node + * @return array + */ + private function localStackingReasons(array $node): array + { + $reasons = array(); + if ( $this->hasAbsoluteChild($node) ) { + $reasons[] = self::STACK_REASON_ABSOLUTE_CHILD; + } + if ( $this->hasDecorativeFlexUnderlayChild($node) ) { + $reasons[] = self::STACK_REASON_DECORATIVE_UNDERLAY; + } + if ( $this->isFreeformContainer($node) ) { + $reasons[] = self::STACK_REASON_FREEFORM_CONTAINER; + } + if ( $this->hasOverlappingStackedChild($node) ) { + $reasons[] = self::STACK_REASON_OVERLAPPING_STACKED_CHILD; + } + + return $reasons; + } + + /** + * @param array $node + * @return array + */ + private function localStackIsolationReasons(array $node): array + { + $reasons = array(); + if ( $this->hasDecorativeFlexUnderlayChild($node) ) { + $reasons[] = self::STACK_REASON_DECORATIVE_UNDERLAY; + } + if ( $this->hasMixedPositioningChildren($node) ) { + $reasons[] = self::STACK_REASON_MIXED_POSITIONING_CHILDREN; + } + if ( $this->hasZIndexedChild($node) ) { + $reasons[] = self::STACK_REASON_Z_INDEXED_CHILD; + } + if ( $this->hasOverlappingStackedChild($node) ) { + $reasons[] = self::STACK_REASON_OVERLAPPING_STACKED_CHILD; + } + + return $reasons; + } + + /** + * @param array $node + */ + public function isAbsoluteChild(array $node): bool + { + return 'absolute' === ($node['layout']['positioning'] ?? null); + } + + /** + * @param array $node + */ + private function hasMixedPositioningChildren(array $node): bool + { + $hasAbsolute = false; + $hasFlow = false; + foreach ( $this->nodeList($node) as $child ) { + if ( ! is_array($child) ) { + continue; + } + + if ( $this->isAbsoluteChild($child) ) { + $hasAbsolute = true; + } else { + $hasFlow = true; + } + + if ( $hasAbsolute && $hasFlow ) { + return true; + } + } + + return false; + } + + /** + * @param array $node + */ + private function hasZIndexedChild(array $node): bool + { + foreach ( $this->nodeList($node) as $child ) { + if ( ! is_array($child) ) { + continue; + } + + if ( null !== $this->nodeZIndex($child) ) { + return true; + } + } + + return false; + } + + /** + * @param array $node + */ + public function hasDecorativeFlexUnderlayChild(array $node): bool + { + foreach ( $this->nodeList($node) as $child ) { + if ( is_array($child) && $this->isDecorativeFlexUnderlay($child, $node) ) { + return true; + } + } + + return false; + } + + /** + * @param array $node + * @param array $parentNode + */ + public function isDecorativeFlexUnderlay(array $node, array $parentNode): bool + { + $parentLayout = is_array($parentNode['layout'] ?? null) ? $parentNode['layout'] : array(); + $isCompactControlUnderlay = $this->isCompactAbsoluteShapeUnderlay($node, $parentNode); + $isLargeVectorUnderlay = $this->isLargeDecorativeVectorUnderlay($node, $parentNode); + if ( ! $isCompactControlUnderlay && ! $isLargeVectorUnderlay && ! $this->parentSupportsDecorativeUnderlay($parentNode, $parentLayout) ) { + return false; + } + + if ( ! $isCompactControlUnderlay && ! $isLargeVectorUnderlay && ! $this->hasDecorativeUnderlayForegroundEvidence($node, $parentNode) ) { + return false; + } + + return $isCompactControlUnderlay + || $isLargeVectorUnderlay + || $this->isOversizedAgainstParent($node, $parentNode) + || $this->isAbsoluteBackgroundBleed($node, $parentNode, $parentLayout) + || $this->isCompactAbsoluteShapeUnderlay($node, $parentNode); + } + + /** + * @param array $node + * @param array $parentNode + */ + private function isDecorativeTrackUnderlay(array $node, array $parentNode): bool + { + if ( ! $this->isDecorativeUnderlayVisualCandidate($node) || ! $this->isThinTrackShape($node) ) { + return false; + } + + $nodeId = (string) ($node['id'] ?? ''); + foreach ( $this->nodeList($parentNode) as $sibling ) { + if ( ! is_array($sibling) || (string) ($sibling['id'] ?? '') === $nodeId ) { + continue; + } + if ( $this->isCompactVisualMarker($sibling) && $this->nodesOverlapInParent($node, $sibling, $parentNode) ) { + return true; + } + } + + return false; + } + + /** + * @param array $node + */ + private function isThinTrackShape(array $node): bool + { + $width = $this->boxValue($node, 'width'); + $height = $this->boxValue($node, 'height'); + if ( null === $width || null === $height || $width <= 0.0 || $height <= 0.0 ) { + return false; + } + + return min($width, $height) <= 24.0 && max($width, $height) >= min($width, $height) * 4.0; + } + + /** + * @param array $node + */ + private function isCompactVisualMarker(array $node): bool + { + $width = $this->boxValue($node, 'width'); + $height = $this->boxValue($node, 'height'); + if ( null === $width || null === $height || $width <= 0.0 || $height <= 0.0 || $width > 64.0 || $height > 64.0 ) { + return false; + } + + $ratio = max($width, $height) / min($width, $height); + return $ratio <= 2.0 && $this->isDecorativeUnderlayVisualCandidate($node); + } + + /** + * @param array $layout + * @param array|null $parentNode */ public function fillsParentFlexMainAxis(array $layout, ?array $parentNode): bool { @@ -315,9 +935,10 @@ public function isClippableDecorativeVisualNode(array $node): bool /** * @param array $layout */ - private function parentSupportsDecorativeFlexUnderlay(array $layout): bool + private function parentSupportsDecorativeUnderlay(array $parentNode, array $layout): bool { - return in_array((string) ($layout['display'] ?? ''), array('flex', 'inline-flex'), true); + return in_array((string) ($layout['display'] ?? ''), array('flex', 'inline-flex'), true) + || $this->isFreeformContainer($parentNode); } /** @@ -326,8 +947,12 @@ private function parentSupportsDecorativeFlexUnderlay(array $layout): bool */ private function hasDecorativeUnderlayForegroundEvidence(array $node, array $parentNode): bool { + $parentLayout = is_array($parentNode['layout'] ?? null) ? $parentNode['layout'] : array(); + $requiresTextOverlap = ! in_array((string) ($parentLayout['display'] ?? ''), array('flex', 'inline-flex'), true); + return $this->isDecorativeUnderlayVisualCandidate($node) && $this->parentHasTextOutsideNode($parentNode, $node) + && (! $requiresTextOverlap || $this->nodeOverlapsTextOutsideNode($parentNode, $node)) && $this->nodeIsBehindTextOutsideNode($parentNode, $node); } @@ -339,6 +964,33 @@ private function isDecorativeUnderlayVisualCandidate(array $node): bool return ! $this->treeHasText($node) && ! $this->treeHasImageReference($node) && $this->treeIsShapeOnlyPrimitiveVisual($node); } + /** + * @param array $node + * @param array $parentNode + */ + private function isLargeDecorativeVectorUnderlay(array $node, array $parentNode): bool + { + if ( $this->isDecorativeUnderlayVisualCandidate($parentNode) || ! $this->isDecorativeUnderlayVisualCandidate($node) || ! $this->isOversizedAgainstParent($node, $parentNode) ) { + return false; + } + + $name = strtolower((string) ($node['name'] ?? '')); + $parentName = strtolower((string) ($parentNode['name'] ?? '')); + if ( str_contains($name, 'logo') || str_contains($parentName, 'logo') || str_contains($parentName, 'social') ) { + return false; + } + + foreach ( array('background', 'bg', 'underlay', 'decorative', 'artwork', 'illustration') as $hint ) { + if ( str_contains($name, $hint) || str_contains($parentName, $hint) ) { + return true; + } + } + + return $this->parentHasTextOutsideNode($parentNode, $node) + && $this->nodeOverlapsTextOutsideNode($parentNode, $node) + && $this->nodeIsBehindTextOutsideNode($parentNode, $node); + } + /** * @param array $node * @param array $children @@ -364,56 +1016,395 @@ private function hasPositionedSourceChild(array $node, array $children): bool } } - return false; + return false; + } + + /** + * @param array $parentBox + * @param array $parentNode + */ + private function shouldInferRootCanvasOrigin(array $parentBox, array $parentNode, string $dimension): bool + { + if ( ! isset($parentBox[$dimension]) || ! is_numeric($parentBox[$dimension]) ) { + return false; + } + + if ( ! empty($parentNode['_parent_id']) ) { + return false; + } + + $origin = $this->inferredContainingBlockOrigin($parentNode, $dimension); + if ( null === $origin ) { + return false; + } + + $parentOrigin = (float) $parentBox[$dimension]; + if ( 0.0 === $parentOrigin ) { + return $origin < 0.0 || $this->hasRootCanvasOriginMismatch($parentBox, $parentNode); + } + + return ($origin < 0.0 && ($parentOrigin - $origin) >= 100.0) + || $this->hasRootCanvasOriginMismatch($parentBox, $parentNode); + } + + /** + * @param array $parentBox + * @param array $parentNode + */ + private function shouldInferMissingParentOrigin(array $parentBox, array $parentNode, string $dimension): bool + { + $origin = $this->inferredContainingBlockOrigin($parentNode, $dimension); + if ( null === $origin ) { + return false; + } + + foreach ( array('x' => 'width', 'y' => 'height') as $originDimension => $sizeKey ) { + $origin = $this->inferredContainingBlockOrigin($parentNode, $originDimension); + if ( null === $origin ) { + continue; + } + + $parentSize = isset($parentBox[$sizeKey]) && is_numeric($parentBox[$sizeKey]) ? (float) $parentBox[$sizeKey] : null; + if ( abs($origin) >= 1000.0 || (null !== $parentSize && $origin > $parentSize + 100.0) ) { + return true; + } + } + + return false; + } + + /** + * @param array $parentBox + * @param array $parentNode + */ + private function hasRootCanvasOriginMismatch(array $parentBox, array $parentNode): bool + { + foreach ( array('x', 'y') as $dimension ) { + $origin = $this->inferredContainingBlockOrigin($parentNode, $dimension); + if ( null === $origin || ! isset($parentBox[$dimension]) || ! is_numeric($parentBox[$dimension]) ) { + continue; + } + + $parentOrigin = (float) $parentBox[$dimension]; + $sizeKey = 'x' === $dimension ? 'width' : 'height'; + $parentSize = isset($parentBox[$sizeKey]) && is_numeric($parentBox[$sizeKey]) ? (float) $parentBox[$sizeKey] : null; + if ( abs($origin - $parentOrigin) >= 1000.0 || (null !== $parentSize && $origin > $parentOrigin + $parentSize + 100.0) ) { + return true; + } + } + + return false; + } + + /** + * @param array $parentNode + */ + private function inferredContainingBlockOrigin(array $parentNode, string $dimension): ?float + { + $preferredOrigin = null; + $fallbackOrigin = null; + foreach ( $this->nodeList($parentNode) as $child ) { + if ( ! is_array($child) ) { + continue; + } + + $childBox = is_array($child['box'] ?? null) ? $child['box'] : array(); + if ( 'local' === ($childBox['coordinate_space'] ?? null) || ! isset($childBox[$dimension]) || ! is_numeric($childBox[$dimension]) ) { + continue; + } + + $value = (float) $childBox[$dimension]; + $fallbackOrigin = null === $fallbackOrigin ? $value : min($fallbackOrigin, $value); + if ( $this->isContainingBlockOriginCandidate($child) ) { + $preferredOrigin = null === $preferredOrigin ? $value : min($preferredOrigin, $value); + } + } + + return $preferredOrigin ?? $fallbackOrigin; + } + + /** + * @param array $node + */ + private function isContainingBlockOriginCandidate(array $node): bool + { + return $this->treeHasText($node) || $this->isImageBackedLandmark($node) || ! $this->treeIsShapeOnlyPrimitiveVisual($node); + } + + /** + * @param array $node + */ + private function isImageBackedLandmark(array $node): bool + { + return $this->treeHasImageReference($node); + } + + /** + * @param array $parentNode + * @param array $node + */ + private function parentHasTextOutsideNode(array $parentNode, array $node): bool + { + $nodeId = (string) ($node['id'] ?? ''); + foreach ( $this->nodeList($parentNode) as $sibling ) { + if ( ! is_array($sibling) || (string) ($sibling['id'] ?? '') === $nodeId ) { + continue; + } + if ( $this->treeHasText($sibling) ) { + return true; + } + } + + return false; + } + + /** + * @param array $parentNode + * @param array $node + */ + private function nodeIsBehindTextOutsideNode(array $parentNode, array $node): bool + { + $nodeId = (string) ($node['id'] ?? ''); + $nodeZIndex = $this->nodeZIndex($node); + $siblings = $this->nodeList($parentNode); + $nodeSiblingIndex = $this->nodeSiblingIndex($siblings, $nodeId); + + foreach ( $siblings as $index => $sibling ) { + if ( ! is_array($sibling) || (string) ($sibling['id'] ?? '') === $nodeId || ! $this->treeHasText($sibling) ) { + continue; + } + + if ( $this->nodeHasPaintOrderEvidenceBehindSibling($node, $sibling, $nodeZIndex, $nodeSiblingIndex, (int) $index) ) { + return true; + } + } + + return false; + } + + /** + * @param array $parentNode + * @param array $node + */ + private function nodeOverlapsTextOutsideNode(array $parentNode, array $node): bool + { + $nodeId = (string) ($node['id'] ?? ''); + foreach ( $this->nodeList($parentNode) as $sibling ) { + if ( ! is_array($sibling) || (string) ($sibling['id'] ?? '') === $nodeId || ! $this->treeHasText($sibling) ) { + continue; + } + + if ( $this->nodesOverlapInParent($node, $sibling, $parentNode) ) { + return true; + } + } + + return false; + } + + /** + * @param array $siblings + */ + private function nodeSiblingIndex(array $siblings, string $nodeId): ?int + { + foreach ( $siblings as $index => $sibling ) { + if ( is_array($sibling) && (string) ($sibling['id'] ?? '') === $nodeId ) { + return (int) $index; + } + } + + return null; + } + + /** + * @param array $node + * @param array $sibling + */ + private function nodeHasPaintOrderEvidenceBehindSibling(array $node, array $sibling, ?int $nodeZIndex, ?int $nodeSiblingIndex, int $siblingIndex): bool + { + $siblingZIndex = $this->nodeZIndex($sibling); + if ( null !== $nodeZIndex && null !== $siblingZIndex ) { + return $nodeZIndex < $siblingZIndex; + } + + $nodeOrder = $this->nodeSourceOrder($node) ?? $nodeSiblingIndex; + $siblingOrder = $this->nodeSourceOrder($sibling) ?? $siblingIndex; + return null !== $nodeOrder && $nodeOrder < $siblingOrder; + } + + /** + * @param array $node + * @return array{0: int, 1: int|float|string, 2: int, 3: string} + */ + private function nodePaintOrderKey(array $node, int $fallbackIndex): array + { + $layout = is_array($node['layout'] ?? null) ? $node['layout'] : array(); + if ( isset($layout['layer_order']) && is_scalar($layout['layer_order']) ) { + $layerOrder = (string) $layout['layer_order']; + return array(0, is_numeric($layerOrder) ? (float) $layerOrder : $layerOrder, $fallbackIndex, (string) ($node['id'] ?? '')); + } + + $sourceOrder = $this->nodeSourceOrder($node); + return array(1, null === $sourceOrder ? $fallbackIndex : $sourceOrder, $fallbackIndex, (string) ($node['id'] ?? '')); + } + + /** + * @param array $node + * @param array $parentNode + * @return array + */ + private function nodeSiblingStackKey(array $node, array $parentNode, int $fallbackIndex): array + { + return array_merge( + array($this->siblingLayerRoleRank($node, $parentNode)), + $this->nodePaintOrderKey($node, $fallbackIndex) + ); + } + + /** + * @param array $node + * @param array $parentNode + */ + private function siblingLayerRoleRank(array $node, array $parentNode): int + { + $role = $this->siblingLayerRole($node, $parentNode); + if ( self::LAYER_ROLE_CONTENT === $role && $this->isHeroMediaLayerOverTopChromeUnderlay($node, $parentNode) ) { + return 3; + } + if ( self::LAYER_ROLE_CONTENT === $role && $this->isTopChromePrimitiveVisual($node, $parentNode) ) { + return 2; + } + + return match ( $role ) { + self::LAYER_ROLE_UNDERLAY => 0, + self::LAYER_ROLE_CHROME => 2, + default => 1, + }; + } + + /** + * @param array $node + * @param array $parentNode + */ + private function isHeroMediaLayerOverTopChromeUnderlay(array $node, array $parentNode): bool + { + if ( $this->treeHasText($node) || ! $this->treeHasImageReference($node) ) { + return false; + } + + $rect = $this->nodeVisualRectInParent($node, $parentNode); + if ( null === $rect || $rect['height'] < 160.0 ) { + return false; + } + + $nodeId = (string) ($node['id'] ?? ''); + foreach ( $this->nodeList($parentNode) as $sibling ) { + if ( ! is_array($sibling) || (string) ($sibling['id'] ?? '') === $nodeId ) { + continue; + } + if ( ! $this->nodesOverlapInParent($node, $sibling, $parentNode) ) { + continue; + } + if ( $this->isTopChromePrimitiveVisual($sibling, $parentNode) || self::LAYER_ROLE_UNDERLAY === $this->siblingLayerRole($sibling, $parentNode) ) { + return true; + } + } + + return false; + } + + /** + * @param array $node + * @param array $parentNode + */ + private function isTopChromePrimitiveVisual(array $node, array $parentNode): bool + { + $type = strtoupper((string) ($node['type'] ?? '')); + if ( ! in_array($type, self::PRIMITIVE_VECTOR_SHAPE_TYPES, true) ) { + return false; + } + + $rect = $this->nodeRectInParent($node, $parentNode); + if ( null === $rect || $rect['y'] < -0.5 ) { + return false; + } + + $parentBox = is_array($parentNode['box'] ?? null) ? $parentNode['box'] : array(); + $parentHeight = isset($parentBox['height']) && is_numeric($parentBox['height']) ? (float) $parentBox['height'] : null; + $topChromeLimit = null === $parentHeight ? 48.0 : max(48.0, min(160.0, $parentHeight * 0.05)); + if ( $rect['y'] > $topChromeLimit ) { + return false; + } + + return $rect['height'] <= max(160.0, null === $parentHeight ? 0.0 : $parentHeight * 0.25); } /** - * @param array $parentBox + * @param array $node * @param array $parentNode */ - private function shouldInferRootCanvasOrigin(array $parentBox, array $parentNode, string $dimension): bool + private function isTopChromeLayer(array $node, array $parentNode): bool { - if ( ! isset($parentBox[$dimension]) || ! is_numeric($parentBox[$dimension]) ) { + $role = $this->chromeGroupRole($node, $parentNode, 1); + $name = strtolower(trim((string) ($node['name'] ?? ''))); + if ( ! in_array($role, array(self::CHROME_GROUP_ROLE_HEADER, self::CHROME_GROUP_ROLE_NAVIGATION), true) && ! str_contains($name, 'header') ) { return false; } - if ( ! empty($parentNode['_parent_id']) ) { + $type = strtoupper((string) ($node['type'] ?? '')); + if ( ! in_array($type, self::FREEFORM_CONTAINER_TYPES, true) ) { return false; } - $origin = $this->inferredContainingBlockOrigin($parentNode, $dimension); - if ( null === $origin ) { + $rect = $this->nodeRectInParent($node, $parentNode); + if ( null === $rect || $rect['y'] < -0.5 ) { return false; } - $parentOrigin = (float) $parentBox[$dimension]; - if ( 0.0 === $parentOrigin ) { - return $origin < 0.0 || $this->hasRootCanvasOriginMismatch($parentBox, $parentNode); + $parentBox = is_array($parentNode['box'] ?? null) ? $parentNode['box'] : array(); + $parentHeight = isset($parentBox['height']) && is_numeric($parentBox['height']) ? (float) $parentBox['height'] : null; + $topChromeLimit = null === $parentHeight ? 48.0 : max(48.0, min(160.0, $parentHeight * 0.05)); + if ( $rect['y'] > $topChromeLimit ) { + return false; } - return ($origin < 0.0 && ($parentOrigin - $origin) >= 100.0) - || $this->hasRootCanvasOriginMismatch($parentBox, $parentNode); + return $rect['height'] <= max(160.0, null === $parentHeight ? 0.0 : $parentHeight * 0.25); } /** - * @param array $parentBox - * @param array $parentNode + * @param array $node + * @param array> $children + * @param array|null $parentNode */ - private function shouldInferMissingParentOrigin(array $parentBox, array $parentNode, string $dimension): bool + private function isHeaderChromeCandidate(array $node, array $children, int $depth, ?array $parentNode): bool { - $origin = $this->inferredContainingBlockOrigin($parentNode, $dimension); - if ( null === $origin ) { + if ( null === $parentNode ) { return false; } - foreach ( array('x' => 'width', 'y' => 'height') as $originDimension => $sizeKey ) { - $origin = $this->inferredContainingBlockOrigin($parentNode, $originDimension); - if ( null === $origin ) { - continue; + $name = strtolower((string) ($node['name'] ?? '')); + if ( str_contains($name, 'header') && ($this->hasLogoChild($children) || $this->linkChildCount($children) >= 1 || $this->hasNavigationTextRun(strtolower($this->subtreePlainText($node))) || $this->hasCtaChild($children)) ) { + $rect = $this->nodeRectInParent($node, $parentNode); + if ( null !== $rect ) { + $parentHeight = $this->boxValue($parentNode, 'height'); + $topChromeLimit = null === $parentHeight ? 160.0 : max(160.0, $parentHeight * 0.05); + if ( $rect['y'] >= -0.5 && $rect['y'] <= $topChromeLimit && $rect['height'] <= max(240.0, null === $parentHeight ? 0.0 : $parentHeight * 0.25) ) { + return true; + } } + } - $parentSize = isset($parentBox[$sizeKey]) && is_numeric($parentBox[$sizeKey]) ? (float) $parentBox[$sizeKey] : null; - if ( abs($origin) >= 1000.0 || (null !== $parentSize && $origin > $parentSize + 100.0) ) { + return 'top' === $this->verticalRegion($node, $parentNode) + && ($this->hasLogoChild($children) || $this->linkChildCount($children) >= 1 || $depth <= 1); + } + + /** + * @param array> $children + */ + private function hasCtaChild(array $children): bool + { + foreach ( $children as $child ) { + if ( $this->isCtaGroup($child, array_values(array_filter($this->nodeList($child), 'is_array'))) ) { return true; } } @@ -422,107 +1413,132 @@ private function shouldInferMissingParentOrigin(array $parentBox, array $parentN } /** - * @param array $parentBox - * @param array $parentNode + * @param array $node + * @param array|null $parentNode */ - private function hasRootCanvasOriginMismatch(array $parentBox, array $parentNode): bool + private function isFooterChromeCandidate(array $node, int $depth, ?array $parentNode): bool { - foreach ( array('x', 'y') as $dimension ) { - $origin = $this->inferredContainingBlockOrigin($parentNode, $dimension); - if ( null === $origin || ! isset($parentBox[$dimension]) || ! is_numeric($parentBox[$dimension]) ) { - continue; - } - - $parentOrigin = (float) $parentBox[$dimension]; - $sizeKey = 'x' === $dimension ? 'width' : 'height'; - $parentSize = isset($parentBox[$sizeKey]) && is_numeric($parentBox[$sizeKey]) ? (float) $parentBox[$sizeKey] : null; - if ( abs($origin - $parentOrigin) >= 1000.0 || (null !== $parentSize && $origin > $parentOrigin + $parentSize + 100.0) ) { - return true; - } + if ( null !== $parentNode && ( 'bottom' === $this->verticalRegion($node, $parentNode) || $this->hasLegalText($node) || $depth <= 1 ) ) { + return true; } - return false; + $text = strtolower($this->subtreePlainText($node)); + return str_contains($text, 'copyright') + || str_contains($text, 'all rights reserved') + || (str_contains($text, 'contact') && str_contains($text, 'location')) + || ($this->hasNavigationTextRun($text) && $this->textDescendantCount($node) <= 8); } /** - * @param array $parentNode + * @param array> $children */ - private function inferredContainingBlockOrigin(array $parentNode, string $dimension): ?float + private function isNavigationContainer(array $children): bool { - $preferredOrigin = null; - $fallbackOrigin = null; - foreach ( $this->nodeList($parentNode) as $child ) { - if ( ! is_array($child) ) { - continue; - } + if ( empty($children) ) { + return false; + } - $childBox = is_array($child['box'] ?? null) ? $child['box'] : array(); - if ( 'local' === ($childBox['coordinate_space'] ?? null) || ! isset($childBox[$dimension]) || ! is_numeric($childBox[$dimension]) ) { - continue; - } + $linkCount = $this->linkChildCount($children); + if ( $linkCount >= 2 && $linkCount === count($children) ) { + return true; + } - $value = (float) $childBox[$dimension]; - $fallbackOrigin = null === $fallbackOrigin ? $value : min($fallbackOrigin, $value); - if ( $this->isContainingBlockOriginCandidate($child) ) { - $preferredOrigin = null === $preferredOrigin ? $value : min($preferredOrigin, $value); + $textCount = 0; + foreach ( $children as $child ) { + if ( 'TEXT' === strtoupper((string) ($child['type'] ?? '')) || $this->isMenuItemName(strtolower((string) ($child['name'] ?? ''))) ) { + $textCount++; } } - return $preferredOrigin ?? $fallbackOrigin; + return $textCount >= 2 && $textCount === count($children); } - /** - * @param array $node - */ - private function isContainingBlockOriginCandidate(array $node): bool + private function hasNavigationTextRun(string $text): bool { - return $this->treeHasText($node) || $this->isImageBackedLandmark($node) || ! $this->treeIsShapeOnlyPrimitiveVisual($node); + $matches = preg_match_all('/\b(home|about|services|reviews|faq|contact|news|blog|appointments?|handouts?)\b/', $text); + return false !== $matches && $matches >= 3; + } + + private function isMenuItemName(string $lowerName): bool + { + return 1 === preg_match('/\b(menu|nav(?:igation)?)\s*item\b|\bitem\s*(menu|nav(?:igation)?)\b/', $lowerName); } /** - * @param array $node + * @param array $node + * @param array> $children */ - private function isImageBackedLandmark(array $node): bool + private function isSocialIconCluster(array $node, array $children): bool { - return $this->treeHasImageReference($node); + if ( count($children) < 2 ) { + return false; + } + + $nameAndText = strtolower((string) ($node['name'] ?? '') . ' ' . $this->subtreePlainText($node)); + $hasSocialSignal = 1 === preg_match('/\b(social|facebook|instagram|linkedin|twitter|x social|x\.com|youtube|tiktok|pinterest)\b/', $nameAndText); + if ( ! $hasSocialSignal ) { + return false; + } + + $compactVisualCount = 0; + foreach ( $children as $child ) { + if ( $this->subtreeHasLink($child) || $this->isCompactVisualOnlyNode($child) ) { + $compactVisualCount++; + } + } + + return $compactVisualCount === count($children); } /** - * @param array $parentNode - * @param array $node + * @param array $node + * @param array> $children */ - private function parentHasTextOutsideNode(array $parentNode, array $node): bool + private function isCtaGroup(array $node, array $children): bool { - $nodeId = (string) ($node['id'] ?? ''); - foreach ( $this->nodeList($parentNode) as $sibling ) { - if ( ! is_array($sibling) || (string) ($sibling['id'] ?? '') === $nodeId ) { - continue; - } - if ( $this->treeHasText($sibling) ) { - return true; - } + $nameAndText = strtolower((string) ($node['name'] ?? '') . ' ' . $this->subtreePlainText($node)); + if ( 1 !== preg_match('/\b(cta|call to action|book now|get started|sign up|subscribe|contact us|learn more)\b/', $nameAndText) ) { + return false; } - return false; + if ( count($children) > 0 && count($children) <= 3 ) { + return true; + } + + $width = $this->boxValue($node, 'width'); + $height = $this->boxValue($node, 'height'); + return null !== $width && null !== $height && $width <= 640.0 && $height <= 220.0 && $this->textDescendantCount($node) <= 3; } /** - * @param array $parentNode * @param array $node */ - private function nodeIsBehindTextOutsideNode(array $parentNode, array $node): bool + private function isCompactVisualOnlyNode(array $node): bool { - $nodeId = (string) ($node['id'] ?? ''); - $nodeZIndex = $this->nodeZIndex($node); - $siblings = $this->nodeList($parentNode); - $nodeSiblingIndex = $this->nodeSiblingIndex($siblings, $nodeId); + if ( $this->treeHasText($node) ) { + return false; + } - foreach ( $siblings as $index => $sibling ) { - if ( ! is_array($sibling) || (string) ($sibling['id'] ?? '') === $nodeId || ! $this->treeHasText($sibling) ) { - continue; - } + $width = $this->boxValue($node, 'width'); + $height = $this->boxValue($node, 'height'); + if ( null !== $width && $width > 96.0 ) { + return false; + } + if ( null !== $height && $height > 96.0 ) { + return false; + } - if ( $this->nodeHasPaintOrderEvidenceBehindSibling($node, $sibling, $nodeZIndex, $nodeSiblingIndex, (int) $index) ) { + return $this->treeIsShapeOnlyPrimitiveVisual($node) || $this->treeHasImageReference($node); + } + + /** + * @param array> $children + */ + private function hasLogoChild(array $children): bool + { + foreach ( $children as $child ) { + $name = strtolower((string) ($child['name'] ?? '')); + if ( str_contains($name, 'logo') || str_contains($name, 'brand') ) { return true; } } @@ -531,49 +1547,86 @@ private function nodeIsBehindTextOutsideNode(array $parentNode, array $node): bo } /** - * @param array $siblings + * @param array $node */ - private function nodeSiblingIndex(array $siblings, string $nodeId): ?int + private function hasLegalText(array $node): bool { - foreach ( $siblings as $index => $sibling ) { - if ( is_array($sibling) && (string) ($sibling['id'] ?? '') === $nodeId ) { - return (int) $index; - } - } - - return null; + $text = strtolower($this->subtreePlainText($node)); + return str_contains($text, '©') || str_contains($text, 'copyright') || str_contains($text, 'rights reserved'); } /** * @param array $node - * @param array $sibling + * @param array $parentNode */ - private function nodeHasPaintOrderEvidenceBehindSibling(array $node, array $sibling, ?int $nodeZIndex, ?int $nodeSiblingIndex, int $siblingIndex): bool + private function verticalRegion(array $node, array $parentNode): ?string { - $siblingZIndex = $this->nodeZIndex($sibling); - if ( null !== $nodeZIndex && null !== $siblingZIndex ) { - return $nodeZIndex < $siblingZIndex; + $siblings = array_values(array_filter($this->nodeList($parentNode), 'is_array')); + if ( 2 > count($siblings) ) { + return 'middle'; + } + + $thisId = (string) ($node['id'] ?? ''); + $positions = array(); + $haveAll = true; + foreach ( $siblings as $sibling ) { + $y = $this->boxValue($sibling, 'y'); + if ( null === $y ) { + $haveAll = false; + break; + } + $positions[(string) ($sibling['id'] ?? '')] = $y; } - $nodeOrder = $this->nodeSourceOrder($node) ?? $nodeSiblingIndex; - $siblingOrder = $this->nodeSourceOrder($sibling) ?? $siblingIndex; - return null !== $nodeOrder && $nodeOrder < $siblingOrder; + if ( $haveAll && isset($positions[$thisId]) ) { + $y = $positions[$thisId]; + if ( $y <= min($positions) ) { + return 'top'; + } + if ( $y >= max($positions) ) { + return 'bottom'; + } + + return 'middle'; + } + + $firstId = (string) ($siblings[0]['id'] ?? ''); + $lastId = (string) ($siblings[count($siblings) - 1]['id'] ?? ''); + if ( $thisId === $firstId ) { + return 'top'; + } + if ( $thisId === $lastId ) { + return 'bottom'; + } + + return 'middle'; } /** * @param array $node - * @return array{0: int, 1: int|float|string, 2: int, 3: string} + * @param array $parentNode */ - private function nodePaintOrderKey(array $node, int $fallbackIndex): array + private function hasProtrudingDecorativeUnderlay(array $node, array $parentNode): bool { - $layout = is_array($node['layout'] ?? null) ? $node['layout'] : array(); - if ( isset($layout['layer_order']) && is_scalar($layout['layer_order']) ) { - $layerOrder = (string) $layout['layer_order']; - return array(0, is_numeric($layerOrder) ? (float) $layerOrder : $layerOrder, $fallbackIndex, (string) ($node['id'] ?? '')); + $nodeRect = $this->nodeRectInParent($node, $parentNode); + if ( null === $nodeRect ) { + return false; } - $sourceOrder = $this->nodeSourceOrder($node); - return array(1, null === $sourceOrder ? $fallbackIndex : $sourceOrder, $fallbackIndex, (string) ($node['id'] ?? '')); + foreach ( $this->nodeList($node) as $child ) { + if ( ! is_array($child) || ! $this->isDecorativeFlexUnderlay($child, $node) ) { + continue; + } + $childRect = $this->nodeRectInParent($child, $node); + if ( null === $childRect ) { + continue; + } + if ( $childRect['x'] < -0.5 || $childRect['y'] < -0.5 || $childRect['x'] + $childRect['width'] > $nodeRect['width'] + 0.5 || $childRect['y'] + $childRect['height'] > $nodeRect['height'] + 0.5 ) { + return true; + } + } + + return false; } /** @@ -583,8 +1636,8 @@ private function nodePaintOrderKey(array $node, int $fallbackIndex): array */ private function nodesOverlapInParent(array $node, array $sibling, array $parentNode): bool { - $nodeRect = $this->nodeRectInParent($node, $parentNode); - $siblingRect = $this->nodeRectInParent($sibling, $parentNode); + $nodeRect = $this->nodeVisualRectInParent($node, $parentNode); + $siblingRect = $this->nodeVisualRectInParent($sibling, $parentNode); if ( null === $nodeRect || null === $siblingRect ) { return false; } @@ -619,6 +1672,53 @@ private function nodeRectInParent(array $node, array $parentNode): ?array return array('x' => $x, 'y' => $y, 'width' => (float) $box['width'], 'height' => (float) $box['height']); } + /** + * @param array $node + * @param array $parentNode + * @return array{x: float, y: float, width: float, height: float}|null + */ + private function nodeVisualRectInParent(array $node, array $parentNode): ?array + { + $rect = $this->nodeRectInParent($node, $parentNode); + if ( null === $rect ) { + return null; + } + $baseRect = $rect; + + foreach ( $this->nodeList($node) as $child ) { + if ( ! is_array($child) ) { + continue; + } + $childRect = $this->nodeVisualRectInParent($child, $node); + if ( null === $childRect ) { + continue; + } + $rect = $this->unionRects($rect, array( + 'x' => $baseRect['x'] + $childRect['x'], + 'y' => $baseRect['y'] + $childRect['y'], + 'width' => $childRect['width'], + 'height' => $childRect['height'], + )); + } + + return $rect; + } + + /** + * @param array{x: float, y: float, width: float, height: float} $left + * @param array{x: float, y: float, width: float, height: float} $right + * @return array{x: float, y: float, width: float, height: float} + */ + private function unionRects(array $left, array $right): array + { + $x1 = min($left['x'], $right['x']); + $y1 = min($left['y'], $right['y']); + $x2 = max($left['x'] + $left['width'], $right['x'] + $right['width']); + $y2 = max($left['y'] + $left['height'], $right['y'] + $right['height']); + + return array('x' => $x1, 'y' => $y1, 'width' => $x2 - $x1, 'height' => $y2 - $y1); + } + /** * @param array $node */ @@ -627,6 +1727,14 @@ private function nodeZIndex(array $node): ?int return isset($node['layout']['z_index']) && is_numeric($node['layout']['z_index']) ? (int) $node['layout']['z_index'] : null; } + /** + * @param array $node + */ + private function hasNegativeAutoLayoutSpacing(array $node): bool + { + return isset($node['layout']['item_spacing']) && is_numeric($node['layout']['item_spacing']) && (float) $node['layout']['item_spacing'] < 0.0; + } + /** * @param array $node */ @@ -701,6 +1809,39 @@ private function isAbsoluteBackgroundBleed(array $node, array $parentNode, array return 1.0 <= ($crossSize / $parentCrossSize) || ($crossOffset <= 0.0 && $crossOffset + $crossSize >= $parentCrossSize); } + /** + * @param array $node + * @param array $parentNode + */ + private function isCompactAbsoluteShapeUnderlay(array $node, array $parentNode): bool + { + if ( 'absolute' !== ($node['layout']['positioning'] ?? null) ) { + return false; + } + + $box = is_array($node['box'] ?? null) ? $node['box'] : array(); + $parentBox = is_array($parentNode['box'] ?? null) ? $parentNode['box'] : array(); + foreach ( array('width', 'height') as $dimension ) { + if ( ! isset($box[$dimension], $parentBox[$dimension]) || ! is_numeric($box[$dimension]) || ! is_numeric($parentBox[$dimension]) || 0.0 >= (float) $parentBox[$dimension] ) { + return false; + } + } + + $parentArea = (float) $parentBox['width'] * (float) $parentBox['height']; + $nodeArea = (float) $box['width'] * (float) $box['height']; + if ( $parentArea > 9216.0 || ($nodeArea / $parentArea) < 0.45 ) { + return false; + } + + foreach ( $this->nodeList($parentNode) as $sibling ) { + if ( is_array($sibling) && (string) ($sibling['id'] ?? '') !== (string) ($node['id'] ?? '') && $this->treeHasText($sibling) && $this->nodesOverlapInParent($node, $sibling, $parentNode) ) { + return true; + } + } + + return false; + } + /** * @param array $node */ @@ -812,7 +1953,7 @@ private function textContent(array $node): string private function isUnresolvedComponentPlaceholderText(array $node, string $characters): bool { $placeholder = strtolower(trim($characters)); - if ( ! in_array($placeholder, array('button label'), true) ) { + if ( ! in_array($placeholder, array('button label', 'label'), true) ) { return false; } @@ -896,25 +2037,7 @@ private function explicitNodeAssetReferences(array $node): array */ private function nodeImagePaints(array $node): array { - $imagePaints = array(); - foreach ( self::PAINT_COLLECTION_KEYS as $paintKey ) { - $paintCollections = array(); - if ( is_array($node[$paintKey] ?? null) ) { - $paintCollections[] = $node[$paintKey]; - } - if ( is_array($node['figma_paints'][$paintKey] ?? null) ) { - $paintCollections[] = $node['figma_paints'][$paintKey]; - } - foreach ( $paintCollections as $paints ) { - foreach ( $paints as $paint ) { - if ( is_array($paint) && 'IMAGE' === strtoupper((string) ($paint['type'] ?? '')) ) { - $imagePaints[] = $paint; - } - } - } - } - - return $imagePaints; + return VisualLayerEvidence::imagePaints($node); } /** diff --git a/figma-transformer/src/Html/LocalBorderShellClusterResolver.php b/figma-transformer/src/Html/LocalBorderShellClusterResolver.php new file mode 100644 index 00000000..0ec35119 --- /dev/null +++ b/figma-transformer/src/Html/LocalBorderShellClusterResolver.php @@ -0,0 +1,304 @@ + $parent + * @param array $children + * @return array{by_first_child_id: array>, member_ids: array} + */ + public function resolve(array $parent, array $children): array + { + $nodes = array_values(array_filter($children, 'is_array')); + if ( count($nodes) < 2 || ! $this->parentCanHostLocalClusters($parent) ) { + return array('by_first_child_id' => array(), 'member_ids' => array()); + } + + $usedIds = array(); + $clusters = array(); + foreach ( $nodes as $index => $shell ) { + $shellId = $this->nodeId($shell); + if ( '' === $shellId || isset($usedIds[$shellId]) || ! $this->isBorderShell($shell) ) { + continue; + } + + $shellBox = $this->nodeBox($shell); + if ( null === $shellBox ) { + continue; + } + + $members = array(array('index' => $index, 'node' => $shell)); + $hasText = false; + $hasVisual = false; + foreach ( $nodes as $candidateIndex => $candidate ) { + $candidateId = $this->nodeId($candidate); + if ( '' === $candidateId || $candidateId === $shellId || isset($usedIds[$candidateId]) ) { + continue; + } + if ( ! $this->nodeCenterIsInside($candidate, $shellBox) ) { + continue; + } + + $members[] = array('index' => $candidateIndex, 'node' => $candidate); + $type = strtoupper((string) ($candidate['type'] ?? '')); + $hasText = $hasText || 'TEXT' === $type || $this->subtreeHasText($candidate); + $hasVisual = $hasVisual || null !== $this->nodeAssetPath($candidate) || $this->hasRenderableVector($candidate); + } + + $memberNodes = array_map(static fn (array $member): array => $member['node'], $members); + $compactTextCard = $this->shellLooksLikeCompactTextCard($shellBox, $memberNodes); + if ( ! $hasText || (count($members) < 3 && ! $compactTextCard) ) { + continue; + } + if ( ! $hasVisual && ! $compactTextCard ) { + continue; + } + + usort($members, static fn (array $left, array $right): int => (int) $left['index'] <=> (int) $right['index']); + $memberNodes = array_map(static fn (array $member): array => $member['node'], $members); + $memberIds = array_values(array_filter(array_map(fn (array $member): string => $this->nodeId($member), $memberNodes))); + foreach ( $memberIds as $memberId ) { + $usedIds[$memberId] = true; + } + + $localMembers = array_map(fn (array $member): array => $this->withLocalBox($member, $shellBox), $memberNodes); + $clusters[] = array( + 'first_child_id' => $memberIds[0], + 'node' => $this->clusterNode($parent, $shell, $localMembers, $shellBox, $memberIds), + 'member_ids' => $memberIds, + ); + } + + $byFirstChildId = array(); + $memberIds = array(); + foreach ( $clusters as $cluster ) { + $byFirstChildId[$cluster['first_child_id']] = $cluster['node']; + foreach ( $cluster['member_ids'] as $memberId ) { + $memberIds[$memberId] = 'local_border_shell_cluster_member'; + } + } + + return array('by_first_child_id' => $byFirstChildId, 'member_ids' => $memberIds); + } + + /** @param array $parent */ + private function parentCanHostLocalClusters(array $parent): bool + { + if ( is_array($parent['_figma_synthetic_local_cluster'] ?? null) ) { + return false; + } + + $type = strtoupper((string) ($parent['type'] ?? '')); + return in_array($type, array('FRAME', 'GROUP', 'COMPONENT', 'INSTANCE', 'SECTION'), true); + } + + /** @param array $node */ + private function isBorderShell(array $node): bool + { + $type = strtoupper((string) ($node['type'] ?? '')); + if ( ! in_array($type, array('RECTANGLE', 'ROUNDED_RECTANGLE', 'VECTOR', 'BOOLEAN_OPERATION'), true) ) { + return false; + } + if ( $this->subtreeHasText($node) || null !== $this->nodeAssetPath($node) ) { + return false; + } + + $paints = is_array($node['figma_paints'] ?? null) ? $node['figma_paints'] : array(); + $strokes = is_array($paints['strokes'] ?? null) ? $paints['strokes'] : array(); + if ( empty($strokes) ) { + return false; + } + $fills = is_array($paints['fills'] ?? null) ? $paints['fills'] : array(); + foreach ( $fills as $fill ) { + if ( is_array($fill) && strtoupper((string) ($fill['type'] ?? '')) !== 'NONE' && (float) ($fill['opacity'] ?? 1.0) > 0.01 ) { + return false; + } + } + + $box = $this->nodeBox($node); + return null !== $box && $box['width'] >= 120.0 && $box['height'] >= 80.0; + } + + /** + * @param array{x: float, y: float, width: float, height: float} $shellBox + * @param array> $members + */ + private function shellLooksLikeCompactTextCard(array $shellBox, array $members): bool + { + $textCount = 0; + foreach ( $members as $member ) { + if ( 'TEXT' === strtoupper((string) ($member['type'] ?? '')) || $this->subtreeHasText($member) ) { + ++$textCount; + } + } + + return $textCount >= 1 && $shellBox['width'] <= 480.0 && $shellBox['height'] <= 260.0; + } + + /** + * @param array $parent + * @param array $shell + * @param array> $members + * @param array{x: float, y: float, width: float, height: float} $shellBox + * @param array $memberIds + * @return array + */ + private function clusterNode(array $parent, array $shell, array $members, array $shellBox, array $memberIds): array + { + $shellId = $this->nodeId($shell); + $parentId = $this->nodeId($parent); + return array( + 'id' => ('' !== $parentId ? $parentId . '/' : '') . 'local-cluster-' . $shellId, + 'type' => 'GROUP', + 'name' => 'Local border shell cluster', + 'box' => array( + 'x' => $shellBox['x'], + 'y' => $shellBox['y'], + 'width' => $shellBox['width'], + 'height' => $shellBox['height'], + ), + 'layout' => array('freeform' => true), + 'children' => $members, + '_figma_synthetic_local_cluster' => array( + 'reason_code' => 'local_border_shell_cluster', + 'shell_id' => $shellId, + 'member_ids' => $memberIds, + ), + ); + } + + /** + * @param array $node + * @param array{x: float, y: float, width: float, height: float} $shellBox + * @return array + */ + private function withLocalBox(array $node, array $shellBox): array + { + $box = $this->nodeBox($node); + if ( null === $box ) { + return $node; + } + + $localBox = array( + 'x' => $box['x'] - $shellBox['x'], + 'y' => $box['y'] - $shellBox['y'], + 'width' => $box['width'], + 'height' => $box['height'], + 'coordinate_space' => 'local', + ); + $node['box'] = $localBox; + $node['x'] = $localBox['x']; + $node['y'] = $localBox['y']; + $node['width'] = $localBox['width']; + $node['height'] = $localBox['height']; + + return $node; + } + + /** + * @param array $node + * @param array{x: float, y: float, width: float, height: float} $shellBox + */ + private function nodeCenterIsInside(array $node, array $shellBox): bool + { + $box = $this->nodeBox($node); + if ( null === $box ) { + return false; + } + if ( $box['width'] <= 0.0 || $box['height'] <= 0.0 ) { + return false; + } + + $centerX = $box['x'] + ($box['width'] / 2.0); + $centerY = $box['y'] + ($box['height'] / 2.0); + return $centerX >= $shellBox['x'] - 1.5 + && $centerY >= $shellBox['y'] - 1.5 + && $centerX <= $shellBox['x'] + $shellBox['width'] + 1.5 + && $centerY <= $shellBox['y'] + $shellBox['height'] + 1.5; + } + + /** + * @param array $node + * @return array{x: float, y: float, width: float, height: float}|null + */ + private function nodeBox(array $node): ?array + { + $box = is_array($node['box'] ?? null) ? $node['box'] : array(); + foreach ( array('x', 'y', 'width', 'height') as $key ) { + if ( ! isset($box[$key]) || ! is_numeric($box[$key]) ) { + return null; + } + } + return array('x' => (float) $box['x'], 'y' => (float) $box['y'], 'width' => (float) $box['width'], 'height' => (float) $box['height']); + } + + /** @param array $node */ + private function nodeId(array $node): string + { + return isset($node['id']) && is_scalar($node['id']) ? (string) $node['id'] : ''; + } + + /** @param array $node */ + private function nodeAssetPath(array $node): ?string + { + foreach ( array('asset_path', '_figma_asset_path') as $key ) { + if ( isset($node[$key]) && is_scalar($node[$key]) && '' !== (string) $node[$key] ) { + return (string) $node[$key]; + } + } + $paints = is_array($node['figma_paints'] ?? null) ? $node['figma_paints'] : array(); + $fills = is_array($paints['fills'] ?? null) ? $paints['fills'] : array(); + foreach ( $fills as $paint ) { + if ( is_array($paint) && 'IMAGE' === strtoupper((string) ($paint['type'] ?? '')) ) { + return 'image-paint'; + } + } + + return null; + } + + /** @param array $node */ + private function hasRenderableVector(array $node): bool + { + $type = strtoupper((string) ($node['type'] ?? '')); + return in_array($type, array('VECTOR', 'BOOLEAN_OPERATION', 'LINE', 'ELLIPSE', 'STAR', 'POLYGON', 'REGULAR_POLYGON'), true); + } + + /** @param array $node */ + private function subtreeHasText(array $node): bool + { + if ( 'TEXT' === strtoupper((string) ($node['type'] ?? '')) ) { + return true; + } + foreach ( $this->nodeList($node) as $child ) { + if ( is_array($child) && $this->subtreeHasText($child) ) { + return true; + } + } + + return false; + } + + /** + * @param array $node + * @return array + */ + private function nodeList(array $node): array + { + if ( is_array($node['nodes'] ?? null) ) { + return array_values($node['nodes']); + } + if ( is_array($node['children'] ?? null) ) { + return array_values($node['children']); + } + + return array(); + } +} diff --git a/figma-transformer/src/Html/PaintStackResolver.php b/figma-transformer/src/Html/PaintStackResolver.php new file mode 100644 index 00000000..a456f66b --- /dev/null +++ b/figma-transformer/src/Html/PaintStackResolver.php @@ -0,0 +1,606 @@ +): ?string $resolveAndMarkPaintAssetPath + * @param callable(float): string $numberFormatter + * @param callable(mixed, mixed=): ?string $color + */ + public function __construct( + private readonly mixed $resolveAndMarkPaintAssetPath, + private readonly mixed $numberFormatter, + private readonly mixed $color, + ) { + } + + /** + * @param array $node + * @param array $fallbackAssetPaths + * @return array + */ + public function composedImageBackgroundStyles(array $node, array $fallbackAssetPaths): array + { + $styles = array(); + $imageLayers = $this->nodeImagePaintLayers($node); + if ( empty($imageLayers) ) { + if ( empty($fallbackAssetPaths) ) { + return array(); + } + + $styles[] = 'background-image:' . $this->cssUrlList($fallbackAssetPaths); + $styles[] = 'background-size:cover'; + $styles[] = 'background-position:center'; + return $styles; + } + + $backgroundLayers = $this->nodeComposedBackgroundLayers($node, $imageLayers); + $styles[] = 'background-image:' . implode(',', array_map(static fn (array $layer): string => (string) $layer['css'], $backgroundLayers)); + $blendModes = $this->composedBackgroundBlendModes($backgroundLayers); + if ( ! empty($blendModes) ) { + $styles[] = 'background-blend-mode:' . implode(',', $blendModes); + } + foreach ( $this->composedBackgroundLayerStyles($node, $backgroundLayers) as $style ) { + $styles[] = $style; + } + + return $styles; + } + + /** + * Return resolved image paint layers ordered top->bottom. Duplicate asset + * paths are intentionally preserved because Figma can stack the same image + * with different crops, opacity, or blend modes. + * + * @param array $node + * @return array}> + */ + public function nodeImagePaintLayers(array $node): array + { + $paths = array(); + + foreach ( array('fills', 'strokes', 'background') as $paintKey ) { + $paintCollections = $this->paintCollections($node, $paintKey); + foreach ( $paintCollections as $paints ) { + // Figma stores fills bottom->top; reverse so topmost is first + // (CSS background-image: first url = topmost layer). + $orderedPaints = array_reverse(array_values($paints)); + foreach ( $orderedPaints as $paint ) { + if ( ! $this->isVisibleImagePaint($paint) ) { + continue; + } + + $path = $this->resolveAndMarkPaintAssetPath($paint); + if ( null === $path ) { + continue; + } + $paths[] = array('path' => $path, 'paint' => $paint); + } + } + } + + return $paths; + } + + /** + * @param array $node + * @param array}> $fallbackImageLayers + * @return array}> + */ + public function nodeComposedBackgroundLayers(array $node, array $fallbackImageLayers): array + { + $layers = array(); + foreach ( array('fills', 'background') as $paintKey ) { + foreach ( $this->paintCollections($node, $paintKey) as $paints ) { + foreach ( array_reverse(array_values($paints)) as $paint ) { + if ( ! is_array($paint) || false === ($paint['visible'] ?? true) ) { + continue; + } + + if ( 'IMAGE' === strtoupper((string) ($paint['type'] ?? '')) ) { + $path = $this->resolveAndMarkPaintAssetPath($paint); + if ( null !== $path ) { + $layers[] = array('type' => 'image', 'css' => 'url("' . $path . '")', 'paint' => $paint); + } + continue; + } + + if ( in_array(($paint['type'] ?? null), array('GRADIENT_LINEAR', 'GRADIENT_RADIAL', 'GRADIENT_ANGULAR'), true) ) { + $gradient = $this->gradientPaint($paint); + if ( null !== $gradient ) { + $layers[] = array('type' => 'gradient', 'css' => $gradient, 'paint' => $paint); + } + } + } + } + } + + if ( ! empty($layers) ) { + return $layers; + } + + return array_map(static fn (array $layer): array => array( + 'type' => 'image', + 'css' => 'url("' . (string) $layer['path'] . '")', + 'paint' => is_array($layer['paint'] ?? null) ? $layer['paint'] : array(), + ), $fallbackImageLayers); + } + + /** + * @param array $node + * @param array}> $layers + * @return array + */ + public function composedBackgroundLayerStyles(array $node, array $layers): array + { + $sizes = array(); + $repeats = array(); + $positions = array(); + foreach ( $layers as $layer ) { + if ( 'image' !== ($layer['type'] ?? null) ) { + $sizes[] = '100% 100%'; + $repeats[] = 'no-repeat'; + $positions[] = 'center'; + continue; + } + + $paint = is_array($layer['paint'] ?? null) ? $layer['paint'] : array(); + $layerStyles = $this->imagePaintLayerBackgroundStyles($node, $paint, $this->imagePaintScaleMode($paint)); + $sizes[] = $layerStyles['size']; + $repeats[] = $layerStyles['repeat']; + $positions[] = $layerStyles['position']; + } + + if ( empty($sizes) ) { + return array(); + } + + if ( array('cover') === array_values(array_unique($sizes)) && array('no-repeat') === array_values(array_unique($repeats)) && array('center') === array_values(array_unique($positions)) ) { + return array('background-size:cover', 'background-position:center'); + } + + return array( + 'background-size:' . implode(',', $sizes), + 'background-repeat:' . implode(',', $repeats), + 'background-position:' . implode(',', $positions), + ); + } + + /** + * @param array}> $layers + * @return array + */ + public function composedBackgroundBlendModes(array $layers): array + { + $blendModes = array(); + foreach ( $layers as $layer ) { + if ( 'image' !== ($layer['type'] ?? null) ) { + $blendModes[] = 'normal'; + continue; + } + + $paint = is_array($layer['paint'] ?? null) ? $layer['paint'] : array(); + $blendMode = null; + if ( isset($paint['blendMode']) && is_scalar($paint['blendMode']) ) { + $blendMode = $this->blendModeCss((string) $paint['blendMode']); + } + $blendModes[] = $blendMode ?? 'normal'; + } + + return in_array(true, array_map(static fn (string $mode): bool => 'normal' !== $mode, $blendModes), true) ? $blendModes : array(); + } + + /** + * @param array $paints + * @return array{css: string, gradient: bool}|null + */ + public function firstCssPaint(array $paints): ?array + { + foreach ( $paints as $paint ) { + if ( ! is_array($paint) ) { + continue; + } + + if ( 'SOLID' === ($paint['type'] ?? null) ) { + $color = $this->color($paint['color'] ?? null, $paint['opacity'] ?? null); + if ( null !== $color ) { + return array('css' => $color, 'gradient' => false); + } + } + + if ( in_array(($paint['type'] ?? null), array('GRADIENT_LINEAR', 'GRADIENT_RADIAL', 'GRADIENT_ANGULAR'), true) ) { + $gradient = $this->gradientPaint($paint); + if ( null !== $gradient ) { + return array('css' => $gradient, 'gradient' => true); + } + } + } + + return null; + } + + /** + * @param array $paint + */ + public function imagePaintScaleMode(array $paint): string + { + foreach ( array('imageScaleMode', 'scaleMode') as $key ) { + if ( isset($paint[$key]) && is_scalar($paint[$key]) && '' !== (string) $paint[$key] ) { + return strtoupper((string) $paint[$key]); + } + } + + return 'FILL'; + } + + /** + * @param array $node + * @param array $paint + * @return array{size: string, repeat: string, position: string}|array{} + */ + public function imagePaintTransformStyles(array $node, array $paint): array + { + $box = is_array($node['box'] ?? null) ? $node['box'] : (is_array($node['figma_box'] ?? null) ? $node['figma_box'] : array()); + $width = $box['width'] ?? $node['width'] ?? null; + $height = $box['height'] ?? $node['height'] ?? null; + if ( ! is_numeric($width) || ! is_numeric($height) || 0 >= (float) $width || 0 >= (float) $height ) { + return array(); + } + + $matrix = $this->imagePaintTransformMatrix($paint); + if ( null === $matrix || $this->isIdentityImageTransform($matrix) ) { + $cropRect = $this->imagePaintCropRect($paint); + if ( null === $cropRect ) { + return array(); + } + + $backgroundWidth = (float) $width / $cropRect['width']; + $backgroundHeight = (float) $height / $cropRect['height']; + $backgroundX = -1 * $cropRect['x'] * $backgroundWidth; + $backgroundY = -1 * $cropRect['y'] * $backgroundHeight; + + return array( + 'size' => $this->number($backgroundWidth) . 'px ' . $this->number($backgroundHeight) . 'px', + 'repeat' => 'no-repeat', + 'position' => $this->number($backgroundX) . 'px ' . $this->number($backgroundY) . 'px', + ); + } + + if ( 0.00001 < abs($matrix['m01']) || 0.00001 < abs($matrix['m10']) || 0 >= $matrix['m00'] || 0 >= $matrix['m11'] ) { + return array(); + } + + $backgroundWidth = (float) $width / $matrix['m00']; + $backgroundHeight = (float) $height / $matrix['m11']; + $backgroundX = -1 * $matrix['m02'] * $backgroundWidth; + $backgroundY = -1 * $matrix['m12'] * $backgroundHeight; + + return array( + 'size' => $this->number($backgroundWidth) . 'px ' . $this->number($backgroundHeight) . 'px', + 'repeat' => 'no-repeat', + 'position' => $this->number($backgroundX) . 'px ' . $this->number($backgroundY) . 'px', + ); + } + + /** + * @param array $paint + */ + public function gradientPaint(array $paint): ?string + { + $stops = is_array($paint['stops'] ?? null) ? $paint['stops'] : array(); + if ( empty($stops) ) { + return null; + } + + $cssStops = array(); + foreach ( $stops as $stop ) { + if ( ! is_array($stop) || ! isset($stop['position']) || ! is_numeric($stop['position']) ) { + continue; + } + + $opacity = $paint['opacity'] ?? null; + $color = $stop['color'] ?? null; + if ( is_numeric($opacity) && is_array($color) && isset($color['a']) && is_numeric($color['a']) ) { + $opacity = (float) $opacity * (float) $color['a']; + } + + $cssColor = $this->color($color, $opacity); + if ( null === $cssColor ) { + continue; + } + + $cssStops[] = $cssColor . ' ' . $this->number((float) $stop['position'] * 100) . '%'; + } + + if ( empty($cssStops) ) { + return null; + } + + if ( 'GRADIENT_RADIAL' === ($paint['type'] ?? null) ) { + return 'radial-gradient(circle,' . implode(',', $cssStops) . ')'; + } + + if ( 'GRADIENT_ANGULAR' === ($paint['type'] ?? null) ) { + $geometry = $this->angularGradientGeometry($paint); + + return 'conic-gradient(from ' . $this->number($geometry['from']) . 'deg at ' + . $this->number($geometry['cx']) . '% ' . $this->number($geometry['cy']) . '%,' + . implode(',', $cssStops) . ')'; + } + + return 'linear-gradient(' . $this->number($this->linearGradientAngle($paint)) . 'deg,' . implode(',', $cssStops) . ')'; + } + + private function resolveAndMarkPaintAssetPath(array $paint): ?string + { + return ($this->resolveAndMarkPaintAssetPath)($paint); + } + + private function number(float $value): string + { + return ($this->numberFormatter)($value); + } + + private function color(mixed $value, mixed $opacity = null): ?string + { + return ($this->color)($value, $opacity); + } + + /** + * @param array $node + * @return array> + */ + private function paintCollections(array $node, string $paintKey): array + { + $paintCollections = array(); + if ( is_array($node[$paintKey] ?? null) ) { + $paintCollections[] = $node[$paintKey]; + } + if ( is_array($node['figma_paints'][$paintKey] ?? null) ) { + $paintCollections[] = $node['figma_paints'][$paintKey]; + } + + return $paintCollections; + } + + private function isVisibleImagePaint(mixed $paint): bool + { + return is_array($paint) + && 'IMAGE' === strtoupper((string) ($paint['type'] ?? '')) + && false !== ($paint['visible'] ?? true); + } + + /** + * @param array $node + * @param array $paint + * @return array{size: string, repeat: string, position: string} + */ + private function imagePaintLayerBackgroundStyles(array $node, array $paint, string $scaleMode): array + { + if ( 'TILE' !== $scaleMode ) { + $transformStyles = $this->imagePaintTransformStyles($node, $paint); + if ( ! empty($transformStyles) ) { + return $transformStyles; + } + } + + if ( 'STRETCH' === $scaleMode ) { + return array('size' => '100% 100%', 'repeat' => 'no-repeat', 'position' => 'center'); + } + + if ( 'TILE' === $scaleMode ) { + return array('size' => 'auto', 'repeat' => 'repeat', 'position' => 'center'); + } + + return array('size' => 'cover', 'repeat' => 'no-repeat', 'position' => 'center'); + } + + /** + * @param array $paint + * @return array{x: float, y: float, width: float, height: float}|null + */ + private function imagePaintCropRect(array $paint): ?array + { + $cropRect = $paint['cropRect'] ?? null; + if ( ! is_array($cropRect) ) { + return null; + } + + $width = $cropRect['width'] ?? $cropRect['w'] ?? null; + $height = $cropRect['height'] ?? $cropRect['h'] ?? null; + $x = $cropRect['x'] ?? 0; + $y = $cropRect['y'] ?? 0; + if ( ! is_numeric($x) || ! is_numeric($y) || ! is_numeric($width) || ! is_numeric($height) || 0 >= (float) $width || 0 >= (float) $height ) { + return null; + } + + return array( + 'x' => (float) $x, + 'y' => (float) $y, + 'width' => (float) $width, + 'height' => (float) $height, + ); + } + + /** + * @param array $paint + * @return array{m00: float, m01: float, m02: float, m10: float, m11: float, m12: float}|null + */ + private function imagePaintTransformMatrix(array $paint): ?array + { + $transform = $paint['transform'] ?? $paint['imageTransform'] ?? null; + if ( ! is_array($transform) ) { + return null; + } + + if ( isset($transform['m00'], $transform['m01'], $transform['m02'], $transform['m10'], $transform['m11'], $transform['m12']) ) { + $values = array( + 'm00' => $transform['m00'], + 'm01' => $transform['m01'], + 'm02' => $transform['m02'], + 'm10' => $transform['m10'], + 'm11' => $transform['m11'], + 'm12' => $transform['m12'], + ); + } elseif ( is_array($transform[0] ?? null) && is_array($transform[1] ?? null) ) { + $values = array( + 'm00' => $transform[0][0] ?? null, + 'm01' => $transform[0][1] ?? null, + 'm02' => $transform[0][2] ?? null, + 'm10' => $transform[1][0] ?? null, + 'm11' => $transform[1][1] ?? null, + 'm12' => $transform[1][2] ?? null, + ); + } else { + return null; + } + + foreach ( $values as $value ) { + if ( ! is_numeric($value) ) { + return null; + } + } + + return array_map(static fn (mixed $value): float => (float) $value, $values); + } + + /** + * @param array{m00: float, m01: float, m02: float, m10: float, m11: float, m12: float} $matrix + */ + private function isIdentityImageTransform(array $matrix): bool + { + return 0.00001 > abs($matrix['m00'] - 1.0) + && 0.00001 > abs($matrix['m01']) + && 0.00001 > abs($matrix['m02']) + && 0.00001 > abs($matrix['m10']) + && 0.00001 > abs($matrix['m11'] - 1.0) + && 0.00001 > abs($matrix['m12']); + } + + /** + * @param array $paths + */ + private function cssUrlList(array $paths): string + { + return implode(',', array_map(static fn (string $path): string => 'url("' . $path . '")', $paths)); + } + + /** + * @param array $paint + * @return array{from: float, cx: float, cy: float} + */ + private function angularGradientGeometry(array $paint): array + { + $default = array('from' => 0.0, 'cx' => 50.0, 'cy' => 50.0); + + $matrix = $paint['gradientTransform'] ?? null; + if ( ! is_array($matrix) || ! is_array($matrix[0] ?? null) || ! is_array($matrix[1] ?? null) ) { + return $default; + } + + $a = $this->numericOrNull($matrix[0][0] ?? null); + $b = $this->numericOrNull($matrix[0][1] ?? null); + $tx = $this->numericOrNull($matrix[0][2] ?? null); + $c = $this->numericOrNull($matrix[1][0] ?? null); + $d = $this->numericOrNull($matrix[1][1] ?? null); + $ty = $this->numericOrNull($matrix[1][2] ?? null); + if ( null === $a || null === $b || null === $tx || null === $c || null === $d || null === $ty ) { + return $default; + } + + $det = $a * $d - $b * $c; + if ( abs($det) < 1e-9 ) { + return $default; + } + + $dx = $d / $det; + $dy = -$c / $det; + $from = 0.0; + if ( abs($dx) >= 1e-9 || abs($dy) >= 1e-9 ) { + $from = fmod(rad2deg(atan2($dx, -$dy)), 360.0); + if ( $from < 0.0 ) { + $from += 360.0; + } + } + + $cx = ($d * (0.5 - $tx) - $b * (0.5 - $ty)) / $det; + $cy = ($a * (0.5 - $ty) - $c * (0.5 - $tx)) / $det; + + return array( + 'from' => $from, + 'cx' => $cx * 100.0, + 'cy' => $cy * 100.0, + ); + } + + /** + * @param array $paint + */ + private function linearGradientAngle(array $paint): float + { + $matrix = $paint['gradientTransform'] ?? null; + if ( ! is_array($matrix) || ! is_array($matrix[0] ?? null) || ! is_array($matrix[1] ?? null) ) { + return 180.0; + } + + $a = $this->numericOrNull($matrix[0][0] ?? null); + $b = $this->numericOrNull($matrix[0][1] ?? null); + $c = $this->numericOrNull($matrix[1][0] ?? null); + $d = $this->numericOrNull($matrix[1][1] ?? null); + if ( null === $a || null === $b || null === $c || null === $d ) { + return 180.0; + } + + $det = $a * $d - $b * $c; + if ( abs($det) < 1e-9 ) { + return 180.0; + } + + $dx = $d / $det; + $dy = -$c / $det; + if ( abs($dx) < 1e-9 && abs($dy) < 1e-9 ) { + return 180.0; + } + + $angle = fmod(rad2deg(atan2($dx, -$dy)), 360.0); + if ( $angle < 0.0 ) { + $angle += 360.0; + } + + return $angle; + } + + private function numericOrNull(mixed $value): ?float + { + return is_numeric($value) ? (float) $value : null; + } + + private function blendModeCss(string $blendMode): ?string + { + return match ( strtoupper($blendMode) ) { + 'MULTIPLY' => 'multiply', + 'SCREEN' => 'screen', + 'OVERLAY' => 'overlay', + 'DARKEN' => 'darken', + 'LIGHTEN' => 'lighten', + 'COLOR_DODGE' => 'color-dodge', + 'COLOR_BURN' => 'color-burn', + 'HARD_LIGHT' => 'hard-light', + 'SOFT_LIGHT' => 'soft-light', + 'DIFFERENCE' => 'difference', + 'EXCLUSION' => 'exclusion', + 'HUE' => 'hue', + 'SATURATION' => 'saturation', + 'COLOR' => 'color', + 'LUMINOSITY' => 'luminosity', + default => null, + }; + } +} diff --git a/figma-transformer/src/Html/PositioningStyleDecision.php b/figma-transformer/src/Html/PositioningStyleDecision.php new file mode 100644 index 00000000..399af37f --- /dev/null +++ b/figma-transformer/src/Html/PositioningStyleDecision.php @@ -0,0 +1,23 @@ + $styles + */ + public function __construct( + public readonly array $styles, + public readonly bool $willPositionAbsolute, + public readonly bool $isDecorativeFlexUnderlay, + public readonly ?string $zIndexReasonCode = null, + public readonly ?AbsolutePositioningDecision $absolutePositioningDecision = null, + ) { + } +} diff --git a/figma-transformer/src/Html/PositioningStyleResolver.php b/figma-transformer/src/Html/PositioningStyleResolver.php new file mode 100644 index 00000000..e3990c89 --- /dev/null +++ b/figma-transformer/src/Html/PositioningStyleResolver.php @@ -0,0 +1,177 @@ +): bool $isFreeformContainer + * @param callable(array): bool $freeformContainerShouldUseFlow + * @param callable(array, array): bool $isDecorativeFlexUnderlay + * @param callable(array): bool $hasDecorativeFlexUnderlayChild + */ + public function __construct( + private readonly LayoutIntentClassifier $layoutIntentClassifier, + private readonly CssPositioningResolver $cssPositioningResolver, + private readonly CanvasShellResolver $canvasShellResolver, + private readonly mixed $isFreeformContainer, + private readonly mixed $freeformContainerShouldUseFlow, + private readonly mixed $isDecorativeFlexUnderlay, + private readonly mixed $hasDecorativeFlexUnderlayChild, + ) { + } + + /** + * @param array $node + * @param array $box + * @param array $layout + * @param array|null $parentNode + * @param array $declaredStyles + */ + public function resolve(array $node, string $type, ?array $parentNode, array $box, array $layout, CanvasShellDecision $canvasShell, array $declaredStyles): PositioningStyleDecision + { + $styles = array(); + $isDecorativeFlexUnderlay = null !== $parentNode && $this->isDecorativeFlexUnderlay($node, $parentNode); + $parentFreeformUsesFlow = null !== $parentNode && $this->freeformContainerShouldUseFlow($parentNode); + $willPositionAbsolute = (null !== $parentNode && $this->isFreeformContainer($parentNode) && ! $parentFreeformUsesFlow) || 'absolute' === ($layout['positioning'] ?? null) || $isDecorativeFlexUnderlay; + $stackingContextPlan = $this->layoutIntentClassifier->stackingContextPlan($node, $parentNode); + $effectiveZIndex = isset($stackingContextPlan['z_index']) && is_int($stackingContextPlan['z_index']) ? $stackingContextPlan['z_index'] : null; + $zIndexReason = isset($stackingContextPlan['z_index_reason']) && is_string($stackingContextPlan['z_index_reason']) ? $stackingContextPlan['z_index_reason'] : null; + + if ( $canvasShell->responsiveCenteredFlowShell && ! $willPositionAbsolute ) { + $styles[] = 'margin-left:auto'; + $styles[] = 'margin-right:auto'; + } + if ( ! $willPositionAbsolute && (true === ($stackingContextPlan['manages_local_stacking'] ?? false) || ($parentFreeformUsesFlow && 'FRAME' === $type)) ) { + $styles[] = 'position:relative'; + } + + if ( true === ($stackingContextPlan['needs_isolation'] ?? false) ) { + $styles[] = 'isolation:isolate'; + } + + $absolutePositioningDecision = $this->absolutePositioningDecision($node, $parentNode, $box, $layout, $canvasShell, $isDecorativeFlexUnderlay, $parentFreeformUsesFlow); + if ( null !== $absolutePositioningDecision ) { + foreach ( $absolutePositioningDecision->declarations as $style ) { + $styles[] = $style; + } + } + + if ( $isDecorativeFlexUnderlay ) { + if ( null !== $effectiveZIndex && ! $this->stylesDeclareProperty(array_merge($declaredStyles, $styles), 'z-index') ) { + $styles[] = 'z-index:' . (string) $effectiveZIndex; + } + $styles[] = 'pointer-events:none'; + } + + if ( null !== $parentNode && ! $willPositionAbsolute && null === $effectiveZIndex && $this->hasDecorativeFlexUnderlayChild($parentNode) ) { + $styles[] = 'position:relative'; + $styles[] = 'z-index:1'; + } + + if ( null !== $effectiveZIndex && ! $willPositionAbsolute && ! $this->stylesDeclareProperty(array_merge($declaredStyles, $styles), 'position') ) { + $styles[] = 'position:relative'; + } + + if ( null !== $effectiveZIndex && ! $this->stylesDeclareProperty(array_merge($declaredStyles, $styles), 'z-index') ) { + $styles[] = 'z-index:' . (string) $effectiveZIndex; + } + + return new PositioningStyleDecision($styles, $willPositionAbsolute, $isDecorativeFlexUnderlay, $zIndexReason, $absolutePositioningDecision); + } + + /** + * @param array $node + * @param array|null $parentNode + * @param array $box + * @param array $layout + */ + private function absolutePositioningDecision(array $node, ?array $parentNode, array $box, array $layout, CanvasShellDecision $canvasShell, bool $isDecorativeFlexUnderlay, bool $parentFreeformUsesFlow): ?AbsolutePositioningDecision + { + $reasonCode = ''; + if ( $isDecorativeFlexUnderlay ) { + $reasonCode = 'decorative_flex_underlay_absolute'; + } elseif ( null !== $parentNode && $this->isFreeformContainer($parentNode) && ! $parentFreeformUsesFlow ) { + $reasonCode = 'freeform_parent_absolute_child'; + } elseif ( 'absolute' === ($layout['positioning'] ?? null) ) { + $reasonCode = 'explicit_absolute_positioning'; + } + + if ( '' === $reasonCode ) { + return null; + } + + $declarations = array('position:absolute'); + $suppressedFullBleedHorizontalOffsets = false; + foreach ( $this->cssPositioningResolver->styles($box, $layout, $parentNode, $node, $canvasShell->centeredWithinParentFluidCanvas) as $style ) { + if ( $canvasShell->fullBleedCanvasChild && $this->isHorizontalOffsetStyle($style) ) { + $suppressedFullBleedHorizontalOffsets = true; + continue; + } + $declarations[] = $style; + } + foreach ( $this->canvasShellResolver->fullBleedViewportBreakoutDecision($canvasShell)['declarations'] as $style ) { + $declarations[] = $style; + } + + return new AbsolutePositioningDecision($reasonCode, $declarations, $suppressedFullBleedHorizontalOffsets); + } + + /** + * @param array $styles + */ + private function stylesDeclareProperty(array $styles, string $property): bool + { + $prefix = $property . ':'; + foreach ( $styles as $style ) { + if ( str_starts_with($style, $prefix) ) { + return true; + } + } + + return false; + } + + private function isHorizontalOffsetStyle(string $style): bool + { + return str_starts_with($style, 'left:') || str_starts_with($style, 'right:') || str_starts_with($style, 'margin-left:') || str_starts_with($style, 'margin-right:'); + } + + /** + * @param array $node + */ + private function isFreeformContainer(array $node): bool + { + return ($this->isFreeformContainer)($node); + } + + /** + * @param array $node + */ + private function freeformContainerShouldUseFlow(array $node): bool + { + return ($this->freeformContainerShouldUseFlow)($node); + } + + /** + * @param array $node + * @param array $parentNode + */ + private function isDecorativeFlexUnderlay(array $node, array $parentNode): bool + { + return ($this->isDecorativeFlexUnderlay)($node, $parentNode); + } + + /** + * @param array $node + */ + private function hasDecorativeFlexUnderlayChild(array $node): bool + { + return ($this->hasDecorativeFlexUnderlayChild)($node); + } +} diff --git a/figma-transformer/src/Html/ResponsiveBreakpointSafetyPolicy.php b/figma-transformer/src/Html/ResponsiveBreakpointSafetyPolicy.php new file mode 100644 index 00000000..14d9c9a1 --- /dev/null +++ b/figma-transformer/src/Html/ResponsiveBreakpointSafetyPolicy.php @@ -0,0 +1,495 @@ +): array $nodeList + * @param callable(float): string $number + */ + public function __construct( + private readonly mixed $nodeList, + private readonly mixed $number, + private readonly BreakpointDimensionPolicy $breakpointDimensionPolicy, + private readonly LayoutIntentClassifier $layoutIntentClassifier, + ) { + } + + /** + * @param array $node + * @param array|null $parentNode + * @param array $baseMap + * @param array|null $grandParentNode + * @param array|null $variantNode + * @return array{reason_code: string, declarations: array} + */ + public function responsiveSafetyDecision(array $node, ?array $parentNode, array $baseMap, float $viewportWidth, int $depth = 0, ?array $grandParentNode = null, ?array $variantNode = null): array + { + $name = strtolower(trim((string) ($node['name'] ?? ''))); + $type = strtoupper((string) ($node['type'] ?? 'FRAME')); + $layout = is_array($node['layout'] ?? null) ? $node['layout'] : array(); + $positioning = (string) ($layout['positioning'] ?? ($baseMap['position'] ?? '')); + $display = (string) ($baseMap['display'] ?? ''); + $width = $this->responsiveSourceWidth($baseMap); + $parentName = null === $parentNode ? '' : strtolower(trim((string) ($parentNode['name'] ?? ''))); + $isContainer = in_array($type, array('FRAME', 'GROUP', 'INSTANCE', 'COMPONENT', 'SYMBOL'), true); + $chromeRole = $this->layoutIntentClassifier->chromeGroupRole($node, $parentNode, $depth); + $parentChromeRole = null === $parentNode ? null : $this->layoutIntentClassifier->chromeGroupRole($parentNode, $grandParentNode, max(1, $depth - 1)); + + $chromeDecision = $this->responsiveChromeFlowDecision($node, $parentNode, $baseMap, $variantNode, $name, $parentName, $isContainer, $chromeRole, $parentChromeRole); + if ( '' !== $chromeDecision['reason_code'] ) { + return $chromeDecision; + } + + $namedShellDecision = $this->namedResponsiveShellDecision($node, $parentNode, $name, $parentName, $isContainer, $width, $positioning, $display, $chromeRole, $viewportWidth); + if ( '' !== $namedShellDecision['reason_code'] ) { + return $namedShellDecision; + } + + if ( $viewportWidth <= 480.0 ) { + $mobileTextDeclarations = $this->mobileCenteredTextFallbackDecision($node, $parentNode, $baseMap, $viewportWidth, $type, $width, $positioning, $variantNode); + if ( ! empty($mobileTextDeclarations) ) { + return array('reason_code' => 'responsive_centered_text_mobile_safety', 'declarations' => $mobileTextDeclarations); + } + + $mobileDeclarations = $this->genericMobileSafetyDeclarations($node, $parentNode, $baseMap, $viewportWidth, $isContainer, $width, $positioning, $display); + if ( ! empty($mobileDeclarations) ) { + return array('reason_code' => 'responsive_generic_mobile_safety', 'declarations' => $mobileDeclarations); + } + } + + return array('reason_code' => '', 'declarations' => array()); + } + + /** + * @param array $node + * @param array|null $parentNode + * @param array $baseMap + * @param array|null $variantNode + * @return array{reason_code: string, declarations: array} + */ + public function responsiveChromeFlowDecision(array $node, ?array $parentNode, array $baseMap, ?array $variantNode, string $name, string $parentName, bool $isContainer, ?string $chromeRole, ?string $parentChromeRole): array + { + if ( (LayoutIntentClassifier::CHROME_GROUP_ROLE_HEADER === $chromeRole || $this->isHeaderChromeShellName($name)) && $isContainer ) { + return array('reason_code' => 'responsive_header_chrome_safety', 'declarations' => $this->breakpointDimensionPolicy->headerChromeDeclarations($this->responsiveHeaderMinHeight($node, $baseMap, $variantNode))); + } + + if ( LayoutIntentClassifier::CHROME_GROUP_ROLE_FOOTER === $chromeRole || 'footer' === $name ) { + if ( $isContainer && ! $this->hasFooterResponsiveShell($node) ) { + return array('reason_code' => 'responsive_footer_chrome_safety', 'declarations' => $this->footerChromeDeclarations($node, $baseMap, $variantNode)); + } + } + + if ( null === $variantNode && (LayoutIntentClassifier::CHROME_GROUP_ROLE_HEADER === $parentChromeRole || $this->isHeaderChromeShellName($parentName)) ) { + $headerChildDeclarations = array('position:relative', 'left:auto', 'right:auto', 'top:auto', 'max-width:100%'); + if ( $isContainer ) { + array_unshift($headerChildDeclarations, 'width:100%', 'max-width:100%', 'height:auto'); + array_push($headerChildDeclarations, 'justify-content:flex-start', 'align-items:center', 'flex-wrap:wrap', 'gap:16px', 'padding-top:24px', 'padding-right:24px', 'padding-bottom:24px', 'padding-left:24px'); + } + + return array('reason_code' => 'responsive_header_child_chrome_safety', 'declarations' => array_values(array_unique($headerChildDeclarations))); + } + + if ( LayoutIntentClassifier::CHROME_GROUP_ROLE_FOOTER === $parentChromeRole || 'footer' === $parentName ) { + if ( str_contains($name, 'newsletter signup') || 'frame 19' === $name || $this->isDecorativeFooterUnderlay($node, $baseMap) ) { + return array('reason_code' => '', 'declarations' => array()); + } + + $footerChildDeclarations = array('position:relative', 'left:auto', 'right:auto', 'top:auto', 'max-width:100%', 'margin-left:0'); + if ( $isContainer ) { + array_unshift($footerChildDeclarations, 'width:100%', 'max-width:100%', 'height:auto'); + array_push($footerChildDeclarations, 'justify-content:flex-start', 'align-items:center', 'flex-wrap:wrap', 'gap:16px'); + } + + return array('reason_code' => 'responsive_footer_child_chrome_safety', 'declarations' => array_values(array_unique($footerChildDeclarations))); + } + + if ( $this->isNavigationShellName($name) && $isContainer ) { + return array('reason_code' => 'responsive_navigation_shell_safety', 'declarations' => array('width:100%', 'max-width:100%', 'height:auto', 'justify-content:flex-start', 'flex-wrap:wrap', 'gap:16px')); + } + + return array('reason_code' => '', 'declarations' => array()); + } + + /** + * @param array $node + * @param array|null $parentNode + * @return array{reason_code: string, declarations: array} + */ + private function namedResponsiveShellDecision(array $node, ?array $parentNode, string $name, string $parentName, bool $isContainer, ?float $width, string $positioning, string $display, ?string $chromeRole, float $viewportWidth): array + { + if ( 'footer' === $name && $isContainer && $this->hasFooterResponsiveShell($node) ) { + return array('reason_code' => 'responsive_footer_shell_safety', 'declarations' => array('height:auto', 'min-height:' . ($this->number)($this->footerResponsiveMinHeight($node)) . 'px')); + } + + if ( (LayoutIntentClassifier::CHROME_GROUP_ROLE_NAVIGATION === $chromeRole || 'navigation' === $name) && $isContainer ) { + return array('reason_code' => 'responsive_navigation_chrome_safety', 'declarations' => array('width:100%', 'max-width:100%', 'height:auto', 'justify-content:flex-start', 'flex-wrap:wrap', 'gap:16px')); + } + + if ( str_contains($name, 'newsletter signup') && $isContainer && 'absolute' === $positioning ) { + return array('reason_code' => 'responsive_absolute_newsletter_shell_safety', 'declarations' => array_merge($this->mobileSafeSourceMaxWidthDeclarations(1216.0, $viewportWidth, 'fixed'), array('height:auto', 'left:24px'))); + } + + if ( 'frame 20' === $name && $isContainer && null !== $parentNode && str_contains($parentName, 'newsletter signup') ) { + return array('reason_code' => 'responsive_newsletter_inner_shell_safety', 'declarations' => array('height:auto', 'padding-top:56px', 'padding-right:24px', 'padding-bottom:48px', 'padding-left:24px', 'gap:24px')); + } + + if ( 'frame 19' === $name && $isContainer && 'absolute' === $positioning ) { + return array('reason_code' => 'responsive_absolute_inner_shell_safety', 'declarations' => array('height:auto', 'position:relative', 'left:auto', 'top:auto', 'justify-content:center', 'flex-wrap:wrap', 'align-content:flex-start', 'padding-top:32px', 'padding-right:24px', 'padding-bottom:32px', 'padding-left:24px')); + } + + if ( ('featured preview' === $name || 'preview' === $name) && $isContainer && null !== $width && $width > 340.0 ) { + return array('reason_code' => 'responsive_preview_card_width_safety', 'declarations' => array('width:100%', 'height:auto')); + } + + if ( 'pagination' === $name && $isContainer ) { + return array('reason_code' => 'responsive_pagination_overflow_safety', 'declarations' => array_merge($this->mobileSafeSourceMaxWidthDeclarations(1216.0, $viewportWidth, 'fixed'), array('overflow-x:auto'))); + } + + if ( 'image' === $name && in_array($display, array('flex', 'inline-flex'), true) && null !== $width && $width > 340.0 ) { + return array('reason_code' => 'responsive_image_fill_safety', 'declarations' => $this->breakpointDimensionPolicy->fluidFillDeclarations()); + } + + return array('reason_code' => '', 'declarations' => array()); + } + + /** + * @param array $node + * @param array|null $parentNode + * @param array $baseMap + * @param array|null $variantNode + * @return array + */ + public function mobileCenteredTextFallbackDecision(array $node, ?array $parentNode, array $baseMap, float $viewportWidth, string $type, ?float $width, string $positioning, ?array $variantNode): array + { + if ( 'TEXT' !== $type || null === $parentNode || null === $width || 'absolute' !== $positioning ) { + return array(); + } + + $computedLeft = $this->mobileComputedCenteredLeft($baseMap['left'] ?? '', $viewportWidth); + if ( null === $computedLeft || $computedLeft >= 0.0 ) { + return array(); + } + + if ( null !== $variantNode && $this->variantTextFitsViewport($variantNode, $viewportWidth) ) { + return array(); + } + + $mobileContentWidth = max(1.0, $viewportWidth - 48.0); + return array( + 'width:calc(100% - 48px)', + 'max-width:' . ($this->number)(min($width, $mobileContentWidth)) . 'px', + 'left:24px', + 'right:auto', + ); + } + + /** + * @param array $node + * @param array|null $parentNode + * @param array $baseMap + * @return array + */ + private function genericMobileSafetyDeclarations(array $node, ?array $parentNode, array $baseMap, float $viewportWidth, bool $isContainer, ?float $width, string $positioning, string $display): array + { + $mobileContentWidth = max(1.0, $viewportWidth - 48.0); + if ( ! $isContainer || null === $parentNode || null === $width || $width <= min(340.0, $mobileContentWidth) || empty(($this->nodeList)($node)) ) { + return array(); + } + + $declarations = array(); + $hasContainerChild = $this->hasContainerChild($node); + + if ( 'absolute' === $positioning ) { + if ( $width > $mobileContentWidth ) { + array_push($declarations, ...$this->mobileSafeSourceMaxWidthDeclarations($width, $viewportWidth, 'absolute')); + $declarations[] = 'height:auto'; + array_push($declarations, ...$this->stackedMobileFlowDeclarations($baseMap, $display, $hasContainerChild)); + array_push($declarations, ...$this->mobilePaddingClampDeclarations($baseMap)); + } + + return $declarations; + } + + if ( 'auto' === ($baseMap['margin-left'] ?? null) && 'auto' === ($baseMap['margin-right'] ?? null) && $width > $mobileContentWidth ) { + array_push($declarations, ...$this->mobileSafeSourceMaxWidthDeclarations($width, $viewportWidth, 'centered')); + + if ( $hasContainerChild ) { + $declarations[] = 'height:auto'; + } + + array_push($declarations, ...$this->stackedMobileFlowDeclarations($baseMap, $display, $hasContainerChild)); + array_push($declarations, ...$this->mobilePaddingClampDeclarations($baseMap)); + + return $declarations; + } + + array_push($declarations, ...$this->breakpointDimensionPolicy->fluidFillDeclarations()); + + if ( $hasContainerChild ) { + $declarations[] = 'height:auto'; + } + + array_push($declarations, ...$this->stackedMobileFlowDeclarations($baseMap, $display, $hasContainerChild)); + array_push($declarations, ...$this->mobilePaddingClampDeclarations($baseMap)); + + return $declarations; + } + + /** + * @return array + */ + private function mobileSafeSourceMaxWidthDeclarations(float $sourceMaxWidth, float $viewportWidth, string $placement): array + { + if ( $viewportWidth > 480.0 ) { + return $this->breakpointDimensionPolicy->sourceMaxWidthDeclarations($sourceMaxWidth, 24.0, $placement); + } + + return $this->breakpointDimensionPolicy->sourceMaxWidthDeclarations(min($sourceMaxWidth, max(1.0, $viewportWidth - 48.0)), 24.0, $placement); + } + + /** + * @param array $baseMap + * @return array + */ + private function stackedMobileFlowDeclarations(array $baseMap, string $display, bool $hasContainerChild): array + { + if ( ! $hasContainerChild ) { + return array(); + } + + if ( in_array($display, array('flex', 'inline-flex'), true) && 'row' === ($baseMap['flex-direction'] ?? null) ) { + return array('flex-direction:column', 'align-items:stretch', 'flex-wrap:nowrap'); + } + + if ( in_array($display, array('grid', 'inline-grid'), true) ) { + return array('grid-template-columns:1fr'); + } + + return array(); + } + + /** + * @param array $baseMap + * @return array + */ + private function mobilePaddingClampDeclarations(array $baseMap): array + { + $declarations = array(); + foreach ( array('top', 'right', 'bottom', 'left') as $edge ) { + $property = 'padding-' . $edge; + $padding = $this->cssPixelValue($baseMap[$property] ?? ''); + if ( null !== $padding && $padding > 24.0 ) { + $declarations[] = $property . ':24px'; + } + } + + return $declarations; + } + + /** + * @param array $node + */ + private function hasContainerChild(array $node): bool + { + foreach ( ($this->nodeList)($node) as $child ) { + if ( ! is_array($child) ) { + continue; + } + + $childType = strtoupper((string) ($child['type'] ?? 'FRAME')); + if ( in_array($childType, array('FRAME', 'GROUP', 'INSTANCE', 'COMPONENT', 'SYMBOL'), true) ) { + return true; + } + } + + return false; + } + + /** + * @param array $baseMap + */ + private function responsiveSourceWidth(array $baseMap): ?float + { + $width = $this->cssPixelValue($baseMap['width'] ?? ''); + if ( null === $width && '100%' === ($baseMap['width'] ?? null) ) { + $width = $this->cssPixelValue($baseMap['max-width'] ?? ''); + } + + return $width; + } + + private function mobileComputedCenteredLeft(string $left, float $viewportWidth): ?float + { + $left = trim($left); + if ( 1 === preg_match('/^calc\(50%\s*([+-])\s*(\d+(?:\.\d+)?)px\)$/', $left, $matches) ) { + $delta = (float) $matches[2]; + return ($viewportWidth / 2.0) + ('-' === $matches[1] ? -$delta : $delta); + } + + return $this->cssPixelValue($left); + } + + /** + * @param array $variantNode + */ + private function variantTextFitsViewport(array $variantNode, float $viewportWidth): bool + { + $box = is_array($variantNode['box'] ?? null) ? $variantNode['box'] : array(); + if ( ! isset($box['x'], $box['width']) || ! is_numeric($box['x']) || ! is_numeric($box['width']) ) { + return false; + } + + $x = (float) $box['x']; + $width = (float) $box['width']; + return $x >= 0.0 && $width > 0.0 && ($x + min($width, $viewportWidth)) <= $viewportWidth + 1.0; + } + + private function isHeaderChromeShellName(string $name): bool + { + return (bool) preg_match('/^(?:header|site\s+header|page\s+header|main\s+header|masthead|top\s*bar|site\s*chrome)$/', $name); + } + + private function isNavigationShellName(string $name): bool + { + return (bool) preg_match('/(?:^|[^a-z0-9])(?:navigation|nav|menu)(?:[^a-z0-9]|$)/', $name); + } + + /** + * @param array $node + */ + private function hasFooterResponsiveShell(array $node): bool + { + $hasNewsletter = false; + $hasBottomRow = false; + $freeformParent = $this->isFreeformContainer($node); + foreach ( ($this->nodeList)($node) as $child ) { + if ( ! is_array($child) ) { + continue; + } + $name = strtolower(trim((string) ($child['name'] ?? ''))); + $layout = is_array($child['layout'] ?? null) ? $child['layout'] : array(); + if ( str_contains($name, 'newsletter signup') && ('absolute' === ($layout['positioning'] ?? null) || $freeformParent) ) { + $hasNewsletter = true; + } + if ( 'frame 19' === $name ) { + $hasBottomRow = true; + } + } + + return $hasNewsletter && $hasBottomRow; + } + + /** + * @param array $node + */ + private function isFreeformContainer(array $node): bool + { + $layout = is_array($node['layout'] ?? null) ? $node['layout'] : array(); + return empty($layout['display']) && ! empty(($this->nodeList)($node)); + } + + /** + * @param array $node + */ + private function footerResponsiveMinHeight(array $node): float + { + $baseHeight = $this->nodeBoxHeight($node) ?? 0.0; + $newsletterHeight = 0.0; + $bottomRowHeight = 0.0; + foreach ( ($this->nodeList)($node) as $child ) { + if ( ! is_array($child) ) { + continue; + } + $name = strtolower(trim((string) ($child['name'] ?? ''))); + if ( str_contains($name, 'newsletter signup') ) { + $newsletterHeight = max($newsletterHeight, $this->nodeBoxHeight($child) ?? 0.0); + } + if ( 'frame 19' === $name ) { + $bottomRowHeight = max($bottomRowHeight, $this->nodeBoxHeight($child) ?? 0.0); + } + } + + return max($baseHeight, $newsletterHeight + $bottomRowHeight); + } + + /** + * @param array $node + * @param array $baseMap + * @param array|null $variantNode + */ + private function responsiveHeaderMinHeight(array $node, array $baseMap, ?array $variantNode): ?float + { + $baseHeight = $this->cssPixelValue($baseMap['height'] ?? '') ?? $this->nodeBoxHeight($node); + $variantHeight = null === $variantNode ? null : $this->nodeBoxHeight($variantNode); + + if ( null === $baseHeight ) { + return $variantHeight; + } + + if ( null === $variantHeight ) { + return $baseHeight; + } + + return max($baseHeight, $variantHeight); + } + + /** + * @param array $node + * @param array $baseMap + * @param array|null $variantNode + * @return array + */ + private function footerChromeDeclarations(array $node, array $baseMap, ?array $variantNode): array + { + $declarations = array('width:100%', 'max-width:100%', 'height:auto', 'display:flex', 'flex-direction:column', 'align-items:stretch', 'justify-content:flex-start'); + $minHeight = $this->responsiveHeaderMinHeight($node, $baseMap, $variantNode); + if ( null !== $minHeight && $minHeight > 0.0 ) { + $declarations[] = 'min-height:' . ($this->number)($minHeight) . 'px'; + } + + return $declarations; + } + + /** + * @param array $node + * @param array $baseMap + */ + private function isDecorativeFooterUnderlay(array $node, array $baseMap): bool + { + $type = strtoupper((string) ($node['type'] ?? '')); + if ( ! in_array($type, array('RECTANGLE', 'VECTOR', 'BOOLEAN_OPERATION', 'LINE', 'ELLIPSE', 'STAR', 'POLYGON', 'REGULAR_POLYGON', 'ROUNDED_RECTANGLE'), true) ) { + return false; + } + + return 'none' === ($baseMap['pointer-events'] ?? null) || isset($baseMap['background']) || isset($baseMap['background-color']) || isset($baseMap['transform']); + } + + private function cssPixelValue(string $value): ?float + { + if ( 1 !== preg_match('/^(-?\d+(?:\.\d+)?)px$/', trim($value), $matches) ) { + return null; + } + + return (float) $matches[1]; + } + + /** + * @param array $node + */ + private function nodeBoxHeight(array $node): ?float + { + $box = is_array($node['box'] ?? null) ? $node['box'] : array(); + if ( ! isset($box['height']) || ! is_numeric($box['height']) ) { + return null; + } + + return (float) $box['height']; + } +} diff --git a/figma-transformer/src/Html/ResponsiveNodeMatcher.php b/figma-transformer/src/Html/ResponsiveNodeMatcher.php new file mode 100644 index 00000000..bf3d3a3d --- /dev/null +++ b/figma-transformer/src/Html/ResponsiveNodeMatcher.php @@ -0,0 +1,152 @@ +> $siblings + * @return array + */ + public function siblingSignatureCounts(array $siblings): array + { + $counts = array(); + foreach ( $siblings as $sibling ) { + $signature = $this->structuralSignature($sibling); + if ( null === $signature ) { + continue; + } + + $counts[$signature] = ($counts[$signature] ?? 0) + 1; + } + + return $counts; + } + + /** + * @param array> $siblings + * @return array + */ + public function siblingSourceIdentityCounts(array $siblings): array + { + $counts = array(); + foreach ( $siblings as $sibling ) { + foreach ( $this->sourceIdentities($sibling) as $sourceId ) { + $key = ($this->slug)($sourceId); + $counts[$key] = ($counts[$key] ?? 0) + 1; + } + } + + return $counts; + } + + /** + * @param array $node + * @param array $siblingSignatureCounts + * @return array + */ + public function childKeys(array $node, int $ordinal, array $siblingSignatureCounts, array $siblingSourceIdentityCounts = array()): array + { + $keys = array(); + + foreach ( $this->sourceIdentities($node) as $sourceId ) { + $sourceKey = ($this->slug)($sourceId); + if ( 1 === ($siblingSourceIdentityCounts[$sourceKey] ?? 1) ) { + $keys[] = 'source:' . $sourceKey; + } + } + + $signature = $this->structuralSignature($node); + if ( null !== $signature && 1 === ($siblingSignatureCounts[$signature] ?? 0) ) { + $keys[] = 'struct-signature:' . $signature; + } + + if ( ! empty($keys) ) { + return array_values(array_unique($keys)); + } + + $keys[] = 'struct-ordinal:' . $ordinal . ':' . $this->nodeType($node) . ':' . $this->responsiveIdentity($this->nodeName($node)); + + return array_values(array_unique($keys)); + } + + public function responsiveIdentity(string $name): string + { + $normalized = strtolower($name); + $normalized = (string) preg_replace('/[_\-\/]+/', ' ', $normalized); + $normalized = (string) preg_replace(self::BREAKPOINT_QUALIFIER_PATTERN, ' ', $normalized); + $normalized = (string) preg_replace(self::VIEWPORT_SIZE_PATTERN, ' ', $normalized); + $normalized = (string) preg_replace(self::VIEWPORT_WIDTH_PATTERN, ' ', $normalized); + $normalized = trim((string) preg_replace('/[^a-z0-9]+/', '-', $normalized), '-'); + + return '' === $normalized ? 'frame' : $normalized; + } + + /** + * @param array $node + */ + private function sourceIdentities(array $node): array + { + $sourceIds = array(); + foreach ( array('figma_component_source_id', 'source_id') as $key ) { + if ( isset($node[$key]) && is_scalar($node[$key]) && '' !== (string) $node[$key] ) { + $sourceIds[] = (string) $node[$key]; + } + } + + $component = is_array($node['figma_component'] ?? null) ? $node['figma_component'] : array(); + foreach ( array('component_id', 'component_source_key', 'definition_id') as $key ) { + if ( isset($component[$key]) && is_scalar($component[$key]) && '' !== (string) $component[$key] ) { + $sourceIds[] = 'figma_component.' . $key . ':' . (string) $component[$key]; + } + } + + return array_values(array_unique($sourceIds)); + } + + /** + * @param array $node + */ + private function structuralSignature(array $node): ?string + { + $name = $this->nodeName($node); + if ( '' === $name ) { + return null; + } + + return $this->nodeType($node) . ':' . $this->responsiveIdentity($name); + } + + /** + * @param array $node + */ + private function nodeType(array $node): string + { + return strtoupper((string) ($node['type'] ?? 'FRAME')); + } + + /** + * @param array $node + */ + private function nodeName(array $node): string + { + return isset($node['name']) && is_scalar($node['name']) ? (string) $node['name'] : ''; + } +} diff --git a/figma-transformer/src/Html/SourceLossCoverageBuilder.php b/figma-transformer/src/Html/SourceLossCoverageBuilder.php new file mode 100644 index 00000000..8826d2ac --- /dev/null +++ b/figma-transformer/src/Html/SourceLossCoverageBuilder.php @@ -0,0 +1,110 @@ +> $domains + * @return array + */ + public function aggregate(array $domains): array + { + $decoded = 0; + $emitted = 0; + $notEmitted = 0; + foreach ( $domains as $domain ) { + $decoded += (int) ($domain['decoded_source_nodes'] ?? 0); + $emitted += (int) ($domain['emitted_source_nodes'] ?? 0); + $notEmitted += (int) ($domain['not_emitted_source_nodes'] ?? 0); + } + + return array( + 'schema' => 'blocks-engine/figma-transformer/source-loss-coverage/v1', + 'decoded_source_nodes' => $decoded, + 'emitted_source_nodes' => $emitted, + 'not_emitted_source_nodes' => $notEmitted, + 'coverage_ratio' => $decoded > 0 ? round($emitted / $decoded, 3) : 1.0, + 'domains' => $domains, + ); + } + + /** + * @return array + */ + public function domain(int $decoded, int $emitted, int $notEmitted, int $intentionallySuppressed = 0): array + { + $decoded = max(0, $decoded); + $emitted = max(0, $emitted); + $notEmitted = max(0, $notEmitted); + $intentionallySuppressed = max(0, $intentionallySuppressed); + + return array( + 'decoded_source_nodes' => $decoded, + 'emitted_source_nodes' => min($decoded, $emitted + $intentionallySuppressed), + 'intentionally_suppressed_source_nodes' => min($decoded, $intentionallySuppressed), + 'not_emitted_source_nodes' => $notEmitted, + ); + } + + /** + * @param array $images + * @return array + */ + public function imageDomain(array $images): array + { + $decoded = (int) ($images['node_refs'] ?? 0); + $assetNodes = is_array($images['asset_nodes'] ?? null) ? $images['asset_nodes'] : array(); + if ( empty($assetNodes) ) { + return $this->domain($decoded, (int) ($images['resolved_assets'] ?? 0), count($images['missing_assets'] ?? array())); + } + + $emitted = 0; + $suppressed = 0; + foreach ( $assetNodes as $assetNode ) { + if ( ! is_array($assetNode) ) { + continue; + } + if ( true === ($assetNode['emitted'] ?? null) ) { + ++$emitted; + continue; + } + if ( $this->isIntentionallySuppressedAssetNode($assetNode) ) { + ++$suppressed; + } + } + + return $this->domain($decoded, $emitted, $decoded - $emitted - $suppressed, $suppressed); + } + + /** + * @param array $assetNode + */ + private function isIntentionallySuppressedAssetNode(array $assetNode): bool + { + if ( isset($assetNode['source_loss_reason']) && is_scalar($assetNode['source_loss_reason']) && '' !== (string) $assetNode['source_loss_reason'] ) { + return true; + } + + $reason = isset($assetNode['reason']) && is_scalar($assetNode['reason']) ? (string) $assetNode['reason'] : ''; + return in_array($reason, array('hidden', 'hidden_parent', 'clipped_masked', 'zero_area'), true); + } + + /** + * @param array $vectors + * @return array + */ + public function vectorDomain(array $vectors): array + { + return $this->domain( + (int) ($vectors['nodes'] ?? 0), + (int) ($vectors['rendered_paths'] ?? 0) + (int) ($vectors['rendered_asset_fallbacks'] ?? 0), + (int) ($vectors['placeholders'] ?? 0) + ); + } +} diff --git a/figma-transformer/src/Html/StackingContextPolicy.php b/figma-transformer/src/Html/StackingContextPolicy.php new file mode 100644 index 00000000..a809863c --- /dev/null +++ b/figma-transformer/src/Html/StackingContextPolicy.php @@ -0,0 +1,70 @@ + $localReasons + * @param array $isolationReasons + * @param array{role?: string|null, overlaps_sibling?: bool, z_index?: int|null} $siblingStackPlan + * @return array{manages_local_stacking: bool, needs_isolation: bool, local_reasons: array, sibling_role: string|null, overlaps_sibling: bool, z_index: int|null, z_index_reason: string|null} + */ + public function plan(array $localReasons, array $isolationReasons, array $siblingStackPlan, bool $isDecorativeUnderlay, ?int $sourceZIndex): array + { + $siblingZIndex = isset($siblingStackPlan['z_index']) && is_int($siblingStackPlan['z_index']) ? $siblingStackPlan['z_index'] : null; + $zIndexDecision = $this->zIndexDecision($isDecorativeUnderlay, $sourceZIndex, $siblingZIndex); + + return array( + 'manages_local_stacking' => ! empty($localReasons), + 'needs_isolation' => ! empty($isolationReasons), + 'local_reasons' => array_values(array_unique(array_merge($localReasons, $isolationReasons))), + 'sibling_role' => is_string($siblingStackPlan['role'] ?? null) ? $siblingStackPlan['role'] : null, + 'overlaps_sibling' => true === ($siblingStackPlan['overlaps_sibling'] ?? false), + 'z_index' => $zIndexDecision['z_index'], + 'z_index_reason' => $zIndexDecision['reason'], + ); + } + + /** + * @return array{z_index: int|null, reason: string|null} + */ + public function zIndexDecision(bool $isDecorativeUnderlay, ?int $sourceZIndex, ?int $siblingZIndex): array + { + if ( $isDecorativeUnderlay ) { + return array( + 'z_index' => $siblingZIndex ?? 0, + 'reason' => null !== $siblingZIndex ? self::STACK_REASON_SIBLING_LAYER_RANK : self::STACK_REASON_DECORATIVE_UNDERLAY, + ); + } + + if ( null !== $siblingZIndex ) { + return array('z_index' => $siblingZIndex, 'reason' => self::STACK_REASON_SIBLING_LAYER_RANK); + } + + if ( null !== $sourceZIndex ) { + return array('z_index' => $sourceZIndex, 'reason' => self::STACK_REASON_SOURCE_Z_INDEX); + } + + return array('z_index' => null, 'reason' => null); + } +} diff --git a/figma-transformer/src/Html/StaticHtmlCssRuleSet.php b/figma-transformer/src/Html/StaticHtmlCssRuleSet.php new file mode 100644 index 00000000..e7ce6ed1 --- /dev/null +++ b/figma-transformer/src/Html/StaticHtmlCssRuleSet.php @@ -0,0 +1,207 @@ + + */ + private array $nodeReadableNames = array(); + + public function resetReadableNames(): void + { + $this->nodeReadableNames = array(); + } + + public function rememberNodeReadableName(string $className, string $name, string $type): void + { + $this->nodeReadableNames[$className] = $this->sharedClassBaseName($name, $type); + } + + /** + * @param array $styles + * @return array + */ + public function styleDeclarationMap(array $styles): array + { + $map = array(); + foreach ( $styles as $style ) { + $parts = explode(':', $style, 2); + if ( 2 === count($parts) ) { + $map[trim($parts[0])] = trim($parts[1]); + } + } + + return $map; + } + + /** + * @param array $styles + */ + public function stylesDeclareProperty(array $styles, string $property): bool + { + foreach ( $styles as $style ) { + if ( is_string($style) && str_starts_with($style, $property . ':') ) { + return true; + } + } + + return false; + } + + /** + * @param array $node + * @return array + */ + public function negativeAutoLayoutSpacingRules(string $className, array $node): array + { + $layout = is_array($node['layout'] ?? null) ? $node['layout'] : array(); + if ( 'flex' !== ($layout['display'] ?? null) || ! $this->isFiniteNumeric($layout['item_spacing'] ?? null) || (float) $layout['item_spacing'] >= 0.0 ) { + return array(); + } + + $property = 'column' === ($layout['flex_direction'] ?? null) ? 'margin-top' : 'margin-left'; + return array('.' . $className . '>*+*{' . $property . ':' . $this->number((float) $layout['item_spacing']) . 'px}'); + } + + /** + * Collapse repeated per-node style rules into shared, readably-named CSS + * classes while leaving the original figma-node hooks in the HTML. + * + * @param array $cssRules + * @return array{rules: array, class_map: array} + */ + public function applySharedStyleClasses(array $cssRules, bool $hashReadableNames = false): array + { + $pattern = '/^\.(figma-node-[A-Za-z0-9_-]+)\{(.*)\}$/s'; + + $bodyToSelectors = array(); + $bodyFirstIndex = array(); + foreach ( $cssRules as $index => $rule ) { + if ( 1 === preg_match($pattern, $rule, $matches) ) { + $body = $matches[2]; + $bodyToSelectors[$body][] = $matches[1]; + if ( ! isset($bodyFirstIndex[$body]) ) { + $bodyFirstIndex[$body] = $index; + } + } + } + + $sharedOrder = array(); + foreach ( $bodyToSelectors as $body => $selectors ) { + if ( count($selectors) >= 2 ) { + $sharedOrder[$body] = $bodyFirstIndex[$body]; + } + } + asort($sharedOrder); + + $reserved = array( + 'figma-root' => true, + 'figma-link' => true, + 'figma-text-glyphs' => true, + 'figma-vector-asset' => true, + ); + $usedNames = array(); + $bodyToSharedClass = array(); + foreach ( array_keys($sharedOrder) as $body ) { + $firstSelector = $bodyToSelectors[$body][0]; + $base = $this->nodeReadableNames[$firstSelector] ?? 'style'; + if ( $hashReadableNames ) { + $base .= '-' . substr(sha1($body), 0, 8); + } + $name = $base; + $suffix = 2; + while ( isset($usedNames[$name]) || isset($reserved[$name]) ) { + $name = $base . '-' . $suffix; + ++$suffix; + } + $usedNames[$name] = true; + $bodyToSharedClass[$body] = $name; + } + + $rules = array(); + $emittedShared = array(); + $classMap = array(); + foreach ( $cssRules as $rule ) { + if ( 1 === preg_match($pattern, $rule, $matches) ) { + $selector = $matches[1]; + $body = $matches[2]; + if ( isset($bodyToSharedClass[$body]) ) { + $shared = $bodyToSharedClass[$body]; + $classMap[$selector] = $shared; + if ( ! isset($emittedShared[$shared]) ) { + $rules[] = '.' . $shared . '{' . $body . '}'; + $emittedShared[$shared] = true; + } + continue; + } + } + $rules[] = $rule; + } + + return array('rules' => $rules, 'class_map' => $classMap); + } + + /** + * @param array $classMap + */ + public function applySharedClassMapToHtml(string $html, array $classMap): string + { + foreach ( $classMap as $selector => $shared ) { + $html = str_replace( + 'class="' . $selector . '"', + 'class="' . $selector . ' ' . $shared . '"', + $html + ); + } + + return $html; + } + + private function sharedClassBaseName(string $name, string $type): string + { + $base = $this->slug($name); + if ( 'node' === $base || '' === $base ) { + $base = $this->slug($type); + if ( 'node' === $base || '' === $base ) { + $base = 'style'; + } + } + + if ( 1 !== preg_match('/^[a-z_]/', $base) ) { + $base = 'style-' . $base; + } + + return $base; + } + + private function slug(string $value): string + { + $slug = strtolower(preg_replace('/[^a-zA-Z0-9]+/', '-', $value) ?? ''); + $slug = trim($slug, '-'); + + return '' === $slug ? 'node' : $slug; + } + + private function isFiniteNumeric(mixed $value): bool + { + return is_numeric($value) && is_finite((float) $value); + } + + private function number(float $value): string + { + if ( ! is_finite($value) ) { + return '0'; + } + + return rtrim(rtrim(sprintf('%.3F', $value), '0'), '.'); + } +} diff --git a/figma-transformer/src/Html/StaticHtmlEmissionDiagnostics.php b/figma-transformer/src/Html/StaticHtmlEmissionDiagnostics.php new file mode 100644 index 00000000..5f1b2753 --- /dev/null +++ b/figma-transformer/src/Html/StaticHtmlEmissionDiagnostics.php @@ -0,0 +1,585 @@ + $coverage + * @param array $implicitRouteTargets + * @return array + */ + public function linkCoverageSummary(array $coverage, array $implicitRouteTargets): array + { + return array( + 'schema' => 'blocks-engine/figma-transformer/link-coverage/v1', + 'sources_found' => (int) ($coverage['sources_found'] ?? 0), + 'anchors_emitted' => (int) ($coverage['anchors_emitted'] ?? 0), + 'url_links' => (int) ($coverage['url_links'] ?? 0), + 'node_links' => (int) ($coverage['node_links'] ?? 0), + 'toc_links' => (int) ($coverage['toc_links'] ?? 0), + 'implicit_route_links' => (int) ($coverage['implicit_route_links'] ?? 0), + 'implicit_route_self_suppressed' => (int) ($coverage['implicit_route_self_suppressed'] ?? 0), + 'implicit_route_unresolved' => (int) ($coverage['implicit_route_unresolved'] ?? 0), + 'route_targets' => array_values($implicitRouteTargets), + 'implicit_route_unresolved_targets' => array_values(is_array($coverage['implicit_route_unresolved_targets'] ?? null) ? $coverage['implicit_route_unresolved_targets'] : array()), + 'implicit_route_self_suppressed_targets' => array_values(is_array($coverage['implicit_route_self_suppressed_targets'] ?? null) ? $coverage['implicit_route_self_suppressed_targets'] : array()), + 'unresolved' => (int) ($coverage['unresolved'] ?? 0), + 'unresolved_targets' => array_values(is_array($coverage['unresolved_targets'] ?? null) ? $coverage['unresolved_targets'] : array()), + ); + } + + /** + * @param array> $visualNodeMap + * @return array + */ + public function visualNodeMapSummary(array $visualNodeMap): array + { + $pagePathCounts = array(); + $emittedTagCounts = array(); + $emittedClassSamples = array(); + $withEmittedMetadata = 0; + $withPagePath = 0; + + foreach ( $visualNodeMap as $visualNode ) { + if ( ! is_array($visualNode) ) { + continue; + } + + $pagePath = isset($visualNode['page_path']) && is_scalar($visualNode['page_path']) ? (string) $visualNode['page_path'] : ''; + if ( '' !== $pagePath ) { + ++$withPagePath; + $pagePathCounts[$pagePath] = ($pagePathCounts[$pagePath] ?? 0) + 1; + } + + $emittedClass = isset($visualNode['emitted_class']) && is_scalar($visualNode['emitted_class']) ? (string) $visualNode['emitted_class'] : ''; + $emittedTag = isset($visualNode['emitted_tag']) && is_scalar($visualNode['emitted_tag']) ? (string) $visualNode['emitted_tag'] : ''; + if ( '' !== $emittedClass || '' !== $emittedTag ) { + ++$withEmittedMetadata; + } + if ( '' !== $emittedTag ) { + $emittedTagCounts[$emittedTag] = ($emittedTagCounts[$emittedTag] ?? 0) + 1; + } + if ( '' !== $emittedClass && count($emittedClassSamples) < 10 ) { + $emittedClassSamples[] = array( + 'node_id' => isset($visualNode['id']) && is_scalar($visualNode['id']) ? (string) $visualNode['id'] : '', + 'class' => $emittedClass, + 'page_path' => '' !== $pagePath ? $pagePath : null, + ); + } + } + + ksort($pagePathCounts); + ksort($emittedTagCounts); + + return array( + 'schema' => 'blocks-engine/figma-transformer/visual-node-map-summary/v1', + 'visual_node_count' => count($visualNodeMap), + 'nodes_with_emitted_metadata' => $withEmittedMetadata, + 'nodes_with_page_path' => $withPagePath, + 'page_path_counts' => $pagePathCounts, + 'emitted_tag_counts' => $emittedTagCounts, + 'emitted_class_samples' => $emittedClassSamples, + ); + } + + /** + * @return array + */ + public function cssDiagnostics(string $css): array + { + $tokens = array(); + if ( 1 === preg_match_all('/(? (string) $match[0], + 'offset' => (int) $match[1], + ); + } + } + + return array( + 'schema' => 'blocks-engine/figma-transformer/css-diagnostics/v1', + 'invalid_numeric_token_count' => count($tokens), + 'invalid_numeric_tokens' => array_slice($tokens, 0, 25), + ); + } + + /** + * @return array + */ + public function htmlArtifactDiagnostics(string $html, string $css): array + { + $elementCount = preg_match_all('/<([a-z][a-z0-9:-]*)(?:\s|>|\/)/i', $html, $tagMatches) ?: 0; + $tags = is_array($tagMatches[1] ?? null) ? array_map('strtolower', $tagMatches[1]) : array(); + $svgDrawingTags = array( + 'circle' => true, + 'clippath' => true, + 'defs' => true, + 'ellipse' => true, + 'g' => true, + 'line' => true, + 'lineargradient' => true, + 'mask' => true, + 'path' => true, + 'polygon' => true, + 'polyline' => true, + 'radialgradient' => true, + 'rect' => true, + 'stop' => true, + 'svg' => true, + 'use' => true, + ); + $structuralTags = array_values(array_filter($tags, static fn (string $tag): bool => ! isset($svgDrawingTags[$tag]))); + $structuralElementCount = count($structuralTags); + $divCount = count(array_filter($tags, static fn (string $tag): bool => 'div' === $tag)); + $semanticTags = array( + 'a' => true, + 'article' => true, + 'aside' => true, + 'button' => true, + 'figcaption' => true, + 'figure' => true, + 'footer' => true, + 'form' => true, + 'h1' => true, + 'h2' => true, + 'h3' => true, + 'h4' => true, + 'h5' => true, + 'h6' => true, + 'header' => true, + 'img' => true, + 'input' => true, + 'li' => true, + 'main' => true, + 'nav' => true, + 'ol' => true, + 'p' => true, + 'picture' => true, + 'section' => true, + 'ul' => true, + ); + $semanticElementCount = count(array_filter($structuralTags, static fn (string $tag): bool => isset($semanticTags[$tag]))); + $htmlBytes = strlen($html); + $inlineSvgBytes = 0; + $inlineSvgCount = preg_match_all('/]*>.*?<\/svg>/is', $html, $svgMatches) ?: 0; + foreach ( is_array($svgMatches[0] ?? null) ? $svgMatches[0] : array() as $svg ) { + $inlineSvgBytes += strlen((string) $svg); + } + + $divRatio = $structuralElementCount > 0 ? round($divCount / $structuralElementCount, 3) : 0.0; + $semanticDensity = $structuralElementCount > 0 ? round($semanticElementCount / $structuralElementCount, 3) : 0.0; + $inlineSvgRatio = $htmlBytes > 0 ? round($inlineSvgBytes / $htmlBytes, 3) : 0.0; + $breakpointLeaks = $this->breakpointOverrideLeaks($css); + $absoluteToFlowConversions = $this->absoluteToFlowConversions($css); + $mediaQueryCount = preg_match_all('/@media\s*\(max-width:[^)]+\)/i', $css) ?: 0; + $fixedWidthOverDesktopCount = 0; + if ( preg_match_all('/\bwidth:([0-9.]+)px\b/i', $css, $widthMatches) ) { + foreach ( is_array($widthMatches[1] ?? null) ? $widthMatches[1] : array() as $width ) { + if ( (float) $width > 1440.0 ) { + ++$fixedWidthOverDesktopCount; + } + } + } + $largeFixedCanvasHeight = false; + if ( preg_match_all('/\b(?:height|min-height):([0-9.]+)px\b/i', $css, $heightMatches) ) { + foreach ( is_array($heightMatches[1] ?? null) ? $heightMatches[1] : array() as $height ) { + if ( (float) $height >= 3000.0 ) { + $largeFixedCanvasHeight = true; + break; + } + } + } + + return array( + 'schema' => 'blocks-engine/figma-transformer/html-artifact-diagnostics/v1', + 'html_bytes' => $htmlBytes, + 'element_count' => $elementCount, + 'structural_element_count' => $structuralElementCount, + 'div_count' => $divCount, + 'div_ratio' => $divRatio, + 'semantic_element_count' => $semanticElementCount, + 'semantic_density' => $semanticDensity, + 'canvas_like_dom' => $structuralElementCount >= 80 && $divRatio >= 0.75 && $semanticDensity <= 0.15, + 'semantic_sparsity' => $structuralElementCount >= 40 && $semanticDensity <= 0.08, + 'inline_svg_count' => $inlineSvgCount, + 'inline_svg_bytes' => $inlineSvgBytes, + 'inline_svg_byte_ratio' => $inlineSvgRatio, + 'overlarge_inline_svg_ratio' => $htmlBytes >= 2048 && $inlineSvgBytes >= 32768 && $inlineSvgRatio >= 0.35, + 'media_query_count' => $mediaQueryCount, + 'fixed_width_over_desktop_count' => $fixedWidthOverDesktopCount, + 'large_fixed_canvas_height' => $largeFixedCanvasHeight, + 'desktop_canvas_without_responsive_breakpoints' => 0 === $mediaQueryCount && $largeFixedCanvasHeight && $structuralElementCount >= 80, + 'breakpoint_override_leak_count' => count($breakpointLeaks), + 'breakpoint_override_leaks' => array_slice($breakpointLeaks, 0, 25), + 'absolute_to_flow_conversion_count' => count($absoluteToFlowConversions), + 'absolute_to_flow_conversions' => array_slice($absoluteToFlowConversions, 0, 25), + ); + } + + /** + * Summarize positional decisions that are visually risky across arbitrary Figma files. + * + * @param array $layout + * @param array $decisionTraces + * @return array + */ + public function positionalParityDiagnostics(array $layout, string $css, array $decisionTraces): array + { + $decorativeUnderlays = is_array($layout['decorative_underlays']['nodes'] ?? null) ? $layout['decorative_underlays']['nodes'] : array(); + $fixedOverRootWidthUnderlays = array_values(array_filter( + $decorativeUnderlays, + static function (array $node): bool { + return isset($node['width'], $node['parent_width']) + && is_numeric($node['width']) + && is_numeric($node['parent_width']) + && (float) $node['width'] > (float) $node['parent_width'] + 1.0; + } + )); + + $offCanvasNodes = is_array($layout['off_canvas_visual_nodes'] ?? null) ? $layout['off_canvas_visual_nodes'] : array(); + $chromeOverflowNodes = array_values(array_filter( + $offCanvasNodes, + static function (array $node): bool { + $parentName = strtolower((string) ($node['parent_name'] ?? '')); + $name = strtolower((string) ($node['name'] ?? '')); + $class = strtolower((string) ($node['class'] ?? '')); + + return str_contains($parentName, 'header') + || str_contains($parentName, 'footer') + || str_contains($name, 'header') + || str_contains($name, 'footer') + || str_contains($class, 'header') + || str_contains($class, 'footer'); + } + )); + + $reasonCounts = is_array($decisionTraces['reason_counts'] ?? null) ? $decisionTraces['reason_counts'] : array(); + $domainCounts = is_array($decisionTraces['domain_counts'] ?? null) ? $decisionTraces['domain_counts'] : array(); + + return array( + 'schema' => 'blocks-engine/figma-transformer/positional-parity/v1', + 'full_bleed_viewport_width_count' => $this->cssDeclarationCount($css, 'width', '100vw'), + 'full_bleed_breakout_count' => $this->cssFullBleedBreakoutCount($css), + 'mirrored_transform_count' => $this->cssMirroredTransformCount($css), + 'reflected_full_bleed_count' => $this->cssReflectedFullBleedCount($css), + 'fixed_over_root_width_underlay_count' => count($fixedOverRootWidthUnderlays), + 'fixed_over_root_width_underlays' => array_slice(array_map(fn (array $node): array => $this->positionalParityNodeSample($node), $fixedOverRootWidthUnderlays), 0, 25), + 'chrome_overflow_count' => count($chromeOverflowNodes), + 'chrome_overflow_nodes' => array_slice(array_map(fn (array $node): array => $this->positionalParityNodeSample($node), $chromeOverflowNodes), 0, 25), + 'root_stacking_trace_count' => (int) ($domainCounts['stacking_context'] ?? 0), + 'root_stacking_reason_counts' => array_filter($reasonCounts, static fn (mixed $count, string $reason): bool => str_contains($reason, 'stack') || str_contains($reason, 'z_index') || str_contains($reason, 'overlap'), ARRAY_FILTER_USE_BOTH), + 'decision_trace_samples' => $this->positionalDecisionTraceSamples($decisionTraces), + ); + } + + /** + * @param array $decisionTraces + * @return array> + */ + private function positionalDecisionTraceSamples(array $decisionTraces): array + { + $samples = array(); + $traces = is_array($decisionTraces['samples'] ?? null) ? $decisionTraces['samples'] : array(); + $positionalDomains = array( + 'effective_geometry' => true, + 'stacking_context' => true, + 'transform_viewport' => true, + 'responsive_decision' => true, + ); + + foreach ( $traces as $trace ) { + if ( ! is_array($trace) ) { + continue; + } + + $domain = (string) ($trace['domain'] ?? ''); + if ( ! isset($positionalDomains[$domain]) ) { + continue; + } + + $samples[] = $this->positionalDecisionTraceSample($trace); + if ( count($samples) >= 25 ) { + break; + } + } + + return $samples; + } + + /** + * @param array $trace + * @return array + */ + private function positionalDecisionTraceSample(array $trace): array + { + $evidence = is_array($trace['evidence'] ?? null) ? $trace['evidence'] : array(); + + return array_filter(array( + 'domain' => isset($trace['domain']) && is_scalar($trace['domain']) ? (string) $trace['domain'] : null, + 'reason_code' => isset($trace['reason_code']) && is_scalar($trace['reason_code']) ? (string) $trace['reason_code'] : null, + 'decision' => isset($trace['decision']) && is_scalar($trace['decision']) ? (string) $trace['decision'] : null, + 'node_id' => isset($trace['node_id']) && is_scalar($trace['node_id']) ? (string) $trace['node_id'] : null, + 'name' => isset($trace['name']) && is_scalar($trace['name']) ? (string) $trace['name'] : null, + 'type' => isset($trace['type']) && is_scalar($trace['type']) ? (string) $trace['type'] : null, + 'class' => isset($trace['class']) && is_scalar($trace['class']) ? (string) $trace['class'] : null, + 'parent_id' => isset($trace['parent_id']) && is_scalar($trace['parent_id']) ? (string) $trace['parent_id'] : null, + 'page_path' => isset($trace['page_path']) && is_scalar($trace['page_path']) ? (string) $trace['page_path'] : null, + 'count' => isset($trace['count']) && is_numeric($trace['count']) ? (int) $trace['count'] : null, + 'source_geometry' => is_array($evidence['source_geometry'] ?? null) ? $evidence['source_geometry'] : null, + 'effective_css_geometry' => is_array($evidence['effective_css_geometry'] ?? null) ? $evidence['effective_css_geometry'] : null, + 'canvas_shell' => is_array($evidence['canvas_shell'] ?? null) ? $evidence['canvas_shell'] : null, + 'canvas_width_reason_code' => isset($evidence['canvas_width_reason_code']) && is_scalar($evidence['canvas_width_reason_code']) ? (string) $evidence['canvas_width_reason_code'] : null, + 'canvas_width_declarations' => is_array($evidence['canvas_width_declarations'] ?? null) ? $evidence['canvas_width_declarations'] : null, + 'full_bleed_reason_code' => isset($evidence['full_bleed_reason_code']) && is_scalar($evidence['full_bleed_reason_code']) ? (string) $evidence['full_bleed_reason_code'] : null, + 'full_bleed_declarations' => is_array($evidence['full_bleed_declarations'] ?? null) ? $evidence['full_bleed_declarations'] : null, + 'manages_local_stacking' => $evidence['manages_local_stacking'] ?? null, + 'needs_isolation' => $evidence['needs_isolation'] ?? null, + 'local_reasons' => is_array($evidence['local_reasons'] ?? null) ? $evidence['local_reasons'] : null, + 'sibling_role' => isset($evidence['sibling_role']) && is_scalar($evidence['sibling_role']) ? (string) $evidence['sibling_role'] : null, + 'overlaps_sibling' => $evidence['overlaps_sibling'] ?? null, + 'z_index' => isset($evidence['z_index']) && is_numeric($evidence['z_index']) ? (int) $evidence['z_index'] : null, + 'z_index_reason' => isset($evidence['z_index_reason']) && is_scalar($evidence['z_index_reason']) ? (string) $evidence['z_index_reason'] : null, + 'will_position_absolute' => $evidence['will_position_absolute'] ?? null, + 'transform' => isset($evidence['transform']) && is_scalar($evidence['transform']) ? (string) $evidence['transform'] : null, + 'matrix' => is_array($evidence['matrix'] ?? null) ? $evidence['matrix'] : null, + 'transformed_rect' => is_array($evidence['transformed_rect'] ?? null) ? $evidence['transformed_rect'] : null, + 'viewport_width' => isset($evidence['viewport_width']) && is_numeric($evidence['viewport_width']) ? $this->reportNumericValue((float) $evidence['viewport_width']) : null, + 'declarations' => is_array($evidence['declarations'] ?? null) ? $evidence['declarations'] : null, + ), static fn (mixed $value): bool => null !== $value && '' !== $value && array() !== $value); + } + + private function cssDeclarationCount(string $css, string $property, string $value): int + { + $pattern = '/' . preg_quote($property, '/') . '\s*:\s*' . preg_quote($value, '/') . '(?:[;}])/i'; + $count = preg_match_all($pattern, $css); + + return false === $count ? 0 : $count; + } + + private function cssFullBleedBreakoutCount(string $css): int + { + $count = preg_match_all('/left\s*:\s*50%[^}]*margin-left\s*:\s*-?50vw/i', $css); + + return false === $count ? 0 : $count; + } + + private function cssReflectedFullBleedCount(string $css): int + { + $count = preg_match_all('/margin-left\s*:\s*50vw[^}]*transform\s*:\s*matrix\s*\(\s*-1\s*,/i', $css); + + return false === $count ? 0 : $count; + } + + private function cssMirroredTransformCount(string $css): int + { + $count = preg_match_all('/transform\s*:\s*matrix\s*\(\s*(?:-[0-9.]+\s*,[^)]*|[^,]+,[^,]+,[^,]+,\s*-[0-9.]+)/i', $css); + + return false === $count ? 0 : $count; + } + + /** + * @param array $node + * @return array + */ + private function positionalParityNodeSample(array $node): array + { + return array_filter(array( + 'frame_id' => isset($node['frame_id']) && is_scalar($node['frame_id']) ? (string) $node['frame_id'] : null, + 'page_path' => isset($node['page_path']) && is_scalar($node['page_path']) ? (string) $node['page_path'] : null, + 'node_id' => isset($node['node_id']) && is_scalar($node['node_id']) ? (string) $node['node_id'] : null, + 'name' => isset($node['name']) && is_scalar($node['name']) ? (string) $node['name'] : null, + 'class' => isset($node['class']) && is_scalar($node['class']) ? (string) $node['class'] : null, + 'parent_id' => isset($node['parent_id']) && is_scalar($node['parent_id']) ? (string) $node['parent_id'] : null, + 'parent_name' => isset($node['parent_name']) && is_scalar($node['parent_name']) ? (string) $node['parent_name'] : null, + 'width' => isset($node['width']) && is_numeric($node['width']) ? $this->reportNumericValue((float) $node['width']) : null, + 'height' => isset($node['height']) && is_numeric($node['height']) ? $this->reportNumericValue((float) $node['height']) : null, + 'parent_width' => isset($node['parent_width']) && is_numeric($node['parent_width']) ? $this->reportNumericValue((float) $node['parent_width']) : null, + 'left' => isset($node['left']) && is_numeric($node['left']) ? $this->reportNumericValue((float) $node['left']) : null, + 'top' => isset($node['top']) && is_numeric($node['top']) ? $this->reportNumericValue((float) $node['top']) : null, + ), static fn (mixed $value): bool => null !== $value && '' !== $value); + } + + /** + * @return array> + */ + private function breakpointOverrideLeaks(string $css): array + { + $leaks = array(); + foreach ( $this->mediaBlocksFromCss($css) as $mediaBlock ) { + $breakpoint = (float) ($mediaBlock['breakpoint'] ?? 0.0); + if ( $breakpoint <= 0.0 || $breakpoint > 600.0 ) { + continue; + } + + $body = (string) ($mediaBlock['body'] ?? ''); + if ( 0 === (preg_match_all('/\.([a-z0-9_-]+)\{([^{}]*)\}/i', $body, $ruleMatches, PREG_SET_ORDER) ?: 0) ) { + continue; + } + + foreach ( $ruleMatches as $ruleMatch ) { + $class = (string) ($ruleMatch[1] ?? ''); + $declarations = (string) ($ruleMatch[2] ?? ''); + $declarationSamples = $this->desktopSizedResponsiveDeclarations($declarations); + if ( empty($declarationSamples) ) { + continue; + } + + $leaks[] = array( + 'breakpoint_px' => $breakpoint, + 'class' => $class, + 'declarations' => $declarationSamples, + ); + if ( count($leaks) >= 25 ) { + return $leaks; + } + } + } + + return $leaks; + } + + /** + * @return array> + */ + private function absoluteToFlowConversions(string $css): array + { + $baseAbsoluteClasses = array(); + if ( 0 < (preg_match_all('/\.([a-z0-9_-]+)\{([^{}]*)\}/i', preg_replace('/@media[^{}]*\{.*?\}/is', '', $css) ?? $css, $baseMatches, PREG_SET_ORDER) ?: 0) ) { + foreach ( $baseMatches as $baseMatch ) { + if ( str_contains((string) ($baseMatch[2] ?? ''), 'position:absolute') ) { + $baseAbsoluteClasses[(string) ($baseMatch[1] ?? '')] = true; + } + } + } + + $conversions = array(); + foreach ( $this->mediaBlocksFromCss($css) as $mediaBlock ) { + $body = (string) ($mediaBlock['body'] ?? ''); + if ( 0 === (preg_match_all('/\.([a-z0-9_-]+)\{([^{}]*)\}/i', $body, $ruleMatches, PREG_SET_ORDER) ?: 0) ) { + continue; + } + + foreach ( $ruleMatches as $ruleMatch ) { + $class = (string) ($ruleMatch[1] ?? ''); + $declarations = (string) ($ruleMatch[2] ?? ''); + if ( ! isset($baseAbsoluteClasses[$class]) ) { + continue; + } + if ( ! str_contains($declarations, 'position:relative') || ! str_contains($declarations, 'left:auto') || ! str_contains($declarations, 'top:auto') ) { + continue; + } + + $conversions[] = array( + 'breakpoint_px' => (float) ($mediaBlock['breakpoint'] ?? 0.0), + 'class' => $class, + 'declarations' => $this->compactCssDeclarations($declarations), + ); + if ( count($conversions) >= 25 ) { + return $conversions; + } + } + } + + return $conversions; + } + + /** + * @return array> + */ + private function mediaBlocksFromCss(string $css): array + { + $blocks = array(); + if ( 0 === (preg_match_all('/@media\s*\([^{}]*max-width\s*:\s*([0-9.]+)px[^{}]*\)\{/i', $css, $matches, PREG_OFFSET_CAPTURE) ?: 0) ) { + return $blocks; + } + + $matchCount = count($matches[0]); + for ( $i = 0; $i < $matchCount; $i++ ) { + $start = (int) $matches[0][$i][1]; + $bodyStart = $start + strlen((string) $matches[0][$i][0]); + $end = $i + 1 < $matchCount ? (int) $matches[0][$i + 1][1] : strlen($css); + $blocks[] = array( + 'breakpoint' => (float) $matches[1][$i][0], + 'body' => substr($css, $bodyStart, max(0, $end - $bodyStart)), + ); + } + + return $blocks; + } + + /** + * @return array + */ + private function desktopSizedResponsiveDeclarations(string $declarations): array + { + $samples = array(); + if ( 0 === (preg_match_all('/(?:^|;)(width|min-width|max-width|left|right):([^;{}]+)/i', $declarations, $matches, PREG_SET_ORDER) ?: 0) ) { + return $samples; + } + + foreach ( $matches as $match ) { + $property = strtolower(trim((string) ($match[1] ?? ''))); + $value = trim((string) ($match[2] ?? '')); + $numeric = $this->largestCssPixelValue($value); + if ( null === $numeric ) { + continue; + } + + $threshold = in_array($property, array('left', 'right'), true) ? 700.0 : 900.0; + if ( 'max-width' === $property ) { + $threshold = 1200.0; + } + if ( $numeric < $threshold ) { + continue; + } + + $samples[] = $property . ':' . $value; + } + + return array_values(array_unique($samples)); + } + + private function largestCssPixelValue(string $value): ?float + { + if ( 0 === (preg_match_all('/-?[0-9.]+px/', $value, $matches) ?: 0) ) { + return null; + } + + $largest = null; + foreach ( $matches[0] as $token ) { + $number = abs((float) str_replace('px', '', (string) $token)); + $largest = null === $largest ? $number : max($largest, $number); + } + + return $largest; + } + + /** + * @return array + */ + private function compactCssDeclarations(string $declarations): array + { + return array_values(array_filter(array_map('trim', explode(';', $declarations)), static fn (string $declaration): bool => '' !== $declaration)); + } + + private function reportNumericValue(mixed $value): mixed + { + if ( ! is_numeric($value) ) { + return null; + } + + $number = (float) $value; + if ( ! is_finite($number) ) { + return null; + } + + return floor($number) === $number ? (int) $number : $number; + } +} diff --git a/figma-transformer/src/Html/StaticHtmlEmitter.php b/figma-transformer/src/Html/StaticHtmlEmitter.php index 38b08bc8..8695a45a 100644 --- a/figma-transformer/src/Html/StaticHtmlEmitter.php +++ b/figma-transformer/src/Html/StaticHtmlEmitter.php @@ -9,16 +9,10 @@ */ final class StaticHtmlEmitter { - /** - * Minimum intrinsic width (px) at which a page root frame is rendered as a - * centered fluid container instead of a fixed canvas width. Roots at least - * this wide are treated as full-page desktop canvases that must fit the - * viewport; narrower roots are typically embedded components and keep their - * intrinsic size. - */ - private const FLUID_ROOT_MIN_WIDTH = 1024.0; - private const EXTERNAL_VECTOR_SVG_BYTES = 65536; + private const INLINE_VECTOR_SVG_BUDGET_BYTES = 32768; + + private LayoutGapResolver $layoutGapResolver; /** * @var array> @@ -40,34 +34,80 @@ final class StaticHtmlEmitter */ private array $generatedVectorSvgPathsByHash = array(); + private int $inlineVectorSvgBytes = 0; + private bool $renderTextGlyphPaths = false; private ?FontResolver $fontResolver = null; + private ?TypographyModel $typographyModel = null; + + /** + * @var array TypographyModel signature => font-size token name. + */ + private array $typographyTokenVars = array(); + private function fontResolver(): FontResolver { return $this->fontResolver ??= new FontResolver(); } + private function typographyModel(): TypographyModel + { + return $this->typographyModel ??= new TypographyModel($this->fontResolver()); + } + private ?DesignSystemExtractor $designSystemExtractor = null; private ?VectorSvgRenderer $vectorSvgRenderer = null; private ?StyleDeclarationBuilder $styleDeclarationBuilder = null; + private ?TextStyleDeclarationResolver $textStyleDeclarationResolver = null; + + private ?PaintStackResolver $paintStackResolver = null; + private ?TransformDiagnosticsBuilder $transformDiagnosticsBuilder = null; + private ?StaticHtmlEmissionDiagnostics $staticHtmlEmissionDiagnostics = null; + private ?EffectOverflowPolicy $effectOverflowPolicy = null; + private ?ClipMaskStyleResolver $clipMaskStyleResolver = null; + private ?CssPositioningResolver $cssPositioningResolver = null; + private ?CanvasShellResolver $canvasShellResolver = null; + + private ?PositioningStyleResolver $positioningStyleResolver = null; + private ?StickyLayoutCoordinator $stickyLayoutCoordinator = null; private ?HtmlArtifactAssembler $htmlArtifactAssembler = null; + private ?BreakpointMediaDiffBuilder $breakpointMediaDiffBuilder = null; + + private ?BreakpointDimensionPolicy $breakpointDimensionPolicy = null; + + private ?ChildLayerCompositionResolver $childLayerCompositionResolver = null; + + private ?LocalBorderShellClusterResolver $localBorderShellClusterResolver = null; + + private ?StaticHtmlCssRuleSet $staticHtmlCssRuleSet = null; + + public function __construct(?LayoutGapResolver $layoutGapResolver = null) + { + $this->layoutGapResolver = $layoutGapResolver ?? new LayoutGapResolver(); + $this->linkState = new StaticHtmlLinkState(); + } + + private ?LayoutFrameRoleClassifier $layoutFrameRoleClassifier = null; + + private ?StaticHtmlSemanticClassifier $staticHtmlSemanticClassifier = null; + private function designSystemExtractor(): DesignSystemExtractor { - return $this->designSystemExtractor ??= new DesignSystemExtractor(); + return $this->designSystemExtractor ??= new DesignSystemExtractor($this->fontResolver()); } private function vectorSvgRenderer(): VectorSvgRenderer @@ -92,16 +132,57 @@ private function styleDeclarationBuilder(): StyleDeclarationBuilder ); } + private function staticHtmlCssRuleSet(): StaticHtmlCssRuleSet + { + return $this->staticHtmlCssRuleSet ??= new StaticHtmlCssRuleSet(); + } + + private function textStyleDeclarationResolver(): TextStyleDeclarationResolver + { + return $this->textStyleDeclarationResolver ??= new TextStyleDeclarationResolver( + $this->typographyModel(), + fn (float $value): string => $this->number($value), + fn (mixed $value, mixed $opacity = null): ?string => $this->color($value, $opacity), + ); + } + + private function paintStackResolver(): PaintStackResolver + { + return $this->paintStackResolver ??= new PaintStackResolver( + fn (array $paint): ?string => $this->resolveAndMarkPaintAssetPath($paint), + fn (float $value): string => $this->number($value), + fn (mixed $value, mixed $opacity = null): ?string => $this->color($value, $opacity), + ); + } + private function transformDiagnosticsBuilder(): TransformDiagnosticsBuilder { return $this->transformDiagnosticsBuilder ??= new TransformDiagnosticsBuilder(); } + private function staticHtmlEmissionDiagnostics(): StaticHtmlEmissionDiagnostics + { + return $this->staticHtmlEmissionDiagnostics ??= new StaticHtmlEmissionDiagnostics(); + } + + private function visualGeometryResolver(): VisualGeometryResolver + { + return new VisualGeometryResolver($this->layoutIntentClassifier()); + } + private function effectOverflowPolicy(): EffectOverflowPolicy { return $this->effectOverflowPolicy ??= new EffectOverflowPolicy(); } + private function clipMaskStyleResolver(): ClipMaskStyleResolver + { + return $this->clipMaskStyleResolver ??= new ClipMaskStyleResolver( + $this->effectOverflowPolicy(), + fn (array $node): bool => $this->stickyLayoutCoordinator()->containsStickyPrimary($node), + ); + } + private function cssPositioningResolver(): CssPositioningResolver { return $this->cssPositioningResolver ??= new CssPositioningResolver( @@ -110,6 +191,32 @@ private function cssPositioningResolver(): CssPositioningResolver ); } + private function canvasShellResolver(): CanvasShellResolver + { + return $this->canvasShellResolver ??= new CanvasShellResolver( + $this->layoutFrameRoleClassifier(), + fn (array $node): bool => $this->isFreeformContainer($node), + fn (array $node): bool => $this->freeformContainerShouldUseFlow($node), + fn (array $node): bool => $this->hasAbsoluteChild($node), + fn (array $node): bool => $this->hasDecorativeFlexUnderlayChild($node), + $this->visualGeometryResolver(), + $this->breakpointDimensionPolicy(), + ); + } + + private function positioningStyleResolver(): PositioningStyleResolver + { + return $this->positioningStyleResolver ??= new PositioningStyleResolver( + $this->layoutIntentClassifier(), + $this->cssPositioningResolver(), + $this->canvasShellResolver(), + fn (array $node): bool => $this->isFreeformContainer($node), + fn (array $node): bool => $this->freeformContainerShouldUseFlow($node), + fn (array $node, array $parentNode): bool => $this->isDecorativeFlexUnderlay($node, $parentNode), + fn (array $node): bool => $this->hasDecorativeFlexUnderlayChild($node), + ); + } + private function stickyLayoutCoordinator(): StickyLayoutCoordinator { return $this->stickyLayoutCoordinator ??= new StickyLayoutCoordinator( @@ -125,24 +232,76 @@ private function htmlArtifactAssembler(): HtmlArtifactAssembler ); } + private function breakpointMediaDiffBuilder(): BreakpointMediaDiffBuilder + { + return $this->breakpointMediaDiffBuilder ??= new BreakpointMediaDiffBuilder( + $this->stickyLayoutCoordinator(), + fn (array $node): array => $this->nodeList($node), + fn (array $node, string $type, ?array $parentNode, ?array $grandParentNode): array => $this->styleDeclarations($node, $type, $parentNode, $grandParentNode), + fn (array $node, string $type, ?array $parentNode): mixed => $this->supportedVectorSvg($node, $type, $parentNode), + fn (array $child, array $parent): bool => $this->isFullyClippedDecorativeChild($child, $parent), + fn (array $node): bool => $this->isPaginationContainer($node), + fn (string $value): string => $this->sanitizeAttribute($value), + fn (string $value): string => $this->slug($value), + fn (float $value): string => $this->number($value), + null, + $this->breakpointDimensionPolicy(), + ); + } + + private function breakpointDimensionPolicy(): BreakpointDimensionPolicy + { + return $this->breakpointDimensionPolicy ??= new BreakpointDimensionPolicy(fn (float $value): string => $this->number($value)); + } + + private function childLayerCompositionResolver(): ChildLayerCompositionResolver + { + return $this->childLayerCompositionResolver ??= new ChildLayerCompositionResolver( + fn (array $node): ?string => $this->nodeAssetPath($node), + fn (float $value): string => $this->number($value), + ); + } + + private function localBorderShellClusterResolver(): LocalBorderShellClusterResolver + { + return $this->localBorderShellClusterResolver ??= new LocalBorderShellClusterResolver(); + } + private function layoutIntentClassifier(): LayoutIntentClassifier { return new LayoutIntentClassifier($this->assetsById); } - /** - * Resolved destination-node-id => page-path map used to turn NODE/prototype links into slug hrefs. - * - * @var array - */ - private array $linkTargetPaths = array(); + private function layoutFrameRoleClassifier(): LayoutFrameRoleClassifier + { + return $this->layoutFrameRoleClassifier ??= new LayoutFrameRoleClassifier(); + } - /** - * Running link-coverage tallies populated while emitting nodes. - * - * @var array - */ - private array $linkCoverage = array(); + private function staticHtmlSemanticClassifier(): StaticHtmlSemanticClassifier + { + return $this->staticHtmlSemanticClassifier ??= new StaticHtmlSemanticClassifier( + $this->layoutIntentClassifier(), + array( + 'nodeList' => fn (array $node): array => $this->nodeList($node), + 'textContent' => fn (array $node, ?array $parentNode = null): string => $this->textContent($node, $parentNode), + 'textDescendantCount' => fn (array $node): int => $this->textDescendantCount($node), + 'subtreePlainText' => fn (array $node): string => $this->subtreePlainText($node), + 'nodePlainText' => fn (array $node): string => $this->nodePlainText($node), + 'boxValue' => fn (array $node, string $key): ?float => $this->boxValue($node, $key), + 'backgroundColor' => fn (array $node): ?string => $this->backgroundColor($node), + 'cornerRadius' => fn (array $node): float => $this->cornerRadius($node), + 'hasStrokePaint' => fn (array $node): bool => $this->hasStrokePaint($node), + 'nodeAssetPath' => fn (array $node): ?string => $this->nodeAssetPath($node), + 'subtreeHasRenderableVector' => fn (array $node): bool => $this->subtreeHasRenderableVector($node), + 'listItemIds' => fn (array $container): array => $this->listItemIds($container), + 'listLooksOrdered' => fn (array $container): bool => $this->listLooksOrdered($container), + 'headingLevel' => fn (array $node, string $lowerName, int $depth, ?array $parentNode = null): ?string => $this->headingLevel($node, $lowerName, $depth, $parentNode), + 'sanitizeAttribute' => fn (string $value): string => $this->sanitizeAttribute($value), + ) + ); + } + + private StaticHtmlLinkState $linkState; /** * Page-relative typographic hierarchy: rounded font-size key => heading tag @@ -187,13 +346,25 @@ private function layoutIntentClassifier(): LayoutIntentClassifier private int $sectionDepth = 0; /** - * Maps each per-node CSS class (the `figma-node-*` hook) to a human-readable - * base name derived from the node's name/role. Used to mint shared, - * authored-looking class names when several nodes share identical styles. + * Source node id => emitted DOM metadata used to connect result JSON back to + * the emitted HTML/CSS artifact. * + * @var array + */ + private array $emittedNodeMetadata = array(); + + /** * @var array */ - private array $nodeReadableNames = array(); + private array $suppressedVisualNodeIds = array(); + + /** + * Stable reason traces for behavior decisions that suppress, re-route, or + * normalize source nodes without changing the emitted artifact contract. + * + * @var array> + */ + private array $decisionTraces = array(); /** * @param array $scenegraph Normalized Figma scenegraph. @@ -206,16 +377,20 @@ public function emit(array $scenegraph, array $options = array()): array $this->usedAssetPaths = array(); $this->generatedAssetFiles = array(); $this->generatedVectorSvgPathsByHash = array(); - $this->nodeReadableNames = array(); + $this->inlineVectorSvgBytes = 0; + $this->staticHtmlCssRuleSet()->resetReadableNames(); + $this->emittedNodeMetadata = array(); + $this->suppressedVisualNodeIds = array(); + $this->decisionTraces = array(); $this->stickyLayoutCoordinator()->reset(); - $this->linkTargetPaths = $this->normalizeLinkTargetPaths($options); - $this->linkCoverage = $this->newLinkCoverage(); + $this->linkState->resetForSinglePage($this->normalizeLinkTargetPaths($options)); $title = $this->sanitizeText((string) ($scenegraph['name'] ?? 'Figma Site')); $nodes = $this->nodeList($scenegraph); + $pagePath = (string) ($options['static_site_page_path'] ?? 'index.html'); $this->stickyLayoutCoordinator()->detectStickyGhostCandidates($nodes); $this->listItemIdCache = array(); $this->prepareHeadingRanking($nodes); - $this->prepareHeadingAnchors($nodes, 'index.html'); + $this->prepareHeadingAnchors($nodes, $pagePath); $diagnostics = array(); $nodeStyleDiagnostics = array(); $assetFiles = $this->normalizeAssets($scenegraph['assets'] ?? array(), $diagnostics); @@ -227,6 +402,8 @@ public function emit(array $scenegraph, array $options = array()): array $cssRules = $this->htmlArtifactAssembler()->baseCssRules($this->renderTextGlyphPaths); $operatorFontCss = $this->fontCss($options); $familyOverrides = $this->fontFamilyOverrides($options); + $designSystem = $this->designSystemExtractor()->extract($scenegraph); + $this->typographyTokenVars = is_array($designSystem['type_token_map'] ?? null) ? $designSystem['type_token_map'] : array(); foreach ( $nodes as $node ) { if ( ! is_array($node) ) { @@ -237,16 +414,16 @@ public function emit(array $scenegraph, array $options = array()): array $assetFiles = array_merge($this->referencedAssetFiles($assetFiles), array_values($this->generatedAssetFiles)); - $shared = $this->applySharedStyleClasses($cssRules); + $shared = $this->staticHtmlCssRuleSet()->applySharedStyleClasses($cssRules); $cssRules = $shared['rules']; - $body = $this->applySharedClassMapToHtml($body, $shared['class_map']); + $body = $this->staticHtmlCssRuleSet()->applySharedClassMapToHtml($body, $shared['class_map']); - $fontFamilies = $this->fontFamilies($nodeStyleDiagnostics); - $fontUsage = $this->fontUsage($nodeStyleDiagnostics); + $cssWithoutFontCss = $this->htmlArtifactAssembler()->stylesheet('', (string) $designSystem['css'], $cssRules); + $fontUsage = $this->fontUsage($nodeStyleDiagnostics, $cssWithoutFontCss, $body); + $fontFamilies = array_column($fontUsage, 'family'); $fontResolution = $this->fontResolver()->resolve($fontUsage, $operatorFontCss, $familyOverrides); $fontCss = (string) $fontResolution['css']; - $designSystem = $this->designSystemExtractor()->extract($scenegraph); foreach ( $this->designSystemDiagnostics($designSystem) as $diagnostic ) { $diagnostics[] = $diagnostic; } @@ -257,7 +434,7 @@ public function emit(array $scenegraph, array $options = array()): array 'path' => 'index.html', 'role' => 'entrypoint', 'mime_type' => 'text/html', - 'content' => $this->htmlArtifactAssembler()->htmlDocument($title, 'style.css', $body), + 'content' => $this->htmlArtifactAssembler()->htmlDocument($title, 'style.css', $body, $this->headMetadata($options, $pagePath, html_entity_decode($title, ENT_QUOTES | ENT_HTML5, 'UTF-8'))), ), array( 'path' => 'style.css', @@ -271,7 +448,9 @@ public function emit(array $scenegraph, array $options = array()): array $files[] = $assetFile; } - $files = (new InlineCssFileInjector())->inject($files, $css); + if ( false !== ($options['inline_css'] ?? true) ) { + $files = (new InlineCssFileInjector())->inject($files, $css); + } $visualNodeMap = $this->visualNodeMap($nodes); $transformDiagnostics = $this->transformDiagnostics($nodes, $visualNodeMap, $assetFiles, $fontFamilies, $fontUsage, $fontResolution, $css, $diagnostics, $body); @@ -298,8 +477,10 @@ public function emit(array $scenegraph, array $options = array()): array 'font_css_supplied' => (bool) $fontResolution['operator_supplied'], 'render_text_glyph_paths' => $this->renderTextGlyphPaths, 'design_system' => array( - 'coverage' => $designSystem['coverage'], - 'frame_names' => $designSystem['frame_names'], + 'coverage' => $designSystem['coverage'], + 'frame_names' => $designSystem['frame_names'], + 'type_token_map' => $designSystem['type_token_map'] ?? array(), + 'materialized_node_classes' => $designSystem['materialized_node_classes'] ?? array(), ), 'transform_diagnostics' => $transformDiagnostics, ), @@ -322,10 +503,21 @@ public function emitSite(array $scenegraph, array $pagePlan, array $options = ar $this->usedAssetPaths = array(); $this->generatedAssetFiles = array(); $this->generatedVectorSvgPathsByHash = array(); - $this->nodeReadableNames = array(); + $this->inlineVectorSvgBytes = 0; + $this->staticHtmlCssRuleSet()->resetReadableNames(); + $this->emittedNodeMetadata = array(); + $this->suppressedVisualNodeIds = array(); + $this->decisionTraces = array(); + $this->breakpointMediaDiffBuilder()->resetDecisionTraces(); $this->stickyLayoutCoordinator()->reset(); - $this->linkTargetPaths = $this->linkTargetPathsFromPagePlan($pagePlan, $options); - $this->linkCoverage = $this->newLinkCoverage(); + $implicitRoutePagePlan = is_array($options['implicit_route_page_plan'] ?? null) ? $options['implicit_route_page_plan'] : $pagePlan; + $implicitRouteData = $this->implicitRouteDataFromPagePlan($implicitRoutePagePlan, $scenegraph); + $this->linkState->resetForSite( + $this->linkTargetPathsFromPagePlan($pagePlan, $options), + $this->entrypointPathFromPagePlan($implicitRoutePagePlan), + $implicitRouteData['paths'], + $implicitRouteData['targets'] + ); $title = $this->sanitizeText((string) ($scenegraph['name'] ?? 'Figma Site')); $diagnostics = array(); $nodeStyleDiagnostics = array(); @@ -335,6 +527,8 @@ public function emitSite(array $scenegraph, array $pagePlan, array $options = ar $cssRules = $this->htmlArtifactAssembler()->baseCssRules($this->renderTextGlyphPaths); $operatorFontCss = $this->fontCss($options); $familyOverrides = $this->fontFamilyOverrides($options); + $designSystem = $this->designSystemExtractor()->extract($scenegraph); + $this->typographyTokenVars = is_array($designSystem['type_token_map'] ?? null) ? $designSystem['type_token_map'] : array(); $files = array(); $pages = array(); $renderedNodes = array(); @@ -404,16 +598,17 @@ public function emitSite(array $scenegraph, array $pagePlan, array $options = ar // A planned page is a single wrapping frame; its bands are its // direct children one level down. $this->sectionDepth = 1; + $this->inlineVectorSvgBytes = 0; $body = $this->emitNode($frameNode, $cssRules, $diagnostics, $nodeStyleDiagnostics, 0, null); $files[] = array( 'path' => $path, 'role' => true === ($page['entrypoint'] ?? false) ? 'entrypoint' : 'document', 'mime_type' => 'text/html', - 'content' => $this->htmlArtifactAssembler()->htmlDocument($this->sanitizeText($pageName), $this->stylesheetHref($path), $body), + 'content' => $this->htmlArtifactAssembler()->htmlDocument($this->sanitizeText($pageName), $this->stylesheetHref($path), $body, $this->headMetadata($options, $path, $pageName)), ); $renderedNodes[] = $frameNode; - foreach ( $this->breakpointMediaBlocks($page, $frameNode, $nodeMap) as $mediaBlock ) { + foreach ( $this->breakpointMediaDiffBuilder()->buildMediaBlocks($page, $frameNode, $nodeMap) as $mediaBlock ) { $mediaBlocks[] = $mediaBlock; } @@ -432,6 +627,7 @@ public function emitSite(array $scenegraph, array $pagePlan, array $options = ar $this->prepareHeadingRanking($fallbackNodes); $this->prepareHeadingAnchors($fallbackNodes, 'index.html'); $this->sectionDepth = $this->sectionDepthFor($fallbackNodes); + $this->inlineVectorSvgBytes = 0; foreach ( $fallbackNodes as $node ) { if ( ! is_array($node) ) { continue; @@ -441,7 +637,7 @@ public function emitSite(array $scenegraph, array $pagePlan, array $options = ar 'path' => 'index.html', 'role' => 'entrypoint', 'mime_type' => 'text/html', - 'content' => $this->htmlArtifactAssembler()->htmlDocument($title, 'style.css', $body), + 'content' => $this->htmlArtifactAssembler()->htmlDocument($title, 'style.css', $body, $this->headMetadata($options, 'index.html', html_entity_decode($title, ENT_QUOTES | ENT_HTML5, 'UTF-8'))), ); $renderedNodes[] = $node; } @@ -449,21 +645,22 @@ public function emitSite(array $scenegraph, array $pagePlan, array $options = ar $assetFiles = array_merge($this->referencedAssetFiles($assetFiles), array_values($this->generatedAssetFiles)); - $shared = $this->applySharedStyleClasses($cssRules, true); + $shared = $this->staticHtmlCssRuleSet()->applySharedStyleClasses($cssRules, true); $cssRules = $shared['rules']; if ( ! empty($shared['class_map']) ) { foreach ( $files as $fileIndex => $file ) { if ( 'text/html' === ($file['mime_type'] ?? '') && isset($file['content']) ) { - $files[$fileIndex]['content'] = $this->applySharedClassMapToHtml((string) $file['content'], $shared['class_map']); + $files[$fileIndex]['content'] = $this->staticHtmlCssRuleSet()->applySharedClassMapToHtml((string) $file['content'], $shared['class_map']); } } } - $fontFamilies = $this->fontFamilies($nodeStyleDiagnostics); - $fontUsage = $this->fontUsage($nodeStyleDiagnostics); + $htmlForFontUsage = $this->htmlArtifactAssembler()->htmlFilesContent($files); + $cssWithoutFontCss = $this->htmlArtifactAssembler()->stylesheet('', (string) $designSystem['css'], $cssRules, $mediaBlocks, true); + $fontUsage = $this->fontUsage($nodeStyleDiagnostics, $cssWithoutFontCss, $htmlForFontUsage); + $fontFamilies = array_column($fontUsage, 'family'); $fontResolution = $this->fontResolver()->resolve($fontUsage, $operatorFontCss, $familyOverrides); $fontCss = (string) $fontResolution['css']; - $designSystem = $this->designSystemExtractor()->extract($scenegraph); foreach ( $this->designSystemDiagnostics($designSystem) as $diagnostic ) { $diagnostics[] = $diagnostic; } @@ -479,7 +676,9 @@ public function emitSite(array $scenegraph, array $pagePlan, array $options = ar $files[] = $assetFile; } - $files = (new InlineCssFileInjector())->inject($files, $css); + if ( true === ($options['inline_css'] ?? false) ) { + $files = (new InlineCssFileInjector())->inject($files, $css); + } $visualNodeMap = $this->visualNodeMap($renderedNodes); $transformDiagnostics = $this->transformDiagnostics($renderedNodes, $visualNodeMap, $assetFiles, $fontFamilies, $fontUsage, $fontResolution, $css, $diagnostics, $this->htmlArtifactAssembler()->htmlFilesContent($files)); @@ -507,8 +706,10 @@ public function emitSite(array $scenegraph, array $pagePlan, array $options = ar 'font_css_supplied' => (bool) $fontResolution['operator_supplied'], 'render_text_glyph_paths' => $this->renderTextGlyphPaths, 'design_system' => array( - 'coverage' => $designSystem['coverage'], - 'frame_names' => $designSystem['frame_names'], + 'coverage' => $designSystem['coverage'], + 'frame_names' => $designSystem['frame_names'], + 'type_token_map' => $designSystem['type_token_map'] ?? array(), + 'materialized_node_classes' => $designSystem['materialized_node_classes'] ?? array(), ), 'transform_diagnostics' => $transformDiagnostics, ), @@ -520,218 +721,15 @@ public function emitSite(array $scenegraph, array $pagePlan, array $options = ar ); } - /** - * Build the `@media (max-width: …)` CSS blocks for one responsive page. - * - * The primary (widest) variant frame is already rendered as the base layout - * by {@see emitSite}. For every narrower breakpoint variant this walks the - * variant frame, computes per-node style declarations with the same - * machinery the base used, maps each variant node onto its base counterpart - * by structural position, and emits only the declarations that DIFFER from - * the base inside a `max-width` media block keyed on the variant viewport. - * - * Single-variant pages return an empty list, so they render exactly as - * before with no `@media` output. - * - * @param array $page Planned page (carries `variants`). - * @param array $baseNode Primary variant frame node. - * @param array> $nodeMap Id => node lookup. - * @return array - */ - private function breakpointMediaBlocks(array $page, array $baseNode, array $nodeMap): array - { - $variants = is_array($page['variants'] ?? null) ? array_values($page['variants']) : array(); - if ( count($variants) < 2 ) { - return array(); - } - - $baseStyles = array(); - $this->collectVariantNodeStyles($baseNode, 0, null, null, 'r', $baseStyles); - - // Derive the primary (base) viewport width from the variants list so we - // can compute midpoint breakpoints between adjacent variant widths. - $primaryViewportWidth = null; - foreach ( $variants as $variant ) { - if ( is_array($variant) && true === ($variant['primary'] ?? false) && is_numeric($variant['viewport_width'] ?? null) ) { - $primaryViewportWidth = (float) $variant['viewport_width']; - break; - } - } - - $blocks = array(); - // Variants are ordered widest-first, so iterating in order emits the - // narrower breakpoints later in the cascade — exactly the precedence - // overlapping `max-width` queries need. - // Track the previously seen (wider) viewport width so each breakpoint - // keys at the midpoint between adjacent variant widths rather than at - // the narrow variant's own width, which would be too narrow for most - // browsers and phones. - $prevViewportWidth = $primaryViewportWidth; - foreach ( $variants as $variant ) { - if ( ! is_array($variant) || true === ($variant['primary'] ?? false) ) { - continue; - } - - $variantId = isset($variant['frame_id']) && is_scalar($variant['frame_id']) ? (string) $variant['frame_id'] : ''; - $viewportWidth = $variant['viewport_width'] ?? null; - if ( '' === $variantId || ! isset($nodeMap[$variantId]) || ! is_numeric($viewportWidth) ) { - continue; - } - - $variantStyles = array(); - $this->collectVariantNodeStyles($nodeMap[$variantId], 0, null, null, 'r', $variantStyles); - - $rules = $this->breakpointDiffRules($baseStyles, $variantStyles); - if ( empty($rules) ) { - $prevViewportWidth = (float) $viewportWidth; - continue; - } - - // Key the breakpoint at the midpoint between this variant and its - // next-wider neighbour (the previous iteration, or the primary). - // Midpoint avoids keying at the narrow variant's own width (e.g. - // 390px) which would leave most desktop-resized browsers outside - // the mobile styles. Falls back to the variant's own width when no - // wider neighbour width is known. - if ( null !== $prevViewportWidth && $prevViewportWidth > (float) $viewportWidth ) { - $breakpointPx = (int) round(($prevViewportWidth + (float) $viewportWidth) / 2); - } else { - $breakpointPx = (int) round((float) $viewportWidth); - } - - $blocks[] = '@media (max-width:' . $this->number((float) $breakpointPx) . 'px){' - . "\n" . implode("\n", $rules) . "\n}"; - - $prevViewportWidth = (float) $viewportWidth; - } - - return $blocks; - } - - /** - * Walk a variant frame and record each node's emitted class name and ordered - * style declarations, keyed by a deterministic structural path. The - * traversal mirrors {@see emitNode} (same child skipping and the same - * BOOLEAN_OPERATION vector short-circuit) so a node at a given position - * always resolves to the same key across breakpoint variants — that key is - * what lets base and narrower styles be diffed without re-deriving layout. - * - * @param array $node - * @param array $map pathKey => array{class: string, styles: array} - */ - private function collectVariantNodeStyles(array $node, int $depth, ?array $parentNode, ?array $grandParentNode, string $pathKey, array &$map): void - { - if ( $this->stickyLayoutCoordinator()->isSuppressedStickyGhost($node) ) { - return; - } - - $id = $this->sanitizeAttribute((string) ($node['id'] ?? '')); - $name = (string) ($node['name'] ?? ''); - $type = strtoupper((string) ($node['type'] ?? 'FRAME')); - $className = 'figma-node-' . $this->slug($id . '-' . $name); - $styles = $this->stickyLayoutCoordinator()->stickyAwareStyleDeclarations($node, $this->styleDeclarations($node, $type, $parentNode, $grandParentNode)); - - $map[$pathKey] = array( - 'class' => $className, - 'styles' => $styles, - 'contains_sticky' => $this->stickyLayoutCoordinator()->containsStickyPrimary($node), - 'node' => $node, - ); - - $vectorSvg = $this->supportedVectorSvg($node, $type, $parentNode); - if ( 'BOOLEAN_OPERATION' === $type && null !== $vectorSvg ) { - return; - } - - $childOrdinal = 0; - foreach ( $this->nodeList($node) as $child ) { - if ( ! is_array($child) || $this->stickyLayoutCoordinator()->isSuppressedStickyGhost($child) || $this->isFullyClippedDecorativeChild($child, $node) ) { - continue; - } - - $childKey = $pathKey . '/' . $this->breakpointChildKey($child, $childOrdinal); - $this->collectVariantNodeStyles($child, $depth + 1, $node, $parentNode, $childKey, $map); - ++$childOrdinal; - } - } - - /** - * @param array $node - */ - private function breakpointChildKey(array $node, int $ordinal): string - { - $type = strtoupper((string) ($node['type'] ?? 'FRAME')); - foreach ( array('figma_component_source_id', 'source_id') as $key ) { - if ( isset($node[$key]) && is_scalar($node[$key]) && '' !== (string) $node[$key] ) { - return 'source:' . $this->slug($type . '-' . (string) $node[$key]); - } - } - - return $ordinal . ':' . $type; - } - - /** - * Diff a narrower variant's per-node styles against the base styles, keeping - * only declarations whose value changed (or that the base lacked). Rules are - * keyed on the BASE class name so the overrides land on the already-rendered - * elements, and are emitted in base-traversal order for deterministic output. - * - * @param array> $baseStyles - * @param array> $variantStyles - * @return array - */ - private function breakpointDiffRules(array $baseStyles, array $variantStyles): array - { - $rules = array(); - foreach ( $baseStyles as $pathKey => $base ) { - if ( ! isset($variantStyles[$pathKey]) ) { - continue; - } - - $baseMap = $this->styleDeclarationMap(is_array($base['styles'] ?? null) ? $base['styles'] : array()); - $variantDeclarations = is_array($variantStyles[$pathKey]['styles'] ?? null) ? $variantStyles[$pathKey]['styles'] : array(); - - $changed = array(); - $baseContainsSticky = true === ($base['contains_sticky'] ?? false); - $baseNode = is_array($base['node'] ?? null) ? $base['node'] : array(); - $preservePaginationRow = ! empty($baseNode) && $this->isPaginationContainer($baseNode); - foreach ( $variantDeclarations as $declaration ) { - $parts = explode(':', (string) $declaration, 2); - if ( 2 !== count($parts) ) { - continue; - } - - $property = trim($parts[0]); - $value = trim($parts[1]); - if ( $baseContainsSticky && 'overflow' === $property ) { - continue; - } - if ( $preservePaginationRow && in_array($property, array('height', 'flex-wrap', 'align-content'), true) ) { - continue; - } - if ( ! array_key_exists($property, $baseMap) || $baseMap[$property] !== $value ) { - $changed[] = $property . ':' . $value; - } - } - - if ( empty($changed) ) { - continue; - } - - $rules[] = '.' . (string) $base['class'] . '{' . implode(';', $changed) . '}'; - } - - return $rules; - } - /** * @param array $node * @param array $cssRules * @param array> $diagnostics */ - private function emitNode(array $node, array &$cssRules, array &$diagnostics, array &$nodeStyleDiagnostics, int $depth, ?array $parentNode, ?array $grandParentNode = null): string + private function emitNode(array $node, array &$cssRules, array &$diagnostics, array &$nodeStyleDiagnostics, int $depth, ?array $parentNode, ?array $grandParentNode = null, bool $insideForm = false, bool $insideLink = false): string { if ( $this->stickyLayoutCoordinator()->isSuppressedStickyGhost($node) ) { + $this->recordDecisionTrace('layout_suppression', 'sticky_ghost_suppressed', $node, 'skip_node', $parentNode, array('depth' => $depth)); return ''; } @@ -741,6 +739,26 @@ private function emitNode(array $node, array &$cssRules, array &$diagnostics, ar // emitted as a top-level render root (depth 0, e.g. an explicitly // selected frame) still renders; hidden descendants never do. if ( $depth > 0 && false === ($node['visible'] ?? null) ) { + $this->recordDecisionTrace('layout_suppression', 'hidden_descendant_suppressed', $node, 'skip_node', $parentNode, array('depth' => $depth)); + return ''; + } + + if ( $depth > 0 && $this->isMaskOperatorNode($node) ) { + $this->recordDecisionTrace('layout_suppression', 'mask_source_suppressed', $node, 'skip_node', $parentNode, array('depth' => $depth)); + return ''; + } + + if ( $depth > 0 && $this->isNonRenderingVectorLayer($node) ) { + $this->recordDecisionTrace('vector_scaffold', 'non_rendering_vector_layer_suppressed', $node, 'skip_node', $parentNode, array('depth' => $depth)); + return ''; + } + + if ( $depth > 0 && $this->isInvisibleZeroAreaScaffold($node) ) { + $scaffoldId = isset($node['id']) && is_scalar($node['id']) ? (string) $node['id'] : ''; + if ( '' !== $scaffoldId ) { + $this->suppressedVisualNodeIds[$scaffoldId] = 'invisible-zero-area-scaffold'; + } + $this->recordDecisionTrace('layout_suppression', 'invisible_zero_area_scaffold_suppressed', $node, 'skip_node', $parentNode, array('depth' => $depth)); return ''; } @@ -754,55 +772,167 @@ private function emitNode(array $node, array &$cssRules, array &$diagnostics, ar // Multi-paragraph text splits into per-paragraph boxes so // `paragraphSpacing` lands as a margin; otherwise render the node // as a single element. - $text = $this->multiParagraphTextContent($node) ?? $this->textContent($node); + $text = $this->multiParagraphTextContent($node) ?? $this->packedNavigationTextContent($node, $parentNode) ?? $this->textContent($node, $parentNode); } } else { - $text = $this->textContent($node); + $text = $this->textContent($node, $parentNode); + } + $tag = $this->semanticTag($node, $type, $name, $depth, $parentNode, $grandParentNode); + $sourceTextList = 'TEXT' === $type ? $this->sourceTextListMarkup($node) : null; + if ( null !== $sourceTextList ) { + $tag = $sourceTextList['tag']; + $text = $sourceTextList['content']; + } + if ( $insideForm && 'form' === $tag ) { + $tag = 'div'; } - $tag = $this->semanticTag($node, $type, $name, $depth, $parentNode); $className = 'figma-node-' . $this->slug($id . '-' . $name); + if ( '' !== $id ) { + $this->emittedNodeMetadata[$id] = array( + 'class' => $className, + 'tag' => $tag, + 'page_path' => $this->currentPagePath, + ); + } $children = $this->nodeList($node); $content = $text; - $vectorSvg = $this->supportedVectorSvg($node, $type, $parentNode); - $assetPath = $this->nodeAssetPath($node); - $hasVectorAssetFallback = $this->isUnsupportedVectorType($type) && null !== $assetPath; + $nodeIntroducesLink = $this->nodeIntroducesLinkContext($node, $parentNode, $insideLink); + $inputAccessoryControl = 'div' === $tag && $this->isInputLike($node) && $this->hasFormControlAccessoryChildren($node); + $textareaAccessoryControl = 'div' === $tag && $this->isTextareaLike($node) && $this->hasFormControlAccessoryChildren($node); + $formControlAccessoryControl = $inputAccessoryControl || $textareaAccessoryControl; + $assetComposition = $this->nodeAssetComposition($node, $type, $parentNode); + $vectorSvg = $assetComposition['vector_svg']; + $hasVectorAssetFallback = $assetComposition['has_vector_asset_fallback']; + $buttonLayerComposition = 'button' === $tag ? $this->buttonLayerComposition($node, $children) : array('styles' => array(), 'suppressed_child_ids' => array()); if ( ! in_array($tag, array('input', 'textarea'), true) && ! ( 'BOOLEAN_OPERATION' === $type && null !== $vectorSvg ) && ! $this->vectorSvgComposesChildren($vectorSvg) ) { + $insertedAccessoryInput = false; + $childCompositionMaps = $this->childAssetCompositionMaps($children); + if ( ! empty($buttonLayerComposition['suppressed_child_ids']) ) { + $childCompositionMaps['suppressed_child_ids'] = array_merge($childCompositionMaps['suppressed_child_ids'], $buttonLayerComposition['suppressed_child_ids']); + } + $suppressRootOffCanvasChildren = 0 === $depth && $this->hasRootOffCanvasChildCluster($children, $node); + $localClusters = $this->localBorderShellClusters($node, $children); + $localClusterByFirstChildId = $localClusters['by_first_child_id']; + $localClusterMemberIds = $localClusters['member_ids']; foreach ( $children as $child ) { if ( is_array($child) ) { + if ( $this->isMaskOperatorNode($child) ) { + $this->recordDecisionTrace('layout_suppression', 'mask_source_suppressed', $child, 'skip_child', $node, array('depth' => $depth + 1)); + continue; + } + $childId = isset($child['id']) && is_scalar($child['id']) ? (string) $child['id'] : ''; + if ( '' !== $childId && isset($childCompositionMaps['suppressed_child_ids'][$childId]) ) { + $reason = $childCompositionMaps['suppressed_child_ids'][$childId]; + $this->suppressedVisualNodeIds[$childId] = $reason; + $this->recordDecisionTrace('layout_suppression', $reason, $child, 'skip_child', $node, array('depth' => $depth + 1)); + continue; + } + if ( '' !== $childId && isset($localClusterByFirstChildId[$childId]) ) { + $content .= $this->emitNode($localClusterByFirstChildId[$childId], $cssRules, $diagnostics, $nodeStyleDiagnostics, $depth + 1, $node, $parentNode, $insideForm || 'form' === $tag, $insideLink || $nodeIntroducesLink); + continue; + } + if ( '' !== $childId && isset($localClusterMemberIds[$childId]) ) { + continue; + } + $child = $this->applyChildAssetComposition($child, $childId, $childCompositionMaps); if ( $this->isFullyClippedDecorativeChild($child, $node) ) { + $this->recordDecisionTrace('layout_suppression', 'fully_clipped_decorative_child_suppressed', $child, 'skip_child', $node, array('depth' => $depth + 1)); + continue; + } + if ( $suppressRootOffCanvasChildren && $this->isFullyOffCanvasRootChild($child, $node) ) { + if ( '' !== $childId ) { + $this->suppressedVisualNodeIds[$childId] = 'root_off_canvas_child_suppressed'; + } + $this->recordDecisionTrace('layout_suppression', 'root_off_canvas_child_suppressed', $child, 'skip_child', $node, array('depth' => $depth + 1)); + continue; + } + if ( 'form' === $tag && $this->isSpatialFormControlLabel($child, $node) ) { + $this->recordDecisionTrace('source_loss_accounting', 'spatial_label_converted_to_form_control', $child, 'skip_child', $node, array('depth' => $depth + 1)); + continue; + } + if ( $formControlAccessoryControl && $this->isFormControlPlaceholderChild($child) ) { + $this->recordDecisionTrace('source_loss_accounting', 'placeholder_child_converted_to_form_control', $child, 'skip_child', $node, array('depth' => $depth + 1)); + if ( ! $insertedAccessoryInput ) { + $content .= $this->syntheticFormControlMarkup($node, $className, $textareaAccessoryControl ? 'textarea' : 'input'); + $cssRules[] = $this->syntheticFormControlResetCss($className, $textareaAccessoryControl ? 'textarea' : 'input'); + $insertedAccessoryInput = true; + } continue; } if ( 'li' === $tag && $this->isListMarkerTextChild($child) ) { + $this->recordDecisionTrace('source_loss_accounting', 'list_marker_text_suppressed', $child, 'skip_child', $node, array('depth' => $depth + 1)); continue; } - $content .= $this->emitNode($child, $cssRules, $diagnostics, $nodeStyleDiagnostics, $depth + 1, $node, $parentNode); + $content .= $this->emitNode($child, $cssRules, $diagnostics, $nodeStyleDiagnostics, $depth + 1, $node, $parentNode, $insideForm || 'form' === $tag, $insideLink || $nodeIntroducesLink); } } + if ( $formControlAccessoryControl && ! $insertedAccessoryInput ) { + $content .= $this->syntheticFormControlMarkup($node, $className, $textareaAccessoryControl ? 'textarea' : 'input', $parentNode); + $cssRules[] = $this->syntheticFormControlResetCss($className, $textareaAccessoryControl ? 'textarea' : 'input'); + } } + $vectorSvgMarkup = null; if ( null !== $vectorSvg ) { - $content = $this->vectorSvgMarkup($vectorSvg, $node, $type) . $content; + $vectorSvgMarkup = $this->vectorSvgMarkup($vectorSvg, $node, $type, $parentNode); + if ( '' !== trim($vectorSvgMarkup) ) { + $content = $vectorSvgMarkup . $content; + } } $hasRenderableVectorFallback = '' !== trim($content); + if ( $this->shouldSuppressNonRenderableUnsupportedVectorPlaceholder($node, $type, $vectorSvg, $hasVectorAssetFallback, $hasRenderableVectorFallback) ) { + if ( '' !== $id ) { + $this->suppressedVisualNodeIds[$id] = 'non_renderable_unsupported_vector_suppressed'; + } + $this->recordDecisionTrace('vector_scaffold', 'non_renderable_unsupported_vector_suppressed', $node, 'skip_node', $parentNode, array('reason' => 'zero_area_without_vector_source')); + + return ''; + } if ( $this->isUnsupportedVectorType($type) && null === $vectorSvg && ! $hasVectorAssetFallback && ! $hasRenderableVectorFallback ) { $diagnostics[] = array( 'severity' => 'warning', 'code' => 'unsupported_vector_node_placeholder', + 'reason_code' => 'unsupported_vector_node_placeholder', 'message' => 'Unsupported vector-like Figma node emitted as a static placeholder.', ) + $this->vectorPlaceholderDiagnostic($node, $type, $parentNode); + $this->recordDecisionTrace('vector_scaffold', 'unsupported_vector_node_placeholder', $node, 'emit_placeholder', $parentNode, $this->vectorPlaceholderDiagnostic($node, $type, $parentNode)); $content = ''; } - $styles = $this->styleDeclarations($node, $type, $parentNode, $grandParentNode); + $rendersInlineVectorSvg = null !== $vectorSvgMarkup && '' !== trim($vectorSvgMarkup); + $styles = $this->styleDeclarations($node, $type, $parentNode, $grandParentNode, $rendersInlineVectorSvg); + if ( ! empty($buttonLayerComposition['styles']) ) { + array_push($styles, ...$buttonLayerComposition['styles']); + } $styles = $this->stickyLayoutCoordinator()->stickyAwareStyleDeclarations($node, $styles); + if ( 'p' === $tag && $this->hasBodyTextNameIntent(strtolower($name)) && ! $this->hasExplicitUppercaseTextCase($node) ) { + $styles = array_values(array_filter($styles, static fn (string $style): bool => 'text-transform:uppercase' !== $style)); + } if ( ! empty($styles) ) { $cssRules[] = '.' . $className . '{' . implode(';', $styles) . '}'; - $this->nodeReadableNames[$className] = $this->sharedClassBaseName($name, $type); + foreach ( $this->negativeAutoLayoutSpacingRules($className, $node) as $rule ) { + $cssRules[] = $rule; + } + $this->staticHtmlCssRuleSet()->rememberNodeReadableName($className, $name, $type); + } + if ( $this->isSemanticListItemBodyText($node, $parentNode, $grandParentNode) && $this->textContainsLowercase($this->rawDecodedText($node)) && ! $this->hasExplicitUppercaseTextCase($node) ) { + $parentClassName = 'figma-node-' . $this->slug((string) ($parentNode['id'] ?? '') . '-' . (string) ($parentNode['name'] ?? 'Node')); + $cssRules[] = '.' . $parentClassName . '>.' . $className . '{text-transform:none}'; + } elseif ( 'p' === $tag && $this->hasBodyTextNameIntent(strtolower($name)) && ! $this->hasExplicitUppercaseTextCase($node) ) { + $cssRules[] = 'ol .' . $className . ',ul .' . $className . '{text-transform:none}'; + } + if ( in_array($tag, array('ol', 'ul'), true) && $this->listShouldRenderMarkers($node, null !== $sourceTextList) && ! $this->isChromeListContext($node, $parentNode, $grandParentNode) ) { + $cssRules[] = '.' . $className . '{list-style:' . ( 'ol' === $tag ? 'decimal' : 'disc' ) . ';padding-left:1.5em' . ( 'ol' === $tag ? ';counter-reset:figma-list-item' : '' ) . '}'; + } + if ( 'li' === $tag && null !== $parentNode && $this->isListItemOf($node, $parentNode) && $this->listShouldRenderMarkers($parentNode, false) && ! $this->isChromeListContext($node, $parentNode, $grandParentNode) ) { + $marker = $this->listLooksOrdered($parentNode) ? 'counter(figma-list-item) ". "' : '"\2022"'; + $cssRules[] = '.' . $className . '{position:relative}'; + $cssRules[] = '.' . $className . '::before{content:' . $marker . ';counter-increment:figma-list-item;display:inline-block;min-width:1.5em;margin-left:-1.5em;flex-shrink:0}'; } - $nodeStyleDiagnostics[] = $this->nodeStyleDiagnostic($node, $type, $className, $tag, $styles, $parentNode); + $nodeStyleDiagnostics[] = $this->nodeStyleDiagnostic($node, $type, $className, $tag, $styles, $parentNode, $rendersInlineVectorSvg); if ( 'TEXT' === $type ) { $paragraphSpacingDiagnostic = $this->paragraphSpacingDiagnostic($node); @@ -817,9 +947,13 @@ private function emitNode(array $node, array &$cssRules, array &$diagnostics, ar $attributes .= ' id="' . $this->sanitizeAttribute($anchorId) . '"'; } if ( in_array($tag, array('input', 'textarea'), true) ) { - $attributes .= $this->formControlAttributes($node, $tag); + $attributes .= $this->formControlAttributes($node, $tag, $parentNode); + } elseif ( 'ol' === $tag && null !== $sourceTextList && isset($sourceTextList['start']) ) { + $attributes .= ' start="' . $this->sanitizeAttribute((string) $sourceTextList['start']) . '"'; } elseif ( 'button' === $tag ) { $attributes .= $this->buttonControlAttributes($node); + } elseif ( 'form' === $tag ) { + $attributes .= $this->formAttributes($node); } if ( 'RECTANGLE' === $type && '' === $content ) { $attributes .= ' aria-hidden="true"'; @@ -836,88 +970,265 @@ private function emitNode(array $node, array &$cssRules, array &$diagnostics, ar $element = sprintf("<%1\$s%2\$s>%3\$s\n", $tag, $attributes, $content); } - return $this->wrapWithLink($node, $element, $diagnostics, $this->isButtonLike($node), $parentNode); + return $this->wrapComposedElementWithLink($node, $element, $diagnostics, $parentNode, $insideLink); } /** - * Selects a semantic HTML element for a node from its type, name, position, - * and content. Landmarks (header/nav/section/footer/article) come from - * structure and position; content tags (h1-h6/p/ul/li/button/span) come from - * the page-relative typographic hierarchy and node shape. Falls back to the - * historical name-based mapping and a generic section/div when no stronger - * signal exists. - * * @param array $node - * @param array|null $parentNode + * @return array{asset_path: string|null, vector_svg: string|null, has_vector_asset_fallback: bool} */ - private function semanticTag(array $node, string $type, string $name, int $depth, ?array $parentNode): string + private function nodeAssetComposition(array $node, string $type, ?array $parentNode): array { - $lowerName = strtolower($name); + $vectorSvg = $this->supportedVectorSvg($node, $type, $parentNode); + $assetPath = $this->nodeAssetPath($node); + if ( null !== $assetPath || $this->nodeHasCssMaskImage($node) ) { + $vectorSvg = null; + } - if ( 'TEXT' === $type ) { - // A label inside a button-like control is inline phrasing content. - if ( null !== $parentNode && $this->isButtonLike($parentNode) ) { - return 'span'; - } + return array( + 'asset_path' => $assetPath, + 'vector_svg' => $vectorSvg, + 'has_vector_asset_fallback' => $this->isUnsupportedVectorType($type) && null !== $assetPath, + ); + } - if ( null !== $parentNode && $this->isSemanticListItemNode($parentNode) ) { - return 'p'; - } + /** + * @param array $children + * @return array{clip_paths: array, image_mask_paths: array, suppressed_child_ids: array} + */ + private function childAssetCompositionMaps(array $children): array + { + return $this->childLayerCompositionResolver()->resolveChildMaps($children); + } + + /** + * @param array $child + * @param array{clip_paths: array, image_mask_paths: array, suppressed_child_ids: array} $compositionMaps + * @return array + */ + private function applyChildAssetComposition(array $child, string $childId, array $compositionMaps): array + { + return $this->childLayerCompositionResolver()->applyToChild($child, $childId, $compositionMaps); + } + + /** + * @param array $parent + * @param array $children + * @return array{by_first_child_id: array>, member_ids: array} + */ + private function localBorderShellClusters(array $parent, array $children): array + { + return $this->localBorderShellClusterResolver()->resolve($parent, $children); + } + + /** + * @param array $button + * @param array $children + * @return array{styles: array, suppressed_child_ids: array} + */ + private function buttonLayerComposition(array $button, array $children): array + { + $backgroundChild = $this->buttonBackgroundLayerChild($button, $children); + if ( null === $backgroundChild ) { + return array('styles' => array(), 'suppressed_child_ids' => array()); + } + + $childId = isset($backgroundChild['id']) && is_scalar($backgroundChild['id']) ? (string) $backgroundChild['id'] : ''; + if ( '' === $childId ) { + return array('styles' => array(), 'suppressed_child_ids' => array()); + } + + $styles = $this->buttonBackgroundLayerStyles($backgroundChild); + if ( empty($styles) ) { + return array('styles' => array(), 'suppressed_child_ids' => array()); + } + + return array( + 'styles' => $styles, + 'suppressed_child_ids' => array($childId => 'button_background_layer_composed_into_control'), + ); + } - $heading = $this->headingLevel($node, $lowerName, $depth, $parentNode); - if ( null !== $heading ) { - return $heading; + /** + * @param array $button + * @param array $children + * @return array|null + */ + private function buttonBackgroundLayerChild(array $button, array $children): ?array + { + $matches = array(); + foreach ( $children as $child ) { + if ( ! is_array($child) || ! $this->isSimpleButtonBackgroundLayer($child, $button) ) { + continue; } - return 'p'; + $matches[] = $child; } - $children = array_values(array_filter($this->nodeList($node), 'is_array')); + return 1 === count($matches) ? $matches[0] : null; + } - // List items: a repeated, structurally-similar child of a list container. - if ( null !== $parentNode && $this->isListItemOf($node, $parentNode) ) { - return 'li'; + /** + * @param array $child + * @param array $button + */ + private function isSimpleButtonBackgroundLayer(array $child, array $button): bool + { + if ( false === ($child['visible'] ?? true) || $this->isMaskOperatorNode($child) || '' !== trim($this->subtreePlainText($child)) ) { + return false; + } + if ( null !== $this->nodeAssetPath($child) || ! empty($this->nodeImagePaints($child)) ) { + return false; } - if ( $this->isTextareaLike($node) ) { - return 'textarea'; + $type = strtoupper((string) ($child['type'] ?? '')); + if ( ! in_array($type, array('RECTANGLE', 'ROUNDED_RECTANGLE', 'VECTOR', 'BOOLEAN_OPERATION'), true) ) { + return false; + } + if ( null === $this->backgroundColor($child) && empty($this->strokeStyles($child)) ) { + return false; + } + if ( 'BOOLEAN_OPERATION' === $type && count(array_filter($this->nodeList($child), 'is_array')) > 1 ) { + return false; } - if ( $this->isInputLike($node) ) { - return 'input'; + return $this->buttonBackgroundLayerCoversButton($child, $button); + } + + /** + * @param array $child + * @param array $button + */ + private function buttonBackgroundLayerCoversButton(array $child, array $button): bool + { + $buttonWidth = $this->boxValue($button, 'width'); + $buttonHeight = $this->boxValue($button, 'height'); + $childWidth = $this->boxValue($child, 'width'); + $childHeight = $this->boxValue($child, 'height'); + if ( null === $buttonWidth || null === $buttonHeight || null === $childWidth || null === $childHeight ) { + return false; + } + if ( abs($buttonWidth - $childWidth) > 1.5 || abs($buttonHeight - $childHeight) > 1.5 ) { + return false; } - // Inner component chrome inside a larger button-like control is - // structural. Only the outer control should become interactive HTML. - if ( null !== $parentNode && $this->isButtonLike($parentNode) && $this->isButtonLike($node) ) { - return 'div'; + $buttonBox = is_array($button['box'] ?? null) ? $button['box'] : array(); + $childBox = is_array($child['box'] ?? null) ? $child['box'] : array(); + $x = $this->positionOffset($childBox, $buttonBox, 'x', $button); + $y = $this->positionOffset($childBox, $buttonBox, 'y', $button); + if ( null === $x && $this->isFiniteNumeric($child['x'] ?? null) ) { + $x = (float) $child['x']; } + if ( null === $y && $this->isFiniteNumeric($child['y'] ?? null) ) { + $y = (float) $child['y']; + } + + return abs((float) ($x ?? 0.0)) <= 1.5 && abs((float) ($y ?? 0.0)) <= 1.5; + } - // A standalone button-like control (no link) becomes a real