@pagebridge/sanity 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Soma Somorjai
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Soma Somorjai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,228 +1,228 @@
1
- # @pagebridge/sanity
2
-
3
- Sanity Studio v3 plugin for PageBridge. Provides document schemas, UI components, and tools for viewing search performance data and managing content refresh tasks.
4
-
5
- ## Installation
6
-
7
- ```bash
8
- pnpm add @pagebridge/sanity
9
- ```
10
-
11
- ## Setup
12
-
13
- ### 1. Add the Plugin
14
-
15
- In your `sanity.config.ts`:
16
-
17
- ```typescript
18
- import { defineConfig } from "sanity";
19
- import { pageBridgePlugin } from "@pagebridge/sanity";
20
-
21
- export default defineConfig({
22
- // ... other config
23
- plugins: [
24
- pageBridgePlugin({
25
- contentTypes: ["post", "page"], // Document types to track
26
- }),
27
- ],
28
- });
29
- ```
30
-
31
- ### 2. Add the Structure Resolver
32
-
33
- To display the Performance pane on your content documents:
34
-
35
- ```typescript
36
- import { defineConfig } from "sanity";
37
- import { structureTool } from "sanity/structure";
38
- import {
39
- pageBridgePlugin,
40
- createPageBridgeStructureResolver,
41
- } from "@pagebridge/sanity";
42
-
43
- export default defineConfig({
44
- // ... other config
45
- plugins: [
46
- structureTool({
47
- structure: createPageBridgeStructureResolver({
48
- contentTypes: ["post", "page"],
49
- }),
50
- }),
51
- pageBridgePlugin({
52
- contentTypes: ["post", "page"],
53
- }),
54
- ],
55
- });
56
- ```
57
-
58
- ## Document Schemas
59
-
60
- The plugin registers three document types:
61
-
62
- ### gscSite
63
-
64
- Represents a Google Search Console property.
65
-
66
- | Field | Type | Description |
67
- | ------------- | -------- | -------------------------------------------- |
68
- | siteUrl | string | GSC site URL (e.g., `sc-domain:example.com`) |
69
- | slug | slug | URL-friendly identifier |
70
- | defaultLocale | string | Default locale (default: "en") |
71
- | pathPrefix | string | Path prefix for URL matching (e.g., `/blog`) |
72
- | lastSyncedAt | datetime | Last sync timestamp (read-only) |
73
-
74
- ### gscSnapshot
75
-
76
- Performance metrics snapshot linked to content documents.
77
-
78
- | Field | Type | Description |
79
- | -------------- | --------- | ------------------------------- |
80
- | site | reference | Reference to gscSite |
81
- | page | string | Page URL |
82
- | linkedDocument | reference | Matched content document |
83
- | period | string | `last7`, `last28`, or `last90` |
84
- | clicks | number | Total clicks |
85
- | impressions | number | Total impressions |
86
- | ctr | number | Click-through rate |
87
- | position | number | Average position |
88
- | topQueries | array | Top search queries with metrics |
89
- | fetchedAt | datetime | When data was fetched |
90
- | indexStatus | object | Google index status details |
91
-
92
- ### gscRefreshTask
93
-
94
- Content refresh task with decay signal information.
95
-
96
- | Field | Type | Description |
97
- | -------------- | --------- | --------------------------------------------------------- |
98
- | site | reference | Reference to gscSite |
99
- | linkedDocument | reference | Content document needing refresh |
100
- | reason | string | `position_decay`, `low_ctr`, `impressions_drop`, `manual` |
101
- | severity | string | `low`, `medium`, `high` |
102
- | status | string | `open`, `snoozed`, `in_progress`, `done`, `dismissed` |
103
- | snoozedUntil | datetime | When to resurface (if snoozed) |
104
- | metrics | object | Position, CTR, impressions data |
105
- | queryContext | array | Top 5 queries with stats |
106
- | notes | text | Resolution notes |
107
- | createdAt | datetime | Task creation time |
108
- | resolvedAt | datetime | Task resolution time |
109
-
110
- ## Components
111
-
112
- ### SearchPerformancePane
113
-
114
- Document view pane showing performance metrics for a content document.
115
-
116
- ```typescript
117
- import { SearchPerformancePane } from "@pagebridge/sanity";
118
-
119
- // Used automatically when you configure the structure resolver
120
- // Can also be used directly in custom document views
121
- ```
122
-
123
- The pane displays:
124
-
125
- - Clicks, impressions, CTR, and position metrics
126
- - Top search queries driving traffic
127
- - Google index status
128
- - Link to associated refresh tasks
129
-
130
- ### RefreshQueueTool
131
-
132
- Sanity tool for managing content refresh tasks. Accessible from the Studio sidebar.
133
-
134
- Features:
135
-
136
- - Filter tasks by status (open, in progress, snoozed, done, dismissed)
137
- - Sort by severity or creation date
138
- - View decay signal details
139
- - Update task status
140
- - Add resolution notes
141
-
142
- ## Configuration Options
143
-
144
- ### pageBridgePlugin
145
-
146
- ```typescript
147
- interface PageBridgePluginConfig {
148
- // Document types that can be linked to snapshots and tasks
149
- contentTypes: string[];
150
- }
151
- ```
152
-
153
- ### createPageBridgeStructureResolver
154
-
155
- ```typescript
156
- interface StructureResolverConfig {
157
- // Document types to show the Performance pane on
158
- contentTypes: string[];
159
- }
160
- ```
161
-
162
- ## Using Schemas Directly
163
-
164
- If you need to customize the schemas or use them without the plugin:
165
-
166
- ```typescript
167
- import {
168
- gscSite,
169
- createGscSnapshot,
170
- createGscRefreshTask,
171
- } from "@pagebridge/sanity/schemas";
172
-
173
- // Create snapshot schema with custom content types
174
- const customSnapshot = createGscSnapshot({
175
- contentTypes: ["article", "guide"],
176
- });
177
-
178
- // Create task schema with custom content types
179
- const customTask = createGscRefreshTask({
180
- contentTypes: ["article", "guide"],
181
- });
182
- ```
183
-
184
- ## Styling
185
-
186
- The components use Sanity UI and follow the Studio's theme. No additional CSS is required.
187
-
188
- ## Peer Dependencies
189
-
190
- - `sanity` >= 3.0.0
191
- - `react` >= 18.0.0
192
- - `react-dom` >= 18.0.0
193
- - `@sanity/ui` >= 2.0.0
194
- - `@sanity/icons` >= 3.0.0
195
-
196
- ## Exports
197
-
198
- ```typescript
199
- // Plugin
200
- export {
201
- pageBridgePlugin,
202
- createPageBridgeStructureResolver,
203
- } from "@pagebridge/sanity";
204
- export type { PageBridgePluginConfig } from "@pagebridge/sanity";
205
-
206
- // Components
207
- export {
208
- SearchPerformancePane,
209
- RefreshQueueTool,
210
- } from "@pagebridge/sanity";
211
-
212
- // Schemas
213
- export {
214
- gscSite,
215
- gscSnapshot,
216
- gscRefreshTask,
217
- createGscSnapshot,
218
- createGscRefreshTask,
219
- } from "@pagebridge/sanity/schemas";
220
- export type {
221
- GscSnapshotOptions,
222
- GscRefreshTaskOptions,
223
- } from "@pagebridge/sanity/schemas";
224
- ```
225
-
226
- ## License
227
-
228
- MIT
1
+ # @pagebridge/sanity
2
+
3
+ Sanity Studio v3 plugin for PageBridge. Provides document schemas, UI components, and tools for viewing search performance data and managing content refresh tasks.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @pagebridge/sanity
9
+ ```
10
+
11
+ ## Setup
12
+
13
+ ### 1. Add the Plugin
14
+
15
+ In your `sanity.config.ts`:
16
+
17
+ ```typescript
18
+ import { defineConfig } from "sanity";
19
+ import { pageBridgePlugin } from "@pagebridge/sanity";
20
+
21
+ export default defineConfig({
22
+ // ... other config
23
+ plugins: [
24
+ pageBridgePlugin({
25
+ contentTypes: ["post", "page"], // Document types to track
26
+ }),
27
+ ],
28
+ });
29
+ ```
30
+
31
+ ### 2. Add the Structure Resolver
32
+
33
+ To display the Performance pane on your content documents:
34
+
35
+ ```typescript
36
+ import { defineConfig } from "sanity";
37
+ import { structureTool } from "sanity/structure";
38
+ import {
39
+ pageBridgePlugin,
40
+ createPageBridgeStructureResolver,
41
+ } from "@pagebridge/sanity";
42
+
43
+ export default defineConfig({
44
+ // ... other config
45
+ plugins: [
46
+ structureTool({
47
+ structure: createPageBridgeStructureResolver({
48
+ contentTypes: ["post", "page"],
49
+ }),
50
+ }),
51
+ pageBridgePlugin({
52
+ contentTypes: ["post", "page"],
53
+ }),
54
+ ],
55
+ });
56
+ ```
57
+
58
+ ## Document Schemas
59
+
60
+ The plugin registers three document types:
61
+
62
+ ### gscSite
63
+
64
+ Represents a Google Search Console property.
65
+
66
+ | Field | Type | Description |
67
+ | ------------- | -------- | -------------------------------------------- |
68
+ | siteUrl | string | GSC site URL (e.g., `sc-domain:example.com`) |
69
+ | slug | slug | URL-friendly identifier |
70
+ | defaultLocale | string | Default locale (default: "en") |
71
+ | pathPrefix | string | Path prefix for URL matching (e.g., `/blog`) |
72
+ | lastSyncedAt | datetime | Last sync timestamp (read-only) |
73
+
74
+ ### gscSnapshot
75
+
76
+ Performance metrics snapshot linked to content documents.
77
+
78
+ | Field | Type | Description |
79
+ | -------------- | --------- | ------------------------------- |
80
+ | site | reference | Reference to gscSite |
81
+ | page | string | Page URL |
82
+ | linkedDocument | reference | Matched content document |
83
+ | period | string | `last7`, `last28`, or `last90` |
84
+ | clicks | number | Total clicks |
85
+ | impressions | number | Total impressions |
86
+ | ctr | number | Click-through rate |
87
+ | position | number | Average position |
88
+ | topQueries | array | Top search queries with metrics |
89
+ | fetchedAt | datetime | When data was fetched |
90
+ | indexStatus | object | Google index status details |
91
+
92
+ ### gscRefreshTask
93
+
94
+ Content refresh task with decay signal information.
95
+
96
+ | Field | Type | Description |
97
+ | -------------- | --------- | --------------------------------------------------------- |
98
+ | site | reference | Reference to gscSite |
99
+ | linkedDocument | reference | Content document needing refresh |
100
+ | reason | string | `position_decay`, `low_ctr`, `impressions_drop`, `manual` |
101
+ | severity | string | `low`, `medium`, `high` |
102
+ | status | string | `open`, `snoozed`, `in_progress`, `done`, `dismissed` |
103
+ | snoozedUntil | datetime | When to resurface (if snoozed) |
104
+ | metrics | object | Position, CTR, impressions data |
105
+ | queryContext | array | Top 5 queries with stats |
106
+ | notes | text | Resolution notes |
107
+ | createdAt | datetime | Task creation time |
108
+ | resolvedAt | datetime | Task resolution time |
109
+
110
+ ## Components
111
+
112
+ ### SearchPerformancePane
113
+
114
+ Document view pane showing performance metrics for a content document.
115
+
116
+ ```typescript
117
+ import { SearchPerformancePane } from "@pagebridge/sanity";
118
+
119
+ // Used automatically when you configure the structure resolver
120
+ // Can also be used directly in custom document views
121
+ ```
122
+
123
+ The pane displays:
124
+
125
+ - Clicks, impressions, CTR, and position metrics
126
+ - Top search queries driving traffic
127
+ - Google index status
128
+ - Link to associated refresh tasks
129
+
130
+ ### RefreshQueueTool
131
+
132
+ Sanity tool for managing content refresh tasks. Accessible from the Studio sidebar.
133
+
134
+ Features:
135
+
136
+ - Filter tasks by status (open, in progress, snoozed, done, dismissed)
137
+ - Sort by severity or creation date
138
+ - View decay signal details
139
+ - Update task status
140
+ - Add resolution notes
141
+
142
+ ## Configuration Options
143
+
144
+ ### pageBridgePlugin
145
+
146
+ ```typescript
147
+ interface PageBridgePluginConfig {
148
+ // Document types that can be linked to snapshots and tasks
149
+ contentTypes: string[];
150
+ }
151
+ ```
152
+
153
+ ### createPageBridgeStructureResolver
154
+
155
+ ```typescript
156
+ interface StructureResolverConfig {
157
+ // Document types to show the Performance pane on
158
+ contentTypes: string[];
159
+ }
160
+ ```
161
+
162
+ ## Using Schemas Directly
163
+
164
+ If you need to customize the schemas or use them without the plugin:
165
+
166
+ ```typescript
167
+ import {
168
+ gscSite,
169
+ createGscSnapshot,
170
+ createGscRefreshTask,
171
+ } from "@pagebridge/sanity/schemas";
172
+
173
+ // Create snapshot schema with custom content types
174
+ const customSnapshot = createGscSnapshot({
175
+ contentTypes: ["article", "guide"],
176
+ });
177
+
178
+ // Create task schema with custom content types
179
+ const customTask = createGscRefreshTask({
180
+ contentTypes: ["article", "guide"],
181
+ });
182
+ ```
183
+
184
+ ## Styling
185
+
186
+ The components use Sanity UI and follow the Studio's theme. No additional CSS is required.
187
+
188
+ ## Peer Dependencies
189
+
190
+ - `sanity` >= 3.0.0
191
+ - `react` >= 18.0.0
192
+ - `react-dom` >= 18.0.0
193
+ - `@sanity/ui` >= 2.0.0
194
+ - `@sanity/icons` >= 3.0.0
195
+
196
+ ## Exports
197
+
198
+ ```typescript
199
+ // Plugin
200
+ export {
201
+ pageBridgePlugin,
202
+ createPageBridgeStructureResolver,
203
+ } from "@pagebridge/sanity";
204
+ export type { PageBridgePluginConfig } from "@pagebridge/sanity";
205
+
206
+ // Components
207
+ export {
208
+ SearchPerformancePane,
209
+ RefreshQueueTool,
210
+ } from "@pagebridge/sanity";
211
+
212
+ // Schemas
213
+ export {
214
+ gscSite,
215
+ gscSnapshot,
216
+ gscRefreshTask,
217
+ createGscSnapshot,
218
+ createGscRefreshTask,
219
+ } from "@pagebridge/sanity/schemas";
220
+ export type {
221
+ GscSnapshotOptions,
222
+ GscRefreshTaskOptions,
223
+ } from "@pagebridge/sanity/schemas";
224
+ ```
225
+
226
+ ## License
227
+
228
+ MIT
@@ -0,0 +1,16 @@
1
+ interface DecayMetrics {
2
+ positionBefore?: number;
3
+ positionNow?: number;
4
+ positionDelta?: number;
5
+ ctrBefore?: number;
6
+ ctrNow?: number;
7
+ impressions?: number;
8
+ }
9
+ interface DecayAlertCardProps {
10
+ reason: "position_decay" | "low_ctr" | "impressions_drop" | "manual";
11
+ severity: "low" | "medium" | "high";
12
+ metrics?: DecayMetrics;
13
+ }
14
+ export declare function DecayAlertCard({ reason, severity, metrics, }: DecayAlertCardProps): import("react/jsx-runtime").JSX.Element;
15
+ export {};
16
+ //# sourceMappingURL=DecayAlertCard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DecayAlertCard.d.ts","sourceRoot":"","sources":["../../src/components/DecayAlertCard.tsx"],"names":[],"mappings":"AAGA,UAAU,YAAY;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,UAAU,mBAAmB;IAC3B,MAAM,EAAE,gBAAgB,GAAG,SAAS,GAAG,kBAAkB,GAAG,QAAQ,CAAC;IACrE,QAAQ,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IACpC,OAAO,CAAC,EAAE,YAAY,CAAC;CACxB;AAwBD,wBAAgB,cAAc,CAAC,EAC7B,MAAM,EACN,QAAQ,EACR,OAAO,GACR,EAAE,mBAAmB,2CAkBrB"}
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Card, Stack, Text, Flex } from "@sanity/ui";
3
+ import { WarningOutlineIcon } from "@sanity/icons";
4
+ function getDecayMessage(reason, metrics) {
5
+ switch (reason) {
6
+ case "position_decay":
7
+ if (metrics?.positionBefore != null && metrics?.positionNow != null) {
8
+ return `Position dropped ${metrics.positionBefore.toFixed(0)} \u2192 ${metrics.positionNow.toFixed(0)} over 30 days`;
9
+ }
10
+ return "Search position has declined over 30 days";
11
+ case "low_ctr":
12
+ if (metrics?.ctrNow != null) {
13
+ return `CTR at ${(metrics.ctrNow * 100).toFixed(1)}% despite top-10 position`;
14
+ }
15
+ return "Click-through rate is below threshold";
16
+ case "impressions_drop":
17
+ return "Impressions dropped significantly over 30 days";
18
+ case "manual":
19
+ return "Manually flagged for review";
20
+ }
21
+ }
22
+ export function DecayAlertCard({ reason, severity, metrics, }) {
23
+ return (_jsx(Card, { padding: 3, radius: 2, tone: "critical", children: _jsxs(Stack, { space: 2, children: [_jsxs(Flex, { align: "center", gap: 2, children: [_jsx(Text, { size: 1, children: _jsx(WarningOutlineIcon, {}) }), _jsx(Text, { size: 1, weight: "semibold", children: "Decay Detected" })] }), _jsx(Text, { size: 1, muted: true, children: getDecayMessage(reason, metrics) })] }) }));
24
+ }
@@ -0,0 +1,3 @@
1
+ import type { DocumentBadgeComponent } from "sanity";
2
+ export declare const DecayBadge: DocumentBadgeComponent;
3
+ //# sourceMappingURL=DecayBadge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DecayBadge.d.ts","sourceRoot":"","sources":["../../src/components/DecayBadge.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,QAAQ,CAAC;AAErD,eAAO,MAAM,UAAU,EAAE,sBAqBxB,CAAC"}
@@ -0,0 +1,19 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useClient } from "sanity";
3
+ export const DecayBadge = (props) => {
4
+ const client = useClient({ apiVersion: "2025-02-07" });
5
+ const [hasDecay, setHasDecay] = useState(false);
6
+ useEffect(() => {
7
+ client
8
+ .fetch(`count(*[_type == "gscRefreshTask" && linkedDocument._ref == $id && status == "open"])`, { id: props.id })
9
+ .then((count) => setHasDecay(count > 0))
10
+ .catch(() => setHasDecay(false));
11
+ }, [props.id, client]);
12
+ if (!hasDecay)
13
+ return null;
14
+ return {
15
+ label: "Decay detected",
16
+ title: "Content decay has been detected for this document",
17
+ color: "danger",
18
+ };
19
+ };
@@ -0,0 +1,12 @@
1
+ export interface ChartBar {
2
+ /** Weekly click value for this bar */
3
+ value: number;
4
+ /** Whether this segment is trending down relative to the longer-term average */
5
+ declining: boolean;
6
+ }
7
+ interface PerformanceChartProps {
8
+ bars: ChartBar[];
9
+ }
10
+ export declare function PerformanceChart({ bars }: PerformanceChartProps): import("react/jsx-runtime").JSX.Element;
11
+ export {};
12
+ //# sourceMappingURL=PerformanceChart.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PerformanceChart.d.ts","sourceRoot":"","sources":["../../src/components/PerformanceChart.tsx"],"names":[],"mappings":"AAEA,MAAM,WAAW,QAAQ;IACvB,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,gFAAgF;IAChF,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,UAAU,qBAAqB;IAC7B,IAAI,EAAE,QAAQ,EAAE,CAAC;CAClB;AAED,wBAAgB,gBAAgB,CAAC,EAAE,IAAI,EAAE,EAAE,qBAAqB,2CA+C/D"}
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Card, Stack, Text, Flex } from "@sanity/ui";
3
+ export function PerformanceChart({ bars }) {
4
+ const maxValue = Math.max(...bars.map((b) => b.value), 1);
5
+ return (_jsx(Card, { padding: 3, radius: 2, shadow: 1, children: _jsxs(Stack, { space: 3, children: [_jsxs(Stack, { space: 1, children: [_jsx(Text, { size: 0, weight: "semibold", muted: true, children: "PERFORMANCE (30D)" }), _jsx(Text, { size: 0, muted: true, children: "Weekly clicks trend" })] }), _jsx("div", { style: {
6
+ display: "flex",
7
+ alignItems: "flex-end",
8
+ height: 120,
9
+ gap: 3,
10
+ padding: "0 4px",
11
+ }, children: bars.map((bar, i) => (_jsx("div", { style: {
12
+ flex: 1,
13
+ height: `${Math.max((bar.value / maxValue) * 100, 4)}%`,
14
+ backgroundColor: bar.declining ? "#f4726d" : "#43d675",
15
+ borderRadius: "3px 3px 0 0",
16
+ minHeight: 4,
17
+ } }, i))) }), _jsxs(Flex, { justify: "space-between", style: { padding: "0 4px" }, children: [_jsx(Text, { size: 0, muted: true, children: "older" }), _jsx(Text, { size: 0, muted: true, children: "recent" })] })] }) }));
18
+ }
@@ -0,0 +1,3 @@
1
+ import type { DocumentInspectorProps } from "sanity";
2
+ export declare function PerformanceInspector({ documentId, }: DocumentInspectorProps): import("react/jsx-runtime").JSX.Element;
3
+ //# sourceMappingURL=PerformanceInspector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PerformanceInspector.d.ts","sourceRoot":"","sources":["../../src/components/PerformanceInspector.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,QAAQ,CAAC;AAiJrD,wBAAgB,oBAAoB,CAAC,EACnC,UAAU,GACX,EAAE,sBAAsB,2CA+RxB"}
@@ -0,0 +1,174 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState, useCallback } from "react";
3
+ import { useClient } from "sanity";
4
+ import { Card, Stack, Text, Badge, Flex, Spinner, Box, TextInput, Button, } from "@sanity/ui";
5
+ import { AddIcon, TrashIcon, CheckmarkCircleIcon, CircleIcon, } from "@sanity/icons";
6
+ import { PerformanceChart } from "./PerformanceChart";
7
+ import { DecayAlertCard } from "./DecayAlertCard";
8
+ /**
9
+ * Deduplicate snapshots — keep only the most recent per period.
10
+ */
11
+ function deduplicateSnapshots(snapshots) {
12
+ const byPeriod = new Map();
13
+ for (const snap of snapshots) {
14
+ const existing = byPeriod.get(snap.period);
15
+ if (!existing || snap.fetchedAt > existing.fetchedAt) {
16
+ byPeriod.set(snap.period, snap);
17
+ }
18
+ }
19
+ return byPeriod;
20
+ }
21
+ /**
22
+ * Build ~12 weekly bars from period aggregates.
23
+ *
24
+ * Derives weekly click totals from the 3 period snapshots, then colors
25
+ * each bar based on the overall trend direction:
26
+ * - If the page is declining (recent < older), older bars are green
27
+ * and recent bars are red — showing the transition from healthy to declining.
28
+ * - If the page is stable/growing, all bars are green.
29
+ */
30
+ function buildChartBars(snapshots) {
31
+ const s7 = snapshots.get("last7");
32
+ const s28 = snapshots.get("last28");
33
+ const s90 = snapshots.get("last90");
34
+ if (!s28 && !s7)
35
+ return [];
36
+ const clicks7 = s7?.clicks ?? 0;
37
+ const clicks28 = s28?.clicks ?? clicks7;
38
+ const clicks90 = s90?.clicks ?? clicks28;
39
+ // Derive weekly values for each segment
40
+ const recentWeekly = clicks7; // most recent week
41
+ const midWeekly = Math.max(clicks28 - clicks7, 0) / 3; // weeks 2-4 avg
42
+ const olderWeekly = s90 ? Math.max(clicks90 - clicks28, 0) / 8 : 0; // weeks 5-12 avg
43
+ // Determine overall trend: is recent worse than historical?
44
+ const historicalWeeklyAvg = s90
45
+ ? (clicks90 - clicks7) / 11
46
+ : s28
47
+ ? (clicks28 - clicks7) / 3
48
+ : clicks7;
49
+ const isDeclining = recentWeekly < historicalWeeklyAvg * 0.85;
50
+ const bars = [];
51
+ // Older weeks (5-12) — 8 bars — green if declining (these were the good days)
52
+ if (s90) {
53
+ for (let i = 0; i < 8; i++) {
54
+ bars.push({ value: olderWeekly, declining: false });
55
+ }
56
+ }
57
+ // Middle weeks (2-4) — 3 bars — transition zone
58
+ if (s28) {
59
+ for (let i = 0; i < 3; i++) {
60
+ bars.push({
61
+ value: midWeekly,
62
+ declining: isDeclining && midWeekly < historicalWeeklyAvg * 0.85,
63
+ });
64
+ }
65
+ }
66
+ // Most recent week — 1 bar — red if the page is declining
67
+ bars.push({ value: recentWeekly, declining: isDeclining });
68
+ return bars;
69
+ }
70
+ /**
71
+ * Check if a target keyword matches any of the top queries.
72
+ * Uses case-insensitive substring matching.
73
+ */
74
+ function isKeywordInQueries(keyword, queries) {
75
+ const lower = keyword.toLowerCase();
76
+ return queries.some((q) => q.query.toLowerCase().includes(lower));
77
+ }
78
+ /**
79
+ * Check if a query matches any of the target keywords.
80
+ */
81
+ function isQueryInTargets(query, targets) {
82
+ const lower = query.toLowerCase();
83
+ return targets.some((t) => lower.includes(t.toLowerCase()));
84
+ }
85
+ export function PerformanceInspector({ documentId, }) {
86
+ const client = useClient({ apiVersion: "2024-01-01" });
87
+ const [snapshots, setSnapshots] = useState(new Map());
88
+ const [decayTask, setDecayTask] = useState(null);
89
+ const [keywordTarget, setKeywordTarget] = useState(null);
90
+ const [newKeyword, setNewKeyword] = useState("");
91
+ const [loading, setLoading] = useState(true);
92
+ const fetchData = useCallback(async () => {
93
+ const [snapshotResults, taskResult, keywordResult] = await Promise.all([
94
+ client.fetch(`*[_type == "gscSnapshot" && linkedDocument._ref == $id] | order(fetchedAt desc) {
95
+ period,
96
+ clicks,
97
+ impressions,
98
+ ctr,
99
+ position,
100
+ topQueries,
101
+ fetchedAt
102
+ }`, { id: documentId }),
103
+ client.fetch(`*[_type == "gscRefreshTask" && linkedDocument._ref == $id && status == "open"] | order(severity desc)[0] {
104
+ reason,
105
+ severity,
106
+ metrics
107
+ }`, { id: documentId }),
108
+ client.fetch(`*[_type == "gscKeywordTarget" && linkedDocument._ref == $id][0] {
109
+ _id,
110
+ keywords
111
+ }`, { id: documentId }),
112
+ ]);
113
+ setSnapshots(deduplicateSnapshots(snapshotResults ?? []));
114
+ setDecayTask(taskResult);
115
+ setKeywordTarget(keywordResult);
116
+ setLoading(false);
117
+ }, [documentId, client]);
118
+ useEffect(() => {
119
+ fetchData();
120
+ }, [fetchData]);
121
+ const addKeyword = useCallback(async () => {
122
+ const keyword = newKeyword.trim();
123
+ if (!keyword)
124
+ return;
125
+ if (keywordTarget) {
126
+ const updated = [...(keywordTarget.keywords ?? []), keyword];
127
+ await client.patch(keywordTarget._id).set({ keywords: updated }).commit();
128
+ setKeywordTarget({ ...keywordTarget, keywords: updated });
129
+ }
130
+ else {
131
+ const doc = await client.create({
132
+ _type: "gscKeywordTarget",
133
+ linkedDocument: { _type: "reference", _ref: documentId },
134
+ keywords: [keyword],
135
+ });
136
+ setKeywordTarget({ _id: doc._id, keywords: [keyword] });
137
+ }
138
+ setNewKeyword("");
139
+ }, [newKeyword, keywordTarget, documentId, client]);
140
+ const removeKeyword = useCallback(async (keyword) => {
141
+ if (!keywordTarget)
142
+ return;
143
+ const updated = keywordTarget.keywords.filter((k) => k !== keyword);
144
+ await client.patch(keywordTarget._id).set({ keywords: updated }).commit();
145
+ setKeywordTarget({ ...keywordTarget, keywords: updated });
146
+ }, [keywordTarget, client]);
147
+ if (loading) {
148
+ return (_jsx(Card, { padding: 4, children: _jsx(Flex, { justify: "center", align: "center", style: { minHeight: 200 }, children: _jsx(Spinner, {}) }) }));
149
+ }
150
+ const snapshot28 = snapshots.get("last28");
151
+ const chartBars = buildChartBars(snapshots);
152
+ const topQueries = snapshot28?.topQueries?.slice(0, 5) ?? [];
153
+ const targetKeywords = keywordTarget?.keywords ?? [];
154
+ return (_jsx(Card, { padding: 4, style: { height: "100%", overflowY: "auto" }, children: _jsxs(Stack, { space: 4, children: [_jsxs(Flex, { justify: "space-between", align: "center", children: [_jsxs(Flex, { align: "center", gap: 2, children: [_jsx("div", { style: {
155
+ width: 10,
156
+ height: 10,
157
+ borderRadius: "50%",
158
+ backgroundColor: "#43d675",
159
+ flexShrink: 0,
160
+ } }), _jsx(Text, { weight: "semibold", size: 2, children: "PageBridge" })] }), _jsx(Badge, { tone: "primary", fontSize: 0, children: "Live GSC data" })] }), chartBars.length > 0 ? (_jsx(PerformanceChart, { bars: chartBars })) : (_jsx(Card, { padding: 4, tone: "caution", radius: 2, children: _jsx(Text, { size: 1, children: "No performance data available yet." }) })), decayTask && (_jsx(DecayAlertCard, { reason: decayTask.reason, severity: decayTask.severity, metrics: decayTask.metrics })), snapshot28 && (_jsxs(Flex, { gap: 3, children: [_jsx(Card, { padding: 3, radius: 2, shadow: 1, style: { flex: 1 }, children: _jsxs(Stack, { space: 2, children: [_jsx(Text, { size: 0, muted: true, children: "Clicks" }), _jsx(Text, { size: 3, weight: "semibold", children: snapshot28.clicks.toLocaleString() })] }) }), _jsx(Card, { padding: 3, radius: 2, shadow: 1, style: { flex: 1 }, children: _jsxs(Stack, { space: 2, children: [_jsx(Text, { size: 0, muted: true, children: "CTR" }), _jsxs(Text, { size: 3, weight: "semibold", children: [(snapshot28.ctr * 100).toFixed(1), "%"] })] }) })] })), topQueries.length > 0 && (_jsxs(Box, { children: [_jsx(Text, { size: 1, weight: "semibold", style: { marginBottom: 8 }, children: "Top Queries" }), _jsx(Stack, { space: 2, children: topQueries.map((q, i) => {
161
+ const isTarget = isQueryInTargets(q.query, targetKeywords);
162
+ return (_jsx(Card, { padding: 2, radius: 2, tone: isTarget ? "positive" : "default", shadow: isTarget ? 1 : 0, children: _jsxs(Flex, { justify: "space-between", align: "center", children: [_jsxs(Flex, { align: "center", gap: 2, style: { flex: 1, minWidth: 0 }, children: [isTarget && (_jsx(Text, { size: 0, children: _jsx(CheckmarkCircleIcon, {}) })), _jsx(Text, { size: 1, style: {
163
+ overflow: "hidden",
164
+ textOverflow: "ellipsis",
165
+ whiteSpace: "nowrap",
166
+ }, children: q.query })] }), _jsxs(Text, { size: 0, muted: true, style: { flexShrink: 0, marginLeft: 8 }, children: [q.clicks, "cl \u00B7 pos ", q.position.toFixed(1)] })] }) }, i));
167
+ }) })] })), _jsxs(Box, { children: [_jsx(Text, { size: 1, weight: "semibold", style: { marginBottom: 8 }, children: "Target Keywords" }), _jsxs(Stack, { space: 2, children: [targetKeywords.map((kw) => {
168
+ const isRanking = isKeywordInQueries(kw, topQueries);
169
+ return (_jsx(Card, { padding: 2, radius: 2, tone: isRanking ? "positive" : "caution", shadow: 1, children: _jsxs(Flex, { justify: "space-between", align: "center", children: [_jsxs(Flex, { align: "center", gap: 2, style: { flex: 1, minWidth: 0 }, children: [_jsx(Text, { size: 0, children: isRanking ? _jsx(CheckmarkCircleIcon, {}) : _jsx(CircleIcon, {}) }), _jsx(Text, { size: 1, children: kw })] }), _jsxs(Flex, { align: "center", gap: 2, style: { flexShrink: 0 }, children: [isRanking && (_jsx(Badge, { tone: "positive", fontSize: 0, children: "Ranking" })), _jsx(Button, { icon: TrashIcon, mode: "ghost", tone: "critical", fontSize: 0, padding: 1, onClick: () => removeKeyword(kw) })] })] }) }, kw));
170
+ }), targetKeywords.length === 0 && (_jsx(Text, { size: 1, muted: true, children: "No target keywords set." })), _jsxs(Flex, { gap: 2, children: [_jsx(Box, { style: { flex: 1 }, children: _jsx(TextInput, { fontSize: 1, placeholder: "Add target keyword...", value: newKeyword, onChange: (e) => setNewKeyword(e.target.value), onKeyDown: (e) => {
171
+ if (e.key === "Enter")
172
+ addKeyword();
173
+ } }) }), _jsx(Button, { icon: AddIcon, mode: "ghost", fontSize: 1, padding: 2, onClick: addKeyword, disabled: !newKeyword.trim() })] })] })] })] }) }));
174
+ }
@@ -22,16 +22,16 @@ export function RefreshQueueTool() {
22
22
  const query = filter === "all"
23
23
  ? `*[_type == "gscRefreshTask" && status != "done" && status != "dismissed"]`
24
24
  : `*[_type == "gscRefreshTask" && status == $status]`;
25
- const results = await client.fetch(`${query} | order(severity desc, createdAt desc) {
26
- _id,
27
- reason,
28
- severity,
29
- status,
30
- snoozedUntil,
31
- metrics,
32
- "documentTitle": linkedDocument->title,
33
- "documentSlug": linkedDocument->slug.current,
34
- createdAt
25
+ const results = await client.fetch(`${query} | order(severity desc, createdAt desc) {
26
+ _id,
27
+ reason,
28
+ severity,
29
+ status,
30
+ snoozedUntil,
31
+ metrics,
32
+ "documentTitle": linkedDocument->title,
33
+ "documentSlug": linkedDocument->slug.current,
34
+ createdAt
35
35
  }`, { status: filter });
36
36
  setTasks(results);
37
37
  }, [filter, client]);
@@ -9,17 +9,17 @@ export function SearchPerformancePane({ documentId, }) {
9
9
  const [loading, setLoading] = useState(true);
10
10
  useEffect(() => {
11
11
  async function fetchData() {
12
- const snapshot = await client.fetch(`*[_type == "gscSnapshot" && linkedDocument._ref == $id && period == "last28"][0]{
13
- clicks,
14
- impressions,
15
- ctr,
16
- position,
17
- topQueries,
18
- fetchedAt,
19
- indexStatus
12
+ const snapshot = await client.fetch(`*[_type == "gscSnapshot" && linkedDocument._ref == $id && period == "last28"][0]{
13
+ clicks,
14
+ impressions,
15
+ ctr,
16
+ position,
17
+ topQueries,
18
+ fetchedAt,
19
+ indexStatus
20
20
  }`, { id: documentId });
21
- const previousSnapshot = await client.fetch(`*[_type == "gscSnapshot" && linkedDocument._ref == $id && period == "last28"] | order(fetchedAt desc)[1]{
22
- position
21
+ const previousSnapshot = await client.fetch(`*[_type == "gscSnapshot" && linkedDocument._ref == $id && period == "last28"] | order(fetchedAt desc)[1]{
22
+ position
23
23
  }`, { id: documentId });
24
24
  if (snapshot) {
25
25
  setData({
@@ -0,0 +1,22 @@
1
+ export interface GscKeywordTargetOptions {
2
+ contentTypes?: string[];
3
+ }
4
+ export declare const createGscKeywordTarget: (options?: GscKeywordTargetOptions) => {
5
+ type: "document";
6
+ name: "gscKeywordTarget";
7
+ } & Omit<import("sanity").DocumentDefinition, "preview"> & {
8
+ preview?: import("sanity").PreviewConfig<{
9
+ title: string;
10
+ keywords: string;
11
+ }, Record<"title" | "keywords", any>> | undefined;
12
+ };
13
+ export declare const gscKeywordTarget: {
14
+ type: "document";
15
+ name: "gscKeywordTarget";
16
+ } & Omit<import("sanity").DocumentDefinition, "preview"> & {
17
+ preview?: import("sanity").PreviewConfig<{
18
+ title: string;
19
+ keywords: string;
20
+ }, Record<"title" | "keywords", any>> | undefined;
21
+ };
22
+ //# sourceMappingURL=gscKeywordTarget.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gscKeywordTarget.d.ts","sourceRoot":"","sources":["../../src/schemas/gscKeywordTarget.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,uBAAuB;IACtC,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;CACzB;AAED,eAAO,MAAM,sBAAsB,GACjC,UAAS,uBAA4B;;;;;;;;CA0CtC,CAAC;AAEF,eAAO,MAAM,gBAAgB;;;;;;;;CAA2B,CAAC"}
@@ -0,0 +1,43 @@
1
+ import { defineType, defineField } from "sanity";
2
+ export const createGscKeywordTarget = (options = {}) => {
3
+ const contentTypes = options.contentTypes ?? [];
4
+ return defineType({
5
+ name: "gscKeywordTarget",
6
+ title: "GSC Keyword Target",
7
+ type: "document",
8
+ fields: [
9
+ ...(contentTypes.length > 0
10
+ ? [
11
+ defineField({
12
+ name: "linkedDocument",
13
+ title: "Linked Document",
14
+ type: "reference",
15
+ to: contentTypes.map((type) => ({ type })),
16
+ validation: (Rule) => Rule.required(),
17
+ }),
18
+ ]
19
+ : []),
20
+ defineField({
21
+ name: "keywords",
22
+ title: "Target Keywords",
23
+ type: "array",
24
+ of: [{ type: "string" }],
25
+ description: "Keywords you want this page to rank for",
26
+ }),
27
+ ],
28
+ preview: {
29
+ select: {
30
+ title: "linkedDocument.title",
31
+ keywords: "keywords",
32
+ },
33
+ prepare({ title, keywords }) {
34
+ const count = keywords?.length ?? 0;
35
+ return {
36
+ title: title || "Untitled",
37
+ subtitle: `${count} target keyword${count !== 1 ? "s" : ""}`,
38
+ };
39
+ },
40
+ },
41
+ });
42
+ };
43
+ export const gscKeywordTarget = createGscKeywordTarget();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pagebridge/sanity",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Sanity Studio plugin for PageBridge — performance pane, decay alerts, and refresh queue powered by Google Search Console data",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -38,7 +38,7 @@
38
38
  }
39
39
  },
40
40
  "peerDependencies": {
41
- "sanity": "^5.0.0",
41
+ "sanity": "^4.0.0",
42
42
  "@sanity/ui": "^3.0.0",
43
43
  "@sanity/icons": "^3.0.0",
44
44
  "react": "^18 || ^19",