@@ -7,19 +7,41 @@ import {
77 SelectorOptionsUnavailableError ,
88} from '@/lib/selectors/server/errors'
99import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results'
10- import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http'
10+ import {
11+ fetchProviderJson ,
12+ fetchProviderJsonWithStatus ,
13+ } from '@/lib/selectors/server/providers/provider-http'
1114import type {
1215 ExecuteServerSelectorArgs ,
1316 ServerSelectorAttachmentMap ,
1417} from '@/lib/selectors/server/types'
18+ import { detailSelectorResult , listSelectorResult } from '@/lib/selectors/server/types'
1519import type { SafeSelectorOption } from '@/lib/selectors/types'
1620
1721type WebflowSelectorKey = Extract <
1822 ServerSelectorKey ,
1923 'webflow.sites' | 'webflow.collections' | 'webflow.items'
2024>
2125
22- const WEBFLOW_MAX_ITEM_PAGES = 50
26+ const WEBFLOW_ITEM_PAGE_SIZE = 100
27+
28+ interface WebflowItem {
29+ id ?: unknown
30+ fieldData ?: {
31+ name ?: unknown
32+ title ?: unknown
33+ slug ?: unknown
34+ }
35+ }
36+
37+ interface WebflowItemPage {
38+ items ?: unknown
39+ pagination ?: {
40+ limit ?: unknown
41+ offset ?: unknown
42+ total ?: unknown
43+ }
44+ }
2345
2446const credential = {
2547 kind : 'stored' ,
@@ -43,6 +65,34 @@ function requireWebflowId(value: string | undefined, name: string): string {
4365 return validation . sanitized ?? value
4466}
4567
68+ function parseItemOffset ( cursor : string | undefined ) : number {
69+ if ( cursor === undefined ) return 0
70+ if ( ! / ^ ( 0 | [ 1 - 9 ] \d * ) $ / . test ( cursor ) ) throw new SelectorContextUnavailableError ( )
71+ const offset = Number ( cursor )
72+ if ( ! Number . isSafeInteger ( offset ) ) throw new SelectorContextUnavailableError ( )
73+ return offset
74+ }
75+
76+ function projectItem ( item : WebflowItem ) : SafeSelectorOption | null {
77+ if ( typeof item . id !== 'string' || ! item . id ) return null
78+ const { name, title, slug } = item . fieldData ?? { }
79+ const label = [ name , title , slug ] . find (
80+ ( candidate ) : candidate is string => typeof candidate === 'string' && candidate . length > 0
81+ )
82+ return { id : item . id , label : label ?? item . id }
83+ }
84+
85+ function requirePaginationNumber ( value : unknown , options : { positive ?: boolean } = { } ) : number {
86+ if (
87+ ! Number . isSafeInteger ( value ) ||
88+ ( value as number ) < 0 ||
89+ ( options . positive && ( value as number ) === 0 )
90+ ) {
91+ throw new SelectorOptionsUnavailableError ( )
92+ }
93+ return value as number
94+ }
95+
4696async function listSites ( args : ExecuteServerSelectorArgs ) : Promise < SafeSelectorOption [ ] > {
4797 const token = await tokenFor ( args )
4898 const data = await fetchProviderJson < {
@@ -74,69 +124,104 @@ async function listCollections(args: ExecuteServerSelectorArgs): Promise<SafeSel
74124 } ) )
75125}
76126
77- async function listItems (
78- args : ExecuteServerSelectorArgs
79- ) : Promise < { items : SafeSelectorOption [ ] ; truncated : boolean } > {
127+ async function listItems ( args : ExecuteServerSelectorArgs ) {
128+ if ( args . request . kind !== 'list' ) throw new SelectorContextUnavailableError ( )
129+ const offset = parseItemOffset ( args . request . cursor )
80130 const collectionId = requireWebflowId ( args . context . collectionId , 'collectionId' )
81131 const token = await tokenFor ( args )
82- const items : Array < {
83- id : string
84- fieldData ?: { name ?: string ; title ?: string ; slug ?: string }
85- } > = [ ]
86- let offset = 0
87- let truncated = false
88- for ( let page = 0 ; page < WEBFLOW_MAX_ITEM_PAGES ; page ++ ) {
89- const url = new URL (
90- `https://api.webflow.com/v2/collections/${ encodeURIComponent ( collectionId ) } /items`
91- )
92- url . searchParams . set ( 'limit' , '100' )
93- url . searchParams . set ( 'offset' , String ( offset ) )
94- const data = await fetchProviderJson < {
95- items ?: typeof items
96- pagination ?: { total ?: number }
97- } > ( url , {
98- headers : { Authorization : `Bearer ${ token } ` , accept : 'application/json' } ,
99- signal : args . signal ,
100- redirect : 'error' ,
101- } )
102- const pageItems = data . items ?? [ ]
103- const reportedTotal = data . pagination ?. total
104- if ( reportedTotal !== undefined && ( ! Number . isInteger ( reportedTotal ) || reportedTotal < 0 ) ) {
105- throw new SelectorOptionsUnavailableError ( )
106- }
107- items . push ( ...pageItems )
108- offset += pageItems . length
109- if ( reportedTotal !== undefined && items . length >= reportedTotal ) {
110- break
111- }
112- if ( pageItems . length === 0 ) {
113- if ( reportedTotal !== undefined && items . length < reportedTotal ) truncated = true
114- break
115- }
116- if ( page === WEBFLOW_MAX_ITEM_PAGES - 1 ) truncated = true
132+ const url = new URL (
133+ `https://api.webflow.com/v2/collections/${ encodeURIComponent ( collectionId ) } /items`
134+ )
135+ url . searchParams . set ( 'limit' , String ( WEBFLOW_ITEM_PAGE_SIZE ) )
136+ url . searchParams . set ( 'offset' , String ( offset ) )
137+ const search = args . request . search ?. trim ( )
138+ if ( search ) url . searchParams . set ( 'filter[name][contains]' , search )
139+
140+ const data = await fetchProviderJson < WebflowItemPage > ( url , {
141+ headers : { Authorization : `Bearer ${ token } ` , accept : 'application/json' } ,
142+ signal : args . signal ,
143+ redirect : 'error' ,
144+ } )
145+ if (
146+ ! data ||
147+ typeof data !== 'object' ||
148+ ! Array . isArray ( data . items ) ||
149+ ! data . pagination ||
150+ typeof data . pagination !== 'object' ||
151+ Array . isArray ( data . pagination )
152+ ) {
153+ throw new SelectorOptionsUnavailableError ( )
117154 }
118155
119- const search = args . request . kind === 'list' ? args . request . search ?. toLowerCase ( ) : undefined
120- return {
121- items : items . flatMap ( ( item ) => {
122- if ( ! item . id ) return [ ]
123- const label = item . fieldData ?. name || item . fieldData ?. title || item . fieldData ?. slug || item . id
124- return search && ! label . toLowerCase ( ) . includes ( search ) ? [ ] : [ { id : item . id , label } ]
125- } ) ,
126- truncated,
156+ const reportedLimit =
157+ data . pagination . limit === undefined
158+ ? WEBFLOW_ITEM_PAGE_SIZE
159+ : requirePaginationNumber ( data . pagination . limit , { positive : true } )
160+ const reportedOffset =
161+ data . pagination . offset === undefined ? offset : requirePaginationNumber ( data . pagination . offset )
162+ const reportedTotal =
163+ data . pagination . total === undefined ? undefined : requirePaginationNumber ( data . pagination . total )
164+ if (
165+ reportedLimit > WEBFLOW_ITEM_PAGE_SIZE ||
166+ reportedOffset !== offset ||
167+ data . items . length > reportedLimit
168+ ) {
169+ throw new SelectorOptionsUnavailableError ( )
170+ }
171+ if ( data . items . length === 0 && reportedTotal !== undefined && reportedOffset < reportedTotal ) {
172+ throw new SelectorOptionsUnavailableError ( )
127173 }
128- }
129174
130- async function executeItems ( args : ExecuteServerSelectorArgs ) {
131- const { items, truncated } = await listItems ( args )
132- return flatSelectorResult (
133- args . request ,
175+ const nextOffset = reportedOffset + data . items . length
176+ if (
177+ ! Number . isSafeInteger ( nextOffset ) ||
178+ ( nextOffset <= reportedOffset && data . items . length > 0 )
179+ ) {
180+ throw new SelectorOptionsUnavailableError ( )
181+ }
182+ const items = data . items . flatMap ( ( item ) => {
183+ if ( ! item || typeof item !== 'object' ) return [ ]
184+ const option = projectItem ( item as WebflowItem )
185+ return option ? [ option ] : [ ]
186+ } )
187+ return listSelectorResult (
134188 items ,
135- false ,
136- truncated ? { truncated : { reason : 'provider-cap' , pages : WEBFLOW_MAX_ITEM_PAGES } } : undefined
189+ data . items . length > 0 &&
190+ ( reportedTotal === undefined
191+ ? data . items . length === reportedLimit
192+ : nextOffset < reportedTotal )
193+ ? String ( nextOffset )
194+ : undefined
137195 )
138196}
139197
198+ async function getItem ( args : ExecuteServerSelectorArgs ) {
199+ if ( args . request . kind !== 'detail' ) throw new SelectorContextUnavailableError ( )
200+ const collectionId = requireWebflowId ( args . context . collectionId , 'collectionId' )
201+ const itemId = requireWebflowId ( args . request . id , 'itemId' )
202+ const token = await tokenFor ( args )
203+ const result = await fetchProviderJsonWithStatus < WebflowItem > (
204+ `https://api.webflow.com/v2/collections/${ encodeURIComponent ( collectionId ) } /items/${ encodeURIComponent ( itemId ) } ` ,
205+ {
206+ headers : { Authorization : `Bearer ${ token } ` , accept : 'application/json' } ,
207+ signal : args . signal ,
208+ redirect : 'error' ,
209+ } ,
210+ { passthroughStatuses : [ 404 ] }
211+ )
212+ if ( ! result . ok ) return detailSelectorResult ( null )
213+ if ( ! result . data || typeof result . data !== 'object' ) {
214+ throw new SelectorOptionsUnavailableError ( )
215+ }
216+ const item = projectItem ( result . data )
217+ if ( ! item || item . id !== itemId ) throw new SelectorOptionsUnavailableError ( )
218+ return detailSelectorResult ( item )
219+ }
220+
221+ function executeItems ( args : ExecuteServerSelectorArgs ) {
222+ return args . request . kind === 'detail' ? getItem ( args ) : listItems ( args )
223+ }
224+
140225export const webflowSelectorAttachments = {
141226 'webflow.sites' : {
142227 credential,
0 commit comments