@gscdump/nuxt 0.19.0
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 +21 -0
- package/README.md +138 -0
- package/app/assets/css/main.css +2 -0
- package/app/components/GscAnalyzerPanel.vue +94 -0
- package/app/components/GscAnonymizationBanner.vue +46 -0
- package/app/components/GscBootProgress.vue +297 -0
- package/app/components/GscCommandPalette.vue +77 -0
- package/app/components/GscDashboardPage.vue +26 -0
- package/app/components/GscEngineBadge.vue +28 -0
- package/app/components/GscHero.vue +215 -0
- package/app/components/GscLazyTopResult.vue +45 -0
- package/app/components/GscPerformanceChart.vue +532 -0
- package/app/components/GscPositionDistributionChart.vue +63 -0
- package/app/components/GscQueryLabel.vue +63 -0
- package/app/components/GscSitePageHeader.vue +40 -0
- package/app/components/GscSourceDebugPanel.vue +195 -0
- package/app/components/GscSyncStatusCell.vue +54 -0
- package/app/components/GscTopRollupCard.vue +90 -0
- package/app/composables/_useGscBackfill.ts +111 -0
- package/app/composables/_useGscQueryDispatcher.ts +122 -0
- package/app/composables/_useGscResource.ts +114 -0
- package/app/composables/_useGscSharedSiteResource.ts +136 -0
- package/app/composables/useGscAnalytics.ts +197 -0
- package/app/composables/useGscAnalyticsClient.ts +9 -0
- package/app/composables/useGscAnalyticsConfig.ts +8 -0
- package/app/composables/useGscAnalyticsSourceInfo.ts +143 -0
- package/app/composables/useGscAnalyzer.ts +374 -0
- package/app/composables/useGscAnalyzerBatch.ts +106 -0
- package/app/composables/useGscAnalyzerDefs.ts +118 -0
- package/app/composables/useGscAuth.ts +115 -0
- package/app/composables/useGscConsoleUrl.ts +45 -0
- package/app/composables/useGscCountries.ts +47 -0
- package/app/composables/useGscCurrentSite.ts +15 -0
- package/app/composables/useGscEngine.ts +16 -0
- package/app/composables/useGscInspectionHistory.ts +42 -0
- package/app/composables/useGscInspections.ts +52 -0
- package/app/composables/useGscPanelContext.ts +24 -0
- package/app/composables/useGscParquetTable.ts +199 -0
- package/app/composables/useGscPeriod.ts +243 -0
- package/app/composables/useGscQuery.ts +290 -0
- package/app/composables/useGscQueryDispatcher.ts +122 -0
- package/app/composables/useGscRequestIndexingInspect.ts +20 -0
- package/app/composables/useGscResource.ts +114 -0
- package/app/composables/useGscRollup.ts +252 -0
- package/app/composables/useGscRollupTable.ts +78 -0
- package/app/composables/useGscRowQuery.ts +56 -0
- package/app/composables/useGscSearchAppearance.ts +47 -0
- package/app/composables/useGscSitemapHistory.ts +41 -0
- package/app/composables/useGscSitemaps.ts +45 -0
- package/app/composables/useGscTableState.ts +119 -0
- package/app/plugins/analytics.ts +57 -0
- package/app/utils/anonymization.ts +24 -0
- package/app/utils/country-names.ts +56 -0
- package/app/utils/gsc-constants.ts +10 -0
- package/app/utils/gsc-error.ts +58 -0
- package/app/utils/gsc-fetch.ts +94 -0
- package/app/utils/gsc-filters.ts +32 -0
- package/app/utils/gsc-rows.ts +72 -0
- package/app/utils/position.ts +7 -0
- package/app/utils/setup-gsc-fetch-auth.ts +62 -0
- package/module.ts +81 -0
- package/nuxt.config.ts +36 -0
- package/package.json +75 -0
- package/types.ts +114 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Per-site shared resource bag. Consolidates the bind/unbind/refcount pattern
|
|
2
|
+
// that useGscAnalyzer and useGscAnalyticsSourceInfo each reinvented.
|
|
3
|
+
//
|
|
4
|
+
// Two entry points share one underlying bag (a module-scope WeakMap keyed by
|
|
5
|
+
// the NuxtApp instance, so each app gets isolated state and SSR is safe):
|
|
6
|
+
// - useGscSharedSiteResource → composable, refcounted, scope-disposed.
|
|
7
|
+
// - acquireSharedEntry → non-composable acquire for boot-time / non-
|
|
8
|
+
// setup callers (e.g. analyzer createInstance).
|
|
9
|
+
//
|
|
10
|
+
// Refcounting via `onDispose` is opt-in:
|
|
11
|
+
// - omitted → entry persists forever (cheap, share-only; e.g. /source-info)
|
|
12
|
+
// - provided → refcounted; runs onDispose + drops the entry at refs=0
|
|
13
|
+
//
|
|
14
|
+
// Returns a `bound` computed that tracks the entry for the current siteId. The
|
|
15
|
+
// caller wraps it in `computed(() => bound.value?.field ?? default)` to expose
|
|
16
|
+
// reactive fields.
|
|
17
|
+
|
|
18
|
+
import type { ComputedRef } from '@vue/runtime-core'
|
|
19
|
+
import type { NuxtApp } from 'nuxt/app'
|
|
20
|
+
|
|
21
|
+
interface CacheEntry<T> {
|
|
22
|
+
entry: T
|
|
23
|
+
refs: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type NamespaceBag = Map<string, Map<string, CacheEntry<unknown>>>
|
|
27
|
+
|
|
28
|
+
const bags = new WeakMap<NuxtApp, NamespaceBag>()
|
|
29
|
+
|
|
30
|
+
function namespaceMap(namespace: string): Map<string, CacheEntry<unknown>> {
|
|
31
|
+
const app = useNuxtApp()
|
|
32
|
+
let bag = bags.get(app)
|
|
33
|
+
if (!bag) {
|
|
34
|
+
bag = new Map()
|
|
35
|
+
bags.set(app, bag)
|
|
36
|
+
}
|
|
37
|
+
let m = bag.get(namespace)
|
|
38
|
+
if (!m) {
|
|
39
|
+
m = new Map()
|
|
40
|
+
bag.set(namespace, m)
|
|
41
|
+
}
|
|
42
|
+
return m
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Non-composable acquire — returns (and lazily creates) the shared entry for
|
|
47
|
+
* `(namespace, siteId)`. No refcount, no scope dispose. Intended for callers
|
|
48
|
+
* outside a Vue setup scope (analyzer boot IIFE, one-shot loaders) that still
|
|
49
|
+
* need to share state with the reactive `useGscSharedSiteResource` consumers.
|
|
50
|
+
*/
|
|
51
|
+
export function acquireSharedEntry<T>(
|
|
52
|
+
namespace: string,
|
|
53
|
+
siteId: string,
|
|
54
|
+
factory: (siteId: string) => T,
|
|
55
|
+
): T {
|
|
56
|
+
const cache = namespaceMap(namespace) as Map<string, CacheEntry<T>>
|
|
57
|
+
let inst = cache.get(siteId)
|
|
58
|
+
if (!inst) {
|
|
59
|
+
inst = { entry: factory(siteId), refs: 0 }
|
|
60
|
+
cache.set(siteId, inst)
|
|
61
|
+
}
|
|
62
|
+
return inst.entry
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface UseSharedSiteResourceOptions<T> {
|
|
66
|
+
/** Build the entry on first acquire for a site. */
|
|
67
|
+
factory: (siteId: string) => T
|
|
68
|
+
/** When set, refcount the entry and run on the last release (deletes from cache). */
|
|
69
|
+
onDispose?: (entry: T) => Promise<void> | void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface UseSharedSiteResourceReturn<T> {
|
|
73
|
+
bound: ComputedRef<T | null>
|
|
74
|
+
currentSiteId: Ref<string | null>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function useGscSharedSiteResource<T>(
|
|
78
|
+
namespace: string,
|
|
79
|
+
siteId: MaybeRefOrGetter<string | null | undefined>,
|
|
80
|
+
opts: UseSharedSiteResourceOptions<T>,
|
|
81
|
+
): UseSharedSiteResourceReturn<T> {
|
|
82
|
+
const cache = namespaceMap(namespace) as Map<string, CacheEntry<T>>
|
|
83
|
+
|
|
84
|
+
const boundRef = shallowRef<T | null>(null)
|
|
85
|
+
const currentSiteId = ref<string | null>(null)
|
|
86
|
+
|
|
87
|
+
function bind(id: string): void {
|
|
88
|
+
unbind()
|
|
89
|
+
let inst = cache.get(id)
|
|
90
|
+
if (!inst) {
|
|
91
|
+
inst = { entry: opts.factory(id), refs: 0 }
|
|
92
|
+
cache.set(id, inst)
|
|
93
|
+
}
|
|
94
|
+
inst.refs++
|
|
95
|
+
boundRef.value = inst.entry
|
|
96
|
+
currentSiteId.value = id
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function unbind(): void {
|
|
100
|
+
const id = currentSiteId.value
|
|
101
|
+
if (!id) {
|
|
102
|
+
boundRef.value = null
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
const inst = cache.get(id)
|
|
106
|
+
boundRef.value = null
|
|
107
|
+
currentSiteId.value = null
|
|
108
|
+
if (!inst)
|
|
109
|
+
return
|
|
110
|
+
inst.refs--
|
|
111
|
+
if (inst.refs <= 0 && opts.onDispose) {
|
|
112
|
+
cache.delete(id)
|
|
113
|
+
void Promise.resolve(opts.onDispose(inst.entry)).catch(() => {})
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
watch(
|
|
118
|
+
() => toValue(siteId),
|
|
119
|
+
(id: string | null | undefined) => {
|
|
120
|
+
if (!id) {
|
|
121
|
+
unbind()
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
if (import.meta.client)
|
|
125
|
+
bind(id)
|
|
126
|
+
},
|
|
127
|
+
{ immediate: true },
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
onScopeDispose(unbind)
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
bound: computed(() => boundRef.value) as ComputedRef<T | null>,
|
|
134
|
+
currentSiteId,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Analytics context: sites list + shared boot-progress map + per-site analyzer
|
|
2
|
+
// cache. Provided app-wide by the layer's plugin as `nuxtApp.$gscAnalytics`.
|
|
3
|
+
// Pages reach in via narrower hooks (`useGscSites`, `useGscSite`,
|
|
4
|
+
// `useGscAnalyzer`, `useGscBootProgress`) — no global mutable `currentSite`,
|
|
5
|
+
// no leaked progress writers.
|
|
6
|
+
|
|
7
|
+
import type { SiteListItem } from '@gscdump/contracts'
|
|
8
|
+
import { useGscAnalyticsClient } from './useGscAnalyticsClient'
|
|
9
|
+
|
|
10
|
+
export type { SiteListItem }
|
|
11
|
+
|
|
12
|
+
export type ReadBackend = 'd1' | 'r2'
|
|
13
|
+
|
|
14
|
+
export interface SiteRecord extends SiteListItem {
|
|
15
|
+
/** Canonical GSC site URL. Alias of `label` for back-compat. */
|
|
16
|
+
siteUrl: string
|
|
17
|
+
/**
|
|
18
|
+
* Which backend serves analysis for this site. Carried through from the
|
|
19
|
+
* sites endpoint; when omitted the value defaults to `'r2'` so legacy
|
|
20
|
+
* consumers keep working. Browser-attach eligibility is ultimately
|
|
21
|
+
* decided by the server's source provider via `browserAttachEligible`
|
|
22
|
+
* on `/api/__gsc/sites/:siteId/source-info`, not by this field.
|
|
23
|
+
*/
|
|
24
|
+
readBackend: ReadBackend
|
|
25
|
+
syncStatus?: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type SiteLoadStage = 'idle' | 'wasm' | 'manifest' | 'attach' | 'ready' | 'error'
|
|
29
|
+
|
|
30
|
+
export interface SiteLoadProgress {
|
|
31
|
+
siteId: string
|
|
32
|
+
stage: SiteLoadStage
|
|
33
|
+
/** Sub-units (parquet files, rollup JSONs) completed. */
|
|
34
|
+
filesAttached: number
|
|
35
|
+
/** Sub-units expected; 0 while unknown. */
|
|
36
|
+
filesTotal: number
|
|
37
|
+
startedAt: number
|
|
38
|
+
endedAt?: number
|
|
39
|
+
error?: string
|
|
40
|
+
source?: 'duckdb' | 'rollup' | string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface GscAnalyticsContext {
|
|
44
|
+
sites: Ref<SiteListItem[] | null>
|
|
45
|
+
sitesLoading: Readonly<Ref<boolean>>
|
|
46
|
+
sitesError: Readonly<Ref<Error | null>>
|
|
47
|
+
refreshSites: () => Promise<void>
|
|
48
|
+
progress: Readonly<Ref<Record<string, SiteLoadProgress>>>
|
|
49
|
+
/** Escape hatch for demos/tests — write raw progress entries. */
|
|
50
|
+
patchProgress: (siteId: string, patch: Partial<SiteLoadProgress>) => void
|
|
51
|
+
/** Escape hatch for demos/tests — clear progress map (all or one site). */
|
|
52
|
+
clearProgress: (siteId?: string) => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Factory exposed so the layer's Nuxt plugin can provide a single app-wide
|
|
56
|
+
// context via `provide: { gscAnalytics: createGscAnalyticsContext() }`.
|
|
57
|
+
export function createGscAnalyticsContext(): GscAnalyticsContext {
|
|
58
|
+
return createContext()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Resolve the shared context. The layer's plugin guarantees this is provided. */
|
|
62
|
+
export function useGscAnalyticsContext(): GscAnalyticsContext {
|
|
63
|
+
return useNuxtApp().$gscAnalytics
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Read-only sites list + refresh. */
|
|
67
|
+
export function useGscSites(): {
|
|
68
|
+
sites: Ref<SiteListItem[] | null>
|
|
69
|
+
loading: Readonly<Ref<boolean>>
|
|
70
|
+
error: Readonly<Ref<Error | null>>
|
|
71
|
+
refresh: () => Promise<void>
|
|
72
|
+
} {
|
|
73
|
+
const { sites, sitesLoading, sitesError, refreshSites } = useGscAnalyticsContext()
|
|
74
|
+
return { sites, loading: sitesLoading, error: sitesError, refresh: refreshSites }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Derived site record for a given id. Non-mutating; returns `null` until the list resolves. */
|
|
78
|
+
export function useGscSite(siteId: MaybeRefOrGetter<string | null | undefined>): Ref<SiteRecord | null> {
|
|
79
|
+
const { sites } = useGscAnalyticsContext()
|
|
80
|
+
return computed(() => {
|
|
81
|
+
const id = toValue(siteId)
|
|
82
|
+
if (!id)
|
|
83
|
+
return null
|
|
84
|
+
const found = sites.value?.find((s: SiteListItem) => s.id === id)
|
|
85
|
+
if (found)
|
|
86
|
+
return toSiteRecord(found)
|
|
87
|
+
// List not yet loaded — stamp a minimal record so downstream queries can
|
|
88
|
+
// dispatch against the id. Gets upgraded when sites resolves.
|
|
89
|
+
return {
|
|
90
|
+
id,
|
|
91
|
+
label: id,
|
|
92
|
+
hostname: id,
|
|
93
|
+
propertyType: 'url-prefix',
|
|
94
|
+
siteUrl: id,
|
|
95
|
+
readBackend: 'r2',
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function toSiteRecord(s: SiteListItem): SiteRecord {
|
|
101
|
+
return {
|
|
102
|
+
...s,
|
|
103
|
+
siteUrl: s.label,
|
|
104
|
+
readBackend: s.readBackend ?? 'r2',
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Read-only boot-progress map for UI indicators. */
|
|
109
|
+
export function useGscBootProgress(): {
|
|
110
|
+
progress: Readonly<Ref<Record<string, SiteLoadProgress>>>
|
|
111
|
+
entries: ComputedRef<SiteLoadProgress[]>
|
|
112
|
+
allReady: ComputedRef<boolean>
|
|
113
|
+
anyActive: ComputedRef<boolean>
|
|
114
|
+
} {
|
|
115
|
+
const { progress } = useGscAnalyticsContext()
|
|
116
|
+
const entries = computed<SiteLoadProgress[]>(() => {
|
|
117
|
+
const all = Object.values(progress.value) as SiteLoadProgress[]
|
|
118
|
+
return all.sort((a, b) => a.startedAt - b.startedAt)
|
|
119
|
+
})
|
|
120
|
+
const allReady = computed(() =>
|
|
121
|
+
entries.value.length > 0 && entries.value.every((s: SiteLoadProgress) => s.stage === 'ready'),
|
|
122
|
+
)
|
|
123
|
+
const anyActive = computed(() =>
|
|
124
|
+
entries.value.some((s: SiteLoadProgress) => s.stage !== 'ready' && s.stage !== 'idle' && s.stage !== 'error'),
|
|
125
|
+
)
|
|
126
|
+
return { progress, entries, allReady, anyActive }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function createContext(): GscAnalyticsContext {
|
|
130
|
+
const sites = ref<SiteListItem[] | null>(null)
|
|
131
|
+
const sitesLoading = ref(false)
|
|
132
|
+
const sitesError = ref<Error | null>(null)
|
|
133
|
+
const progress = ref<Record<string, SiteLoadProgress>>({})
|
|
134
|
+
|
|
135
|
+
async function refreshSites(): Promise<void> {
|
|
136
|
+
sitesLoading.value = true
|
|
137
|
+
sitesError.value = null
|
|
138
|
+
sites.value = await useGscAnalyticsClient().listSites().catch((err) => {
|
|
139
|
+
sitesError.value = err instanceof Error ? err : new Error(String(err))
|
|
140
|
+
return null
|
|
141
|
+
})
|
|
142
|
+
sitesLoading.value = false
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function patchProgress(siteId: string, patch: Partial<SiteLoadProgress>): void {
|
|
146
|
+
const prev = progress.value[siteId]
|
|
147
|
+
progress.value = {
|
|
148
|
+
...progress.value,
|
|
149
|
+
[siteId]: {
|
|
150
|
+
siteId,
|
|
151
|
+
stage: 'idle',
|
|
152
|
+
filesAttached: 0,
|
|
153
|
+
filesTotal: 0,
|
|
154
|
+
startedAt: prev?.startedAt ?? Date.now(),
|
|
155
|
+
...prev,
|
|
156
|
+
...patch,
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function clearProgress(siteId?: string): void {
|
|
162
|
+
if (!siteId) {
|
|
163
|
+
progress.value = {}
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
const next = { ...progress.value }
|
|
167
|
+
delete next[siteId]
|
|
168
|
+
progress.value = next
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Lazy-load: defer refreshSites until something actually reads `sites`.
|
|
172
|
+
// Eager kick-off used to race host plugins that set auth via `setGscAuth`;
|
|
173
|
+
// first call would 401 and the rest of the session would silently degrade.
|
|
174
|
+
// Triggering on first read defers the request to a microtask after plugin
|
|
175
|
+
// setup, so auth state is in place.
|
|
176
|
+
let kickedOff = false
|
|
177
|
+
function maybeKickOff(): void {
|
|
178
|
+
if (kickedOff || !import.meta.client)
|
|
179
|
+
return
|
|
180
|
+
kickedOff = true
|
|
181
|
+
refreshSites()
|
|
182
|
+
}
|
|
183
|
+
const lazySites = computed<SiteListItem[] | null>(() => {
|
|
184
|
+
maybeKickOff()
|
|
185
|
+
return sites.value
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
sites: lazySites as Readonly<Ref<SiteListItem[] | null>> as unknown as Ref<SiteListItem[] | null>,
|
|
190
|
+
sitesLoading: sitesLoading as Readonly<Ref<boolean>>,
|
|
191
|
+
sitesError: sitesError as Readonly<Ref<Error | null>>,
|
|
192
|
+
refreshSites,
|
|
193
|
+
progress: progress as Readonly<Ref<Record<string, SiteLoadProgress>>>,
|
|
194
|
+
patchProgress,
|
|
195
|
+
clearProgress,
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Reader for the layer's `AnalyticsClient`. Built once per NuxtApp by the
|
|
2
|
+
// layer plugin and provided as `$gscAnalyticsClient`. Hosts override by
|
|
3
|
+
// providing their own from a later plugin (e.g. to swap the SDK transport).
|
|
4
|
+
|
|
5
|
+
import type { AnalyticsClient } from '@gscdump/sdk'
|
|
6
|
+
|
|
7
|
+
export function useGscAnalyticsClient(): AnalyticsClient {
|
|
8
|
+
return useNuxtApp().$gscAnalyticsClient as AnalyticsClient
|
|
9
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Typed reader for `runtimeConfig.public.analytics`. Single accessor so leaf
|
|
2
|
+
// composables stop re-typing the cast and silently drift when keys are added.
|
|
3
|
+
|
|
4
|
+
import type { GscAnalyticsRuntimeConfig } from '../../types'
|
|
5
|
+
|
|
6
|
+
export function useGscAnalyticsConfig(): GscAnalyticsRuntimeConfig {
|
|
7
|
+
return useRuntimeConfig().public.analytics as unknown as GscAnalyticsRuntimeConfig
|
|
8
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Per-site reactive probe of what the server-resolved AnalysisQuerySource
|
|
2
|
+
// can do. The layer exposes source shape (kind + analyzer set); tier logic
|
|
3
|
+
// lives in the host.
|
|
4
|
+
//
|
|
5
|
+
// Use this to render locked/ghost UI upfront without issuing a doomed
|
|
6
|
+
// analyze() call for each panel.
|
|
7
|
+
//
|
|
8
|
+
// The reactive composable and the non-reactive `loadSourceInfoFor` (used by
|
|
9
|
+
// `useGscAnalyzer.createInstance`) share one entry per site via the shared
|
|
10
|
+
// site-resource seam, so they collapse to one network read per session.
|
|
11
|
+
|
|
12
|
+
import type { SourceCapabilities } from '@gscdump/analysis'
|
|
13
|
+
import { acquireSharedEntry, useGscSharedSiteResource } from './_useGscSharedSiteResource'
|
|
14
|
+
import { useGscAnalyticsClient } from './useGscAnalyticsClient'
|
|
15
|
+
|
|
16
|
+
export interface GscAnalyticsSourceInfo {
|
|
17
|
+
/** Source implementation name — e.g. "gsc-api", "engine", "browser". */
|
|
18
|
+
name: string
|
|
19
|
+
/** `row` = GSC API / in-memory; `sql` = DuckDB/SQLite-backed. */
|
|
20
|
+
kind: 'row' | 'sql'
|
|
21
|
+
capabilities: SourceCapabilities
|
|
22
|
+
/** Analyzer ids runnable against this source, from the default registry. */
|
|
23
|
+
supportedAnalyzerIds: string[]
|
|
24
|
+
/** Host declared attach hint — client should boot DuckDB-WASM and attach parquets. */
|
|
25
|
+
browserAttachEligible: boolean
|
|
26
|
+
/**
|
|
27
|
+
* Host-controlled identity attrs echoed back for debug/display (e.g.
|
|
28
|
+
* `{ tier: 'free' | 'pro' }`). Layer treats as opaque — never use these
|
|
29
|
+
* for gating on the client, the source provider is the gate.
|
|
30
|
+
*/
|
|
31
|
+
identityAttrs?: Record<string, unknown> | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface GscAnalyticsSourceInfoState {
|
|
35
|
+
info: Ref<GscAnalyticsSourceInfo | null>
|
|
36
|
+
loading: Ref<boolean>
|
|
37
|
+
error: Ref<Error | null>
|
|
38
|
+
refresh: () => Promise<void>
|
|
39
|
+
/** Convenience predicate for panel gating: true when the analyzer id is in `supportedAnalyzerIds`. */
|
|
40
|
+
supports: (analyzerId: MaybeRefOrGetter<string>) => ComputedRef<boolean>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface SourceInfoEntry {
|
|
44
|
+
info: Ref<GscAnalyticsSourceInfo | null>
|
|
45
|
+
loading: Ref<boolean>
|
|
46
|
+
error: Ref<Error | null>
|
|
47
|
+
pending: Ref<Promise<void> | null>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SOURCE_INFO_NAMESPACE = 'source-info'
|
|
51
|
+
|
|
52
|
+
function createEntry(): SourceInfoEntry {
|
|
53
|
+
return {
|
|
54
|
+
info: ref<GscAnalyticsSourceInfo | null>(null),
|
|
55
|
+
loading: ref(false),
|
|
56
|
+
error: ref<Error | null>(null),
|
|
57
|
+
pending: shallowRef<Promise<void> | null>(null),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fetchInto(entry: SourceInfoEntry, siteId: string): Promise<void> {
|
|
62
|
+
if (entry.pending.value)
|
|
63
|
+
return entry.pending.value
|
|
64
|
+
entry.loading.value = true
|
|
65
|
+
entry.error.value = null
|
|
66
|
+
const p = (useGscAnalyticsClient().getSourceInfo(siteId) as Promise<GscAnalyticsSourceInfo>)
|
|
67
|
+
.then((data) => {
|
|
68
|
+
entry.info.value = data
|
|
69
|
+
})
|
|
70
|
+
.catch((err: unknown) => {
|
|
71
|
+
entry.error.value = err instanceof Error ? err : new Error(String(err))
|
|
72
|
+
})
|
|
73
|
+
.finally(() => {
|
|
74
|
+
entry.loading.value = false
|
|
75
|
+
entry.pending.value = null
|
|
76
|
+
})
|
|
77
|
+
entry.pending.value = p
|
|
78
|
+
return p
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Non-reactive source-info loader for callers outside a Vue scope (e.g. the
|
|
83
|
+
* analyzer's boot IIFE in `useGscAnalyzer.createInstance`). Shares the same
|
|
84
|
+
* per-site cache `useGscAnalyticsSourceInfo` consumes via the shared
|
|
85
|
+
* site-resource seam, so gating UI and the analyzer mode probe collapse to
|
|
86
|
+
* one network read per site per session.
|
|
87
|
+
*/
|
|
88
|
+
export async function loadSourceInfoFor(siteId: string): Promise<GscAnalyticsSourceInfo> {
|
|
89
|
+
const entry = acquireSharedEntry(SOURCE_INFO_NAMESPACE, siteId, createEntry)
|
|
90
|
+
if (entry.info.value)
|
|
91
|
+
return entry.info.value
|
|
92
|
+
await fetchInto(entry, siteId)
|
|
93
|
+
if (entry.error.value)
|
|
94
|
+
throw entry.error.value
|
|
95
|
+
if (!entry.info.value)
|
|
96
|
+
throw new Error(`loadSourceInfoFor(${siteId}): no info after fetch`)
|
|
97
|
+
return entry.info.value
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function useGscAnalyticsSourceInfo(
|
|
101
|
+
siteId: MaybeRefOrGetter<string | null | undefined>,
|
|
102
|
+
): GscAnalyticsSourceInfoState {
|
|
103
|
+
const { bound } = useGscSharedSiteResource<SourceInfoEntry>(SOURCE_INFO_NAMESPACE, siteId, {
|
|
104
|
+
factory: () => createEntry(),
|
|
105
|
+
// No onDispose: source-info is cheap and persistent across the session.
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
if (import.meta.client) {
|
|
109
|
+
watch(bound, (entry: SourceInfoEntry | null) => {
|
|
110
|
+
if (!entry)
|
|
111
|
+
return
|
|
112
|
+
const id = toValue(siteId)
|
|
113
|
+
if (!id)
|
|
114
|
+
return
|
|
115
|
+
if (entry.info.value == null && entry.pending.value == null && entry.error.value == null)
|
|
116
|
+
void fetchInto(entry, id)
|
|
117
|
+
}, { immediate: true })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const info = computed(() => bound.value?.info.value ?? null) as unknown as Ref<GscAnalyticsSourceInfo | null>
|
|
121
|
+
const loading = computed(() => bound.value?.loading.value ?? false) as unknown as Ref<boolean>
|
|
122
|
+
const error = computed(() => bound.value?.error.value ?? null) as unknown as Ref<Error | null>
|
|
123
|
+
|
|
124
|
+
async function refresh(): Promise<void> {
|
|
125
|
+
const entry = bound.value
|
|
126
|
+
const id = toValue(siteId)
|
|
127
|
+
if (!entry || !id)
|
|
128
|
+
return
|
|
129
|
+
entry.info.value = null
|
|
130
|
+
await fetchInto(entry, id)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function supports(analyzerId: MaybeRefOrGetter<string>): ComputedRef<boolean> {
|
|
134
|
+
return computed(() => {
|
|
135
|
+
const list = bound.value?.info.value?.supportedAnalyzerIds
|
|
136
|
+
if (!list)
|
|
137
|
+
return false
|
|
138
|
+
return list.includes(toValue(analyzerId))
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { info, loading, error, refresh, supports }
|
|
143
|
+
}
|