diff --git a/plugins/first-rank-pro/README.md b/plugins/first-rank-pro/README.md new file mode 100644 index 000000000..fc1c4babd --- /dev/null +++ b/plugins/first-rank-pro/README.md @@ -0,0 +1,31 @@ +# First Rank Pro + +On-page SEO auditing for Framer sites. First Rank Pro analyzes the published pages of your project and checks the fundamentals search engines care about: page title, meta description, H1 and heading hierarchy, main-keyword usage and placement, image alt text, and content length — with a quick summary, per-check guidance, and a live SERP preview. + +**By:** @arun-dev-des + +![Quick Summary](docs/01-quick-summary.png) + +The plugin is live on the Framer Marketplace: [**First Rank Pro**](https://www.framer.com/marketplace/plugins/first-rank-pro/) (plugin id `117943`). Marketplace versions are published manually by the author. + +## How it works + +- Pages and publish state come from the Plugin API (`getPublishInfo`, `subscribeToPublishInfo`, `getNodesWithType("WebPageNode")`), so the audited domain updates live when the site is republished. When a project has both a custom domain and a `*.framer.app` domain, a switcher in the navbar selects which one to audit. +- Page HTML is fetched through Framer's own CORS proxy (`framer-cors-proxy.framer-team.workers.dev`, the same worker other plugins in this repo use) and analyzed client-side. Override it with any transparent CORS-proxy prefix (the target URL is appended raw) via the `VITE_PROXY_URL` environment variable: + + ```bash + VITE_PROXY_URL="https://your-proxy.example.com/?" yarn dev --filter=first-rank-pro + ``` + +- Image alt text can be edited inline; updates are written back to canvas nodes via `setAttributes` with cloned `ImageAsset`s. +- Focus keywords and analysis summaries persist via `setPluginData`/`getPluginData`. +- AI generation surfaces (suggested titles, descriptions, H1s, keywords, and alt text) exist in the code base but are disabled behind a single flag (`src/config/featureFlags.ts`, `AI_GENERATION_ENABLED = false`); the endpoints are env-overridable (`VITE_AI_API_URL`, `VITE_ALT_TEXT_API_URL`). +- The plugin intentionally does not import `framer-plugin/framer.css`; it ships its own design system that themes light/dark off the `data-framer-theme` attribute Framer sets on `` (WCAG AA contrast in both themes), plus an in-plugin theme toggle. + +## Development + +```bash +yarn dev --filter=first-rank-pro +``` + +Then open the printed `https://localhost` URL in Framer via **Plugins → Developer Tools → Open Development Plugin**. diff --git a/plugins/first-rank-pro/docs/01-quick-summary.png b/plugins/first-rank-pro/docs/01-quick-summary.png new file mode 100644 index 000000000..ff19345c8 Binary files /dev/null and b/plugins/first-rank-pro/docs/01-quick-summary.png differ diff --git a/plugins/first-rank-pro/framer.json b/plugins/first-rank-pro/framer.json new file mode 100644 index 000000000..8e2d42f62 --- /dev/null +++ b/plugins/first-rank-pro/framer.json @@ -0,0 +1,6 @@ +{ + "id": "117943", + "name": "First Rank Pro", + "modes": ["canvas"], + "icon": "/icon.svg" +} diff --git a/plugins/first-rank-pro/index.html b/plugins/first-rank-pro/index.html new file mode 100644 index 000000000..cb9be98cb --- /dev/null +++ b/plugins/first-rank-pro/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + First Rank Pro + + +
+ + + diff --git a/plugins/first-rank-pro/package.json b/plugins/first-rank-pro/package.json new file mode 100644 index 000000000..7a388c033 --- /dev/null +++ b/plugins/first-rank-pro/package.json @@ -0,0 +1,22 @@ +{ + "name": "first-rank-pro", + "type": "module", + "scripts": { + "dev": "run g:dev", + "build": "run g:build", + "check-biome": "run g:check-biome", + "check-eslint": "run g:check-eslint", + "preview": "run g:preview", + "pack": "run g:pack", + "check-typescript": "run g:check-typescript" + }, + "dependencies": { + "framer-plugin": "3.11.0-alpha.12", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.24", + "@types/react-dom": "^18.3.7" + } +} diff --git a/plugins/first-rank-pro/public/icon.svg b/plugins/first-rank-pro/public/icon.svg new file mode 100644 index 000000000..6ef704b2d --- /dev/null +++ b/plugins/first-rank-pro/public/icon.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/first-rank-pro/src/App.css b/plugins/first-rank-pro/src/App.css new file mode 100644 index 000000000..5bc7abbea --- /dev/null +++ b/plugins/first-rank-pro/src/App.css @@ -0,0 +1,446 @@ +:root { + /* Native controls / scrollbars follow the theme (dark by default) */ + color-scheme: dark; + + /* Typography */ + --font-family: + "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + + /* Font weights */ + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + /* Colors - Dark Mode (default) */ + --color-bg: #10131e; + --color-surface: #0a0c13; + --color-border: #141824; + --color-text-primary: #b8c5f6; + --color-text-secondary: #96a2d0; + --color-text-tertiary: #565e7b; + --color-accent: #73d5ff; + + /* Status Colors - Dark Mode */ + --color-pass: #22c55e; + --color-pass-bg: rgba(34, 197, 94, 0.1); + --color-pass-text: #e4ffee; + --color-fail: #ef4444; + --color-fail-bg: rgba(239, 68, 68, 0.1); + --color-fail-text: #ffd9d9; + --color-warning: #eab308; + --color-warning-bg: rgba(234, 179, 8, 0.1); + --color-warning-text: #fff6e0; + + /* UI Elements - Dark Mode */ + --color-input-bg: var(--color-surface); + --color-card-bg: #2a2a2a; + --color-pill-bg: #141824; + + /* Extended tokens - Dark Mode (buttons / AI / neutral surfaces / info / status-strong) */ + --color-button-primary: #3b82f6; + --color-button-primary-hover: #2563eb; + --color-button-primary-text: #ffffff; + --color-button-neutral: #6b7280; + --color-accent-hover: #5bbfef; + --color-ai-gradient-start: #6985ff; + --color-ai-gradient-end: #a44cff; + --color-ai-surface: #382b60; + --color-ai-on-gradient: #ffffff; + --color-neutral-surface: #2a2a2a; + --color-neutral-surface-alt: #1f2230; + --color-on-neutral-surface: #b8c5f6; + --color-on-pass-solid: #06210f; + --color-pass-hover: #16a34a; + --color-info: #2196f3; + --color-info-bg: rgba(33, 150, 243, 0.12); + --color-info-text: #9ac3ff; + --color-serp-title: #9ac3ff; + --color-serp-meta: #bfbfbf; + --color-warning-strong: #d09500; + --color-warning-strong-text: #ffffff; + --color-fail-strong: #980000; + --color-fail-strong-text: #ffffff; + --color-highlight-pulse: #5c4714; + --color-check-warning-bg: #191815; + --color-check-warning-border: #36332d; + --color-check-fail-bg: #151213; + --color-check-fail-border: #443a3b; + --color-tag-purple-bg: rgba(168, 85, 247, 0.15); + --color-tag-purple: #c4b5fd; +} + +/* Light Mode - Framer theme attribute (Framer sets data-framer-theme on document.body) */ +[data-framer-theme="light"] { + color-scheme: light; + --color-bg: #ffffff; + --color-surface: #f7f8fa; + --color-border: #e4e7ec; + --color-text-primary: #1a1d26; + --color-text-secondary: #4a5568; + --color-text-tertiary: #667085; + --color-accent: #0a66c2; + + /* Status Colors - Light Mode */ + --color-pass: #15803d; + --color-pass-bg: rgba(34, 197, 94, 0.12); + --color-pass-text: #166534; + --color-fail: #c81e1e; + --color-fail-bg: rgba(239, 68, 68, 0.12); + --color-fail-text: #991b1b; + --color-warning: #9a6700; + --color-warning-bg: rgba(234, 179, 8, 0.12); + --color-warning-text: #854d0e; + + /* UI Elements - Light Mode */ + --color-input-bg: #ffffff; + --color-card-bg: #f3f4f6; + --color-pill-bg: #e5e7eb; + + /* Extended tokens - Light Mode */ + --color-button-primary: #2563eb; + --color-button-primary-hover: #1d4ed8; + --color-button-primary-text: #ffffff; + --color-button-neutral: #9ca3af; + --color-accent-hover: #0056b3; + --color-ai-gradient-start: #6985ff; + --color-ai-gradient-end: #8b3fe0; + --color-ai-surface: #ede9fe; + --color-ai-on-gradient: #ffffff; + --color-neutral-surface: #f3f4f6; + --color-neutral-surface-alt: #e5e7eb; + --color-on-neutral-surface: #1a1d26; + --color-on-pass-solid: #ffffff; + --color-pass-hover: #166534; + --color-info: #1565c0; + --color-info-bg: #e3f2fd; + --color-info-text: #1565c0; + --color-serp-title: #1a56db; + --color-serp-meta: #5f6368; + --color-warning-strong: #b45309; + --color-warning-strong-text: #ffffff; + --color-fail-strong: #b91c1c; + --color-fail-strong-text: #ffffff; + --color-highlight-pulse: #fde68a; + --color-check-warning-bg: #fffbeb; + --color-check-warning-border: #fde68a; + --color-check-fail-bg: #fef2f2; + --color-check-fail-border: #fecaca; + --color-tag-purple-bg: #f3e8ff; + --color-tag-purple: #7e22ce; +} + +/* Fallback: prefers-color-scheme for system preference */ +@media (prefers-color-scheme: light) { + :root:not([data-framer-theme="dark"]) { + color-scheme: light; + --color-bg: #ffffff; + --color-surface: #f7f8fa; + --color-border: #e4e7ec; + --color-text-primary: #1a1d26; + --color-text-secondary: #4a5568; + --color-text-tertiary: #667085; + --color-accent: #0a66c2; + + /* Status Colors - Light Mode */ + --color-pass: #15803d; + --color-pass-bg: rgba(34, 197, 94, 0.12); + --color-pass-text: #166534; + --color-fail: #c81e1e; + --color-fail-bg: rgba(239, 68, 68, 0.12); + --color-fail-text: #991b1b; + --color-warning: #9a6700; + --color-warning-bg: rgba(234, 179, 8, 0.12); + --color-warning-text: #854d0e; + + /* UI Elements - Light Mode */ + --color-input-bg: #ffffff; + --color-card-bg: #f3f4f6; + --color-pill-bg: #e5e7eb; + + /* Extended tokens - Light Mode */ + --color-button-primary: #2563eb; + --color-button-primary-hover: #1d4ed8; + --color-button-primary-text: #ffffff; + --color-button-neutral: #9ca3af; + --color-accent-hover: #0056b3; + --color-ai-gradient-start: #6985ff; + --color-ai-gradient-end: #8b3fe0; + --color-ai-surface: #ede9fe; + --color-ai-on-gradient: #ffffff; + --color-neutral-surface: #f3f4f6; + --color-neutral-surface-alt: #e5e7eb; + --color-on-neutral-surface: #1a1d26; + --color-on-pass-solid: #ffffff; + --color-pass-hover: #166534; + --color-info: #1565c0; + --color-info-bg: #e3f2fd; + --color-info-text: #1565c0; + --color-serp-title: #1a56db; + --color-serp-meta: #5f6368; + --color-warning-strong: #b45309; + --color-warning-strong-text: #ffffff; + --color-fail-strong: #b91c1c; + --color-fail-strong-text: #ffffff; + --color-highlight-pulse: #fde68a; + --color-check-warning-bg: #fffbeb; + --color-check-warning-border: #fde68a; + --color-check-fail-bg: #fef2f2; + --color-check-fail-border: #fecaca; + --color-tag-purple-bg: #f3e8ff; + --color-tag-purple: #7e22ce; + } +} + +/* Global styles */ +* { + padding: 0; + margin: 0; +} + +body { + background-color: var(--color-bg); + font-family: var(--font-family); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Navbar styles */ +.navbar { + display: flex; + flex-direction: column; + background-color: var(--color-surface); + border-bottom: 1px solid var(--color-border); + padding: 24px 24px; + gap: 8px; +} + +.navbar-content { + display: flex; + align-items: center; + gap: 8px; +} + +.navbar-url { + color: var(--color-text-primary); + font-size: 16px; + line-height: 20px; + font-weight: var(--font-weight-regular); +} + +.navbar-theme-toggle { + margin-left: auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background-color: var(--color-border); + color: var(--color-text-secondary); + border: none; + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; + transition: + background-color 0.2s, + color 0.2s; +} + +.navbar-theme-toggle:hover { + color: var(--color-accent); + background-color: var(--color-surface); +} + +/* Domain switcher (shown only when more than one distinct domain exists). + The visible text + caret hug each other; a transparent native { + setSearchTerm(e.target.value) + }} + className="search-input" + /> + + + + + )} + + + ) : ( + selectedPage && ( + { + setCurrentView("pages") + setSelectedPage(null) + }} + /> + ) + )} + + ) +} diff --git a/plugins/first-rank-pro/src/assets/icons/index.tsx b/plugins/first-rank-pro/src/assets/icons/index.tsx new file mode 100644 index 000000000..4ea0f2c26 --- /dev/null +++ b/plugins/first-rank-pro/src/assets/icons/index.tsx @@ -0,0 +1,282 @@ +import type { FC } from "react" + +interface IconProps { + className?: string +} + +export const OptimizedIcon: FC = () => ( + + + + + + + + + + + +) + +export const UnoptimizedIcon: FC = () => ( + + + + + + + + + + + +) + +export const WarningIcon: FC = () => ( + + + + +) + +export const SearchIcon: FC = () => ( + + + +) + +export const BackIcon: FC = () => ( + + + +) + +export const MagicWandIcon: FC = () => ( + + + +) + +export const HelpIcon: FC = () => ( + + + +) + +export const GoodVsBadIcon: FC = () => ( + + + + +) + +export const ChevronDownIcon: FC = () => ( + + + +) + +export const ChevronUpIcon: FC = () => ( + + + +) + +export const WarningArrowIcon: FC = () => ( + + + +) + +export const FailArrowIcon: FC = () => ( + + + +) + +export const QuickSummaryIcon: FC = () => ( + + + +) + +export const SparklesIcon: FC = () => ( + + + + + + + + + + + + + + + + + + + +) + +export const HomeIcon: FC = () => ( + + + +) + +export const PageIcon: FC = () => ( + + + +) + +export const SunIcon: FC = ({ className }) => ( + + + + +) + +export const MoonIcon: FC = ({ className }) => ( + + + +) + +export const CaretDownIcon: FC = ({ className }) => ( + + + +) + +export const SyncIcon: FC = ({ className }) => ( + + + +) + +export const FrontArrowIcon: FC = () => ( + + + +) diff --git a/plugins/first-rank-pro/src/components/Navbar/Navbar.tsx b/plugins/first-rank-pro/src/components/Navbar/Navbar.tsx new file mode 100644 index 000000000..3e4111900 --- /dev/null +++ b/plugins/first-rank-pro/src/components/Navbar/Navbar.tsx @@ -0,0 +1,63 @@ +import { CaretDownIcon, MoonIcon, SunIcon, SyncIcon } from "../../assets/icons/index.tsx" +import type { Theme } from "../../hooks/useTheme" + +interface NavbarProps { + url: string + score?: number + theme: Theme + domains: string[] + onDomainChange: (url: string) => void + onAuditClick: () => void + onToggleTheme: () => void +} + +export function Navbar({ url, theme, domains, onDomainChange, onAuditClick, onToggleTheme }: NavbarProps) { + const canSwitch = domains.length > 1 + return ( + + ) +} diff --git a/plugins/first-rank-pro/src/components/PagesList/PageItem.tsx b/plugins/first-rank-pro/src/components/PagesList/PageItem.tsx new file mode 100644 index 000000000..21e44c45c --- /dev/null +++ b/plugins/first-rank-pro/src/components/PagesList/PageItem.tsx @@ -0,0 +1,44 @@ +import React from "react" +import type { Page } from "../../types/page" +import "./styles.css" +import { HomeIcon, PageIcon } from "../../assets/icons" + +interface PageItemProps { + page: Page + onSelect: (page: Page) => void + analysisSummary?: { + pass: number | string + fail: number | string + warning: number | string + } +} + +export const PageItem = React.memo(function PageItem({ page, onSelect, analysisSummary }: PageItemProps) { + return ( +
{ + onSelect(page) + }} + > +
+ {page.name === "Home" ? : } + {page.name} +
+ + {analysisSummary && ( +
+ + {analysisSummary.fail} + + + {analysisSummary.warning} + + + {analysisSummary.pass} + +
+ )} +
+ ) +}) diff --git a/plugins/first-rank-pro/src/components/PagesList/PagesList.tsx b/plugins/first-rank-pro/src/components/PagesList/PagesList.tsx new file mode 100644 index 000000000..d41b0cebc --- /dev/null +++ b/plugins/first-rank-pro/src/components/PagesList/PagesList.tsx @@ -0,0 +1,91 @@ +import { useEffect, useMemo, useState } from "react" +import { PageDataService } from "../../services/pageDataService" +import type { Page, PublishInfo } from "../../types/page" +import { PageItem } from "./PageItem" +import "./styles.css" + +interface SummaryCounts { + pass: number | string + fail: number | string + warning: number | string +} + +interface PagesListProps { + pages: Page[] + publishInfo: PublishInfo | null + onPageSelect: (page: Page) => void + searchTerm: string +} + +export function PagesList({ pages, publishInfo, onPageSelect, searchTerm }: PagesListProps) { + const [analysisSummaries, setAnalysisSummaries] = useState>({}) + + // Get current deployment times + const currentDeploymentTimes = useMemo( + () => ({ + staging: publishInfo?.staging?.deploymentTime ?? null, + production: publishInfo?.production?.deploymentTime ?? null, + }), + [publishInfo] + ) + + // Load analysis summaries for all pages + useEffect(() => { + async function loadSummaries() { + const summaries: Record = {} + + for (const page of pages) { + const summary = await PageDataService.getAnalysisSummary(page.id) + + if (!summary) { + // No analysis yet - show dashes + summaries[page.id] = { pass: "-", fail: "-", warning: "-" } + } else { + // Always show actual counts, even if site was republished + summaries[page.id] = summary.counts + } + } + + setAnalysisSummaries(summaries) + } + + if (pages.length > 0) { + void loadSummaries() + } + }, [pages, currentDeploymentTimes]) + + // Memoize filtered pages to prevent unnecessary filtering on every render + const filteredPages = useMemo( + () => pages.filter(page => page.name.toLowerCase().includes(searchTerm.toLowerCase())), + [pages, searchTerm] + ) + + return ( +
+ {/* Header row for columns */} +
+ Pages +
+ Fail + Warn + Pass +
+
+ + {filteredPages.length === 0 ? ( +
+

No pages found. Make sure your project has pages and is published.

+
+ ) : ( + filteredPages.map(page => ( + + )) + )} +
+ ) +} diff --git a/plugins/first-rank-pro/src/components/PagesList/styles.css b/plugins/first-rank-pro/src/components/PagesList/styles.css new file mode 100644 index 000000000..8038e03f2 --- /dev/null +++ b/plugins/first-rank-pro/src/components/PagesList/styles.css @@ -0,0 +1,120 @@ +@import "../../App.css"; + +.pages-list { + flex: 1; + overflow-y: auto; +} + +.pages-list-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 16px; + font-size: 11px; + font-weight: 600; + color: var(--color-text-tertiary); + border-bottom: 1px solid var(--color-border); + background-color: var(--color-surface); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.header-name { + flex: 1; +} + +.header-counts { + display: flex; + min-width: 240px; + justify-content: space-between; +} + +.header-pass, +.header-warning, +.header-fail { + width: 32px; + text-align: center; +} + +.page-name { + flex: 1; + font-size: 14px; + line-height: 20px; + color: var(--color-text-primary); + transition: color 0.2s; +} + +.page-item { + display: flex; + align-items: center; + justify-content: space-between; + background-color: var(--color-surface); + gap: 8px; + padding: 16px; + border-bottom: 1px solid var(--color-border); + border-left: 2px solid var(--color-surface); + cursor: pointer; + transition: background-color 0.2s; +} + +.page-item:hover { + background-color: var(--color-border); + border-left: 2px solid var(--color-accent); +} + +.page-item:hover .page-name { + color: var(--color-accent); +} + +.page-item:hover .page-icon svg path { + fill: var(--color-accent); +} + +.page-icon svg path { + opacity: 0.8; +} + +.page-item.selected { + background-color: var(--color-border); +} + +.page-icon-container { + display: flex; + align-items: center; + gap: 8px; + min-width: 200px; +} + +.analysis-counts { + display: flex; + min-width: 240px; + justify-content: space-between; + font-size: 13px; + font-weight: 600; +} + +.count-pass, +.count-warning, +.count-fail { + width: 32px; + text-align: center; + padding: 2px 2px; + border-radius: 4px; + font-variant-numeric: tabular-nums; + min-width: 40px; +} + +.count-pass { + color: var(--color-pass-text); + background: var(--color-pass-bg); +} + +.count-warning { + color: var(--color-warning-text); + background: var(--color-warning-bg); +} + +.count-fail { + color: var(--color-fail-text); + background: var(--color-fail-bg); +} diff --git a/plugins/first-rank-pro/src/components/SEOAnalysis/HeadingCounts.css b/plugins/first-rank-pro/src/components/SEOAnalysis/HeadingCounts.css new file mode 100644 index 000000000..756554881 --- /dev/null +++ b/plugins/first-rank-pro/src/components/SEOAnalysis/HeadingCounts.css @@ -0,0 +1,73 @@ +.heading-counter-container, +.heading-counter-container-summary { + display: flex; + align-items: center; + justify-content: space-between; + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 4px; + gap: 8px; + padding: 12px; + margin-bottom: 24px; +} + +.heading-counter-container-summary { + margin-bottom: 0px; +} + +.heading-count-container { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + background-color: var(--color-border); + border-radius: 4px; + padding: 4px 8px; + gap: 0px; +} + +.heading-count-container .heading-level-badge-container { + font-size: 14px; + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + border-radius: 4px; + min-width: 24px; + text-align: center; +} + +.h1-supporting-text { + font-size: 14px; + color: var(--color-fail-text); +} + +.heading-count-container .heading-count-number-container { + font-size: 14px; + font-weight: var(--font-weight-regular); + color: var(--color-text-primary); + min-width: 24px; + text-align: center; +} + +.heading-count-container.fail { + background-color: var(--color-fail-strong); +} + +.heading-count-container.fail .heading-level-badge-container { + color: var(--color-fail-strong-text); +} + +.heading-count-container.fail .heading-count-number-container { + color: var(--color-fail-strong-text); +} + +.heading-count-container.warning { + background-color: var(--color-warning-strong); +} + +.heading-count-container.warning .heading-level-badge-container { + color: var(--color-warning-strong-text); +} + +.heading-count-container.warning .heading-count-number-container { + color: var(--color-warning-strong-text); +} diff --git a/plugins/first-rank-pro/src/components/SEOAnalysis/HeadingCounts.tsx b/plugins/first-rank-pro/src/components/SEOAnalysis/HeadingCounts.tsx new file mode 100644 index 000000000..4e89a3ce5 --- /dev/null +++ b/plugins/first-rank-pro/src/components/SEOAnalysis/HeadingCounts.tsx @@ -0,0 +1,50 @@ +import type { SEOHeading } from "../../types/seo" +import "./HeadingCounts.css" + +interface HeadingCountsProps { + headings: SEOHeading[] +} + +export function HeadingCounts({ headings }: HeadingCountsProps) { + // Calculate heading counts by level + const h1s = headings.filter(h => h.level === "h1" && !h.duplicateOf) + const h2s = headings.filter(h => h.level === "h2" && !h.duplicateOf) + const h3s = headings.filter(h => h.level === "h3" && !h.duplicateOf) + const h4s = headings.filter(h => h.level === "h4" && !h.duplicateOf) + const h5s = headings.filter(h => h.level === "h5" && !h.duplicateOf) + const h6s = headings.filter(h => h.level === "h6" && !h.duplicateOf) + + return ( + <> + +
+
1 ? "warning" : ""}`} + > + H1 + {h1s.length} +
+
+ H2 + {h2s.length} +
+
+ H3 + {h3s.length} +
+
+ H4 + {h4s.length} +
+
+ H5 + {h5s.length} +
+
+ H6 + {h6s.length} +
+
+ + ) +} diff --git a/plugins/first-rank-pro/src/components/SEOAnalysis/HeadingIssuesPanel.css b/plugins/first-rank-pro/src/components/SEOAnalysis/HeadingIssuesPanel.css new file mode 100644 index 000000000..263575109 --- /dev/null +++ b/plugins/first-rank-pro/src/components/SEOAnalysis/HeadingIssuesPanel.css @@ -0,0 +1,72 @@ +.heading-issues-panel { + margin-bottom: 16px; + border-radius: 8px; + overflow: hidden; + background-color: var(--color-surface); + border: 1px solid var(--color-border); +} + +.issues-header { + padding: 12px 16px; + background-color: var(--color-surface); + border-bottom: 1px solid var(--color-border); +} + +.issues-title { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); +} + +.issues-list { + background-color: var(--color-surface); +} + +.issue-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--color-border); +} + +.issue-item:last-child { + border-bottom: none; +} + +.issue-item.error { + background-color: var(--color-surface); +} + +.issue-item.error .issues-header { + background-color: var(--color-border); + border-bottom-color: var(--color-border); +} + +.issue-icon { + font-size: 16px; + line-height: 20px; + flex-shrink: 0; +} + +.issue-description { + flex: 1; + font-size: 13px; + line-height: 18px; + color: var(--color-text-primary); +} + +.issue-locate-button { + padding: 4px 12px; + font-size: 12px; + background-color: var(--color-accent); + color: var(--color-bg); + border: none; + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; +} + +.issue-locate-button:hover { + background-color: var(--color-accent-hover); +} diff --git a/plugins/first-rank-pro/src/components/SEOAnalysis/HeadingIssuesPanel.tsx b/plugins/first-rank-pro/src/components/SEOAnalysis/HeadingIssuesPanel.tsx new file mode 100644 index 000000000..0ae697c18 --- /dev/null +++ b/plugins/first-rank-pro/src/components/SEOAnalysis/HeadingIssuesPanel.tsx @@ -0,0 +1,53 @@ +import type { HeadingIssue } from "../../types/seo" +import "./HeadingIssuesPanel.css" +import { UnoptimizedIcon, WarningIcon } from "../../assets/icons" + +interface HeadingIssuesPanelProps { + issues: HeadingIssue[] + onLocateHeading?: (index: number) => void +} + +export function HeadingIssuesPanel({ issues }: HeadingIssuesPanelProps) { + if (issues.length === 0) return null + + const getIssueDescription = (issue: HeadingIssue): string => { + switch (issue.type) { + case "jump": + return `Jump detected: ${issue.previousHeading?.level.toUpperCase() ?? "?"} "${issue.previousHeading?.text ?? ""}" → ${issue.level.toUpperCase()} "${issue.text}"` + case "missing_level": + return `${issue.level.toUpperCase()} "${issue.text}" found without ${issue.missingLevel?.toUpperCase() ?? "parent level"}` + case "missing_h1": + return "No H1 heading found on this page" + case "multiple_h1": + return `Multiple H1: "${issue.text}"` + default: + return "Unknown issue" + } + } + + return ( +
+
+ Issues Found ({issues.length}) +
+
+ {issues.map((issue, index) => ( +
+ + {issue.severity === "error" ? : } + + {getIssueDescription(issue)} + {/* {issue.index >= 0 && onLocateHeading && ( + + )} */} +
+ ))} +
+
+ ) +} diff --git a/plugins/first-rank-pro/src/components/SEOAnalysis/HeadingTree.css b/plugins/first-rank-pro/src/components/SEOAnalysis/HeadingTree.css new file mode 100644 index 000000000..96289477f --- /dev/null +++ b/plugins/first-rank-pro/src/components/SEOAnalysis/HeadingTree.css @@ -0,0 +1,137 @@ +.heading-tree { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 4px; + padding: 12px; +} + +.heading-tree-controls { + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--color-border); +} + +.heading-tree-toggle-label { + display: flex; + align-items: center; + gap: 8px; + color: var(--color-text-secondary); + font-size: 13px; + cursor: pointer; +} + +.heading-tree-toggle-label input[type="checkbox"] { + width: 16px; + height: 16px; + margin: 0; + cursor: pointer; +} + +.heading-tree-item { + margin: 4px 0; +} + +.heading-tree-row { + display: flex; + align-items: center; + gap: 8px; + padding: 4px; + border-radius: 4px; + transition: background-color 0.2s ease; +} + +.heading-tree-row:hover { + background: var(--color-border); +} + +.heading-tree-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + background: none; + border: none; + cursor: pointer; + color: var(--color-text-secondary); + transition: transform 0.2s ease; +} + +.heading-tree-toggle:hover { + color: var(--color-text-primary); +} + +.heading-tree-indent { + width: 20px; +} + +.heading-tree-content { + display: flex; + align-items: center; + gap: 8px; + flex: 1; +} + +.heading-level-badge { + font-size: 11px; + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); + padding: 2px 6px; + background: var(--color-border); + border-radius: 3px; + min-width: 24px; + text-align: center; +} + +.heading-text { + color: var(--color-text-primary); + font-size: 14px; + text-align: left; +} + +.heading-meta { + font-size: 12px; + color: var(--color-text-tertiary); + font-style: italic; +} + +.heading-tree-children { + margin-left: 4px; + border-left: 1px solid var(--color-border); +} + +/* Issue highlighting */ +.heading-tree-item.has-issue { + border-left: 2px solid var(--color-warning-strong); + padding-left: 12px; + margin-left: 0 !important; + border-radius: 4px; +} + +.heading-tree-item.has-issue .heading-tree-row { + padding: 4px 0; +} + +.heading-tree-item.highlighted { + /* background-color: #D09500; */ + animation: highlight-pulse 0.5s ease-in-out; +} + +@keyframes highlight-pulse { + 0%, + 100% { + background-color: var(--color-highlight-pulse); + } +} + +.heading-level-badge.has-issue { + background-color: var(--color-warning-strong); + color: var(--color-warning-strong-text); + font-weight: 600; +} + +.issue-indicator { + margin-left: 8px; + font-size: 14px; +} diff --git a/plugins/first-rank-pro/src/components/SEOAnalysis/HeadingTree.tsx b/plugins/first-rank-pro/src/components/SEOAnalysis/HeadingTree.tsx new file mode 100644 index 000000000..f30c7b025 --- /dev/null +++ b/plugins/first-rank-pro/src/components/SEOAnalysis/HeadingTree.tsx @@ -0,0 +1,118 @@ +import { useState } from "react" +import { ChevronDownIcon, ChevronUpIcon } from "../../assets/icons" +import type { HeadingIssue, SEOHeading } from "../../types/seo" +import "./HeadingTree.css" + +interface HeadingNode extends SEOHeading { + children: HeadingNode[] +} + +function buildHeadingTree(headings: SEOHeading[]): HeadingNode[] { + const roots: HeadingNode[] = [] + const stack: HeadingNode[] = [] + + headings.forEach(h => { + const levelNum = Number(h.level[1]) + const node: HeadingNode = { ...h, children: [] } + + // Pop until we find a parent of lower level + let top = stack.at(-1) + while (top && Number(top.level[1]) >= levelNum) { + stack.pop() + top = stack.at(-1) + } + + if (top) { + top.children.push(node) + } else { + roots.push(node) + } + stack.push(node) + }) + + return roots +} + +interface HeadingRowProps { + node: HeadingNode + depth?: number + highlightedIndex?: number +} + +function HeadingRow({ node, depth = 0, highlightedIndex }: HeadingRowProps) { + const [isOpen, setIsOpen] = useState(true) + const hasChildren = node.children.length > 0 + const isHighlighted = node.index === highlightedIndex + const hasIssue = node.hasIssue ?? false + + return ( +
+
+ {hasChildren ? ( + + ) : ( + + )} + +
+ + {node.level.toUpperCase()} + + {node.text} + {hasIssue && ⚠️} + {!node.visible && (hidden)} +
+
+ + {hasChildren && isOpen && ( +
+ {node.children.map((child, index) => ( + + ))} +
+ )} +
+ ) +} + +interface HeadingTreeProps { + headings: SEOHeading[] + issues?: HeadingIssue[] + highlightedIndex?: number +} + +export function HeadingTree({ headings, issues = [], highlightedIndex }: HeadingTreeProps) { + // Mark headings with issues + const headingsWithIssues = headings.map(heading => ({ + ...heading, + hasIssue: issues.some(issue => issue.index === heading.index), + issueType: issues.find(issue => issue.index === heading.index)?.type, + })) + + const tree = buildHeadingTree(headingsWithIssues) + + return ( +
+ {tree.map((node, index) => ( + + ))} +
+ ) +} diff --git a/plugins/first-rank-pro/src/components/SEOAnalysis/ImageTable.tsx b/plugins/first-rank-pro/src/components/SEOAnalysis/ImageTable.tsx new file mode 100644 index 000000000..b6c3d10cd --- /dev/null +++ b/plugins/first-rank-pro/src/components/SEOAnalysis/ImageTable.tsx @@ -0,0 +1,362 @@ +import { framer } from "framer-plugin" +import { useMemo, useState } from "react" +import { AI_GENERATION_ENABLED } from "../../config/featureFlags" + +// Widen the literal flag type so the toggle reads as a runtime condition rather than dead code +const aiGenerationEnabled: boolean = AI_GENERATION_ENABLED + +import { clearAnalysisCache } from "../../lib/analysisCache" +import { AIService } from "../../services/aiService" +import { FramerImageService } from "../../services/framerImageService" +import type { SEOImage } from "../../types/seo" +import "./styles.css" + +interface ImageTableProps { + images: SEOImage[] +} + +interface GroupedImage { + src: string + alt: string | null + count: number + instances: SEOImage[] + nodeIds: string[] + imageType: "SVG" | "Image" + isLocked: boolean +} + +export function ImageTable({ images }: ImageTableProps) { + const [editingStates, setEditingStates] = useState>({}) + const [savingStates, setSavingStates] = useState>({}) + const [savedAlts, setSavedAlts] = useState>({}) + const [generatingAlt, setGeneratingAlt] = useState>({}) + + // Helper function to detect if image is SVG + const isSVG = (src: string): boolean => { + if (!src) return false + + // Check for .svg extension + if (src.toLowerCase().includes(".svg")) return true + + // Check for SVG data URI + if (src.startsWith("data:image/svg+xml")) return true + + return false + } + + // Group images by their src and apply saved alt texts + const groupedImages = useMemo((): GroupedImage[] => { + const groups = new Map() + + images.forEach(image => { + const existing = groups.get(image.src) + + if (existing) { + existing.count++ + existing.instances.push(image) + if (image.nodeId) { + existing.nodeIds.push(image.nodeId) + } + } else { + // Use saved alt text if available, otherwise use the image's alt + const altText = savedAlts[image.src] ?? image.alt + + groups.set(image.src, { + src: image.src, + alt: altText, + count: 1, + instances: [image], + nodeIds: image.nodeId ? [image.nodeId] : [], + imageType: isSVG(image.src) ? "SVG" : "Image", + isLocked: image.isLocked ?? false, + }) + } + }) + + // Filter out images with no valid src and sort + return Array.from(groups.values()) + .filter(group => group.src && group.src.trim() !== "") // Only show images with valid src + .sort((a, b) => { + // First sort by type: 'Image' comes before 'SVG' + if (a.imageType !== b.imageType) { + return a.imageType === "Image" ? -1 : 1 + } + // Then sort by count (descending - most copies first) + return b.count - a.count + }) + }, [images, savedAlts]) + + const handleAltChange = (src: string, value: string) => { + setEditingStates(prev => ({ + ...prev, + [src]: value, + })) + } + + const handleSave = async (src: string, group: GroupedImage) => { + if (group.nodeIds.length === 0) { + return + } + + const newAltText = (editingStates[src] ?? savedAlts[src] ?? group.alt ?? "").trim() + const baseline = (savedAlts[src] ?? group.alt ?? "").trim() + + // Don't save if nothing changed (after trimming whitespace) + if (newAltText === baseline) { + return + } + + setSavingStates(prev => ({ ...prev, [src]: true })) + + try { + // Update all instances of this image + await Promise.all( + group.nodeIds.map(nodeId => FramerImageService.updateImageAltText(nodeId, newAltText, false)) + ) + + // Reflect saved value immediately in UI (store trimmed version) + setSavedAlts(prev => ({ ...prev, [src]: newAltText.trim() })) + + // Clear editing state after successful save + setEditingStates(prev => { + const { [src]: _removed, ...rest } = prev + return rest + }) + + // Show notification + const instanceText = group.count > 1 ? `${group.count} copies` : "1 image" + framer.notify(`Alt text updated for ${instanceText}`, { variant: "success" }) + + // Clear analysis cache to force fresh data on next page load + // No need to trigger immediate re-analysis - UI updates via state + clearAnalysisCache() + } catch (error) { + console.error("Failed to update alt text:", error) + // Show more specific error message based on error type + if (error instanceof Error) { + if (error.message.includes("insufficient permissions") || error.message.includes("setAttributes")) { + framer.notify("Cannot edit: Insufficient permissions", { variant: "error" }) + } else if (error.message.includes("No image property found")) { + framer.notify("Cannot edit: Image format not supported", { variant: "error" }) + } else { + framer.notify(`Failed to update alt text: ${error.message}`, { variant: "error" }) + } + } else { + framer.notify("Failed to update alt text", { variant: "error" }) + } + } finally { + setSavingStates(prev => ({ ...prev, [src]: false })) + } + } + + const getCurrentAltText = (src: string, group: GroupedImage) => { + if (editingStates[src] !== undefined) return editingStates[src] + if (savedAlts[src] !== undefined) return savedAlts[src] + return group.alt ?? "" + } + + const handleGenerateAltText = async (src: string, group: GroupedImage) => { + if (group.nodeIds.length === 0) { + framer.notify("Cannot generate: No node ID for image", { variant: "error" }) + return + } + + // Don't generate for SVGs + if (group.imageType === "SVG") { + framer.notify("AI generation not available for SVG images", { variant: "error" }) + return + } + + setGeneratingAlt(prev => ({ ...prev, [src]: true })) + + try { + console.log("Generating alt text for image:", src.substring(0, 50)) + + // Call AI service to generate alt text + const response = await AIService.generateAltText(src) + const generatedAltText = response.altText + + console.log(`Generated alt text using ${response.model}:`, generatedAltText) + + // Update the editing state with generated text + setEditingStates(prev => ({ + ...prev, + [src]: generatedAltText, + })) + + // Automatically save the generated alt text + setSavingStates(prev => ({ ...prev, [src]: true })) + + try { + // Update all instances of this image + await Promise.all( + group.nodeIds.map(nodeId => FramerImageService.updateImageAltText(nodeId, generatedAltText, false)) + ) + + // Reflect saved value immediately in UI + setSavedAlts(prev => ({ ...prev, [src]: generatedAltText.trim() })) + + // Clear editing state after successful save + setEditingStates(prev => { + const { [src]: _removed, ...rest } = prev + return rest + }) + + // Show success notification + const instanceText = group.count > 1 ? `${group.count} copies` : "1 image" + framer.notify(`✨ AI-generated alt text saved for ${instanceText}`, { variant: "success" }) + + // Clear analysis cache + clearAnalysisCache() + } catch (saveError) { + console.error("Failed to save generated alt text:", saveError) + + if (saveError instanceof Error) { + if ( + saveError.message.includes("insufficient permissions") || + saveError.message.includes("setAttributes") + ) { + framer.notify("Cannot save: Insufficient permissions", { variant: "error" }) + } else if (saveError.message.includes("No image property found")) { + framer.notify("Cannot save: Image format not supported", { variant: "error" }) + } else { + framer.notify("Failed to save alt text", { variant: "error" }) + } + } else { + framer.notify("Failed to save alt text", { variant: "error" }) + } + } finally { + setSavingStates(prev => ({ ...prev, [src]: false })) + } + } catch (error) { + console.error("Failed to generate alt text:", error) + + let errorMessage = "Failed to generate alt text" + if (error instanceof Error) { + if (error.message.includes("AI service not configured") || error.message.includes("API key")) { + errorMessage = "AI service not configured" + } else if (error.message.includes("timed out")) { + errorMessage = "Request timed out. Please try again." + } else if (error.message.includes("unavailable")) { + errorMessage = "AI generation unavailable" + } else { + errorMessage = error.message + } + } + + framer.notify(errorMessage, { variant: "error" }) + } finally { + setGeneratingAlt(prev => ({ ...prev, [src]: false })) + } + } + + if (images.length === 0) { + return ( +
+

No images found on this page

+
+ ) + } + + return ( +
+ + + + + + + + + {groupedImages.map((group, index) => { + const currentAltText = getCurrentAltText(group.src, group) + const isSaving = savingStates[group.src] ?? false + const isGenerating = generatingAlt[group.src] ?? false + const isSVG = group.imageType === "SVG" + + return ( + + +
ImageAlt Text
+
+ {group.src ? ( + <> + {currentAltText { + e.currentTarget.style.display = "none" + e.currentTarget.nextElementSibling?.classList.remove("hidden") + }} + /> +
+
No preview
+ {group.nodeIds[0] !== undefined && ( +
+ ID: {group.nodeIds[0].substring(0, 8)}... +
+ )} +
+ + ) : ( +
+
No preview
+ {group.nodeIds[0] !== undefined && ( +
+ ID: {group.nodeIds[0].substring(0, 8)}... +
+ )} +
+ )} +
+
+
+