From f611fa11d949c19fb5d404b4bebe4d96b80cba74 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Fri, 2 May 2025 12:03:41 -0400 Subject: [PATCH] feat (content api): add schemas and models for search results and guides (#35284) Add models and GraphQL interface definitions for search results and guides (representing Markdown documents such as tutorials, etc.). These aren't connected to anything yet, but putting them in a separate PR to keep the review short and relatively simple. Towards DOCS-214 --- apps/docs/features/helpers.misc.ts | 17 + .../globalSearch/globalSearchInterface.ts | 5 + .../globalSearch/globalSearchSchema.ts | 22 ++ apps/docs/resources/guide/guideModel.ts | 37 ++ apps/docs/resources/guide/guideSchema.ts | 56 +++ apps/docs/resources/utils/connections.ts | 357 ++++++++++++++++++ 6 files changed, 494 insertions(+) create mode 100644 apps/docs/features/helpers.misc.ts create mode 100644 apps/docs/resources/globalSearch/globalSearchInterface.ts create mode 100644 apps/docs/resources/globalSearch/globalSearchSchema.ts create mode 100644 apps/docs/resources/guide/guideModel.ts create mode 100644 apps/docs/resources/guide/guideSchema.ts create mode 100644 apps/docs/resources/utils/connections.ts diff --git a/apps/docs/features/helpers.misc.ts b/apps/docs/features/helpers.misc.ts new file mode 100644 index 00000000000..127958425cd --- /dev/null +++ b/apps/docs/features/helpers.misc.ts @@ -0,0 +1,17 @@ +/** + * Creates a short random identifier + */ +export function nanoId( + /** + * The length of the identifier to generate + */ + length?: number +) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + const l = length || 12 + let id = '' + for (let i = 0; i < l; i++) { + id += chars.charAt(Math.floor(Math.random() * chars.length)) + } + return id +} diff --git a/apps/docs/resources/globalSearch/globalSearchInterface.ts b/apps/docs/resources/globalSearch/globalSearchInterface.ts new file mode 100644 index 00000000000..ecf1bab91a6 --- /dev/null +++ b/apps/docs/resources/globalSearch/globalSearchInterface.ts @@ -0,0 +1,5 @@ +export interface SearchResultInterface { + title?: string + href?: string + content?: string +} diff --git a/apps/docs/resources/globalSearch/globalSearchSchema.ts b/apps/docs/resources/globalSearch/globalSearchSchema.ts new file mode 100644 index 00000000000..e62dcd06b8d --- /dev/null +++ b/apps/docs/resources/globalSearch/globalSearchSchema.ts @@ -0,0 +1,22 @@ +import { GraphQLInterfaceType, GraphQLString } from 'graphql' + +export const GRAPHQL_FIELD_SEARCH_GLOBAL = 'searchDocs' + +export const GraphQLInterfaceTypeSearchResult = new GraphQLInterfaceType({ + name: 'SearchResult', + description: 'Document that matches a search query', + fields: { + title: { + type: GraphQLString, + description: 'The title of the matching result', + }, + href: { + type: GraphQLString, + description: 'The URL of the matching result', + }, + content: { + type: GraphQLString, + description: 'The full content of the matching result', + }, + }, +}) diff --git a/apps/docs/resources/guide/guideModel.ts b/apps/docs/resources/guide/guideModel.ts new file mode 100644 index 00000000000..a1ba712600f --- /dev/null +++ b/apps/docs/resources/guide/guideModel.ts @@ -0,0 +1,37 @@ +import { SearchResultInterface } from '../globalSearch/globalSearchInterface' + +export class GuideModel implements SearchResultInterface { + public title?: string + public href?: string + public content?: string + public subsections: Array + + constructor({ + title, + href, + content, + subsections, + }: { + title?: string + href?: string + content?: string + subsections?: Array<{ title?: string; href?: string; content?: string }> + }) { + this.title = title + this.href = href + this.content = content + this.subsections = subsections?.map((subsection) => new SubsectionModel(subsection)) ?? [] + } +} + +export class SubsectionModel { + public title?: string + public href?: string + public content?: string + + constructor({ title, href, content }: { title?: string; href?: string; content?: string }) { + this.title = title + this.href = href + this.content = content + } +} diff --git a/apps/docs/resources/guide/guideSchema.ts b/apps/docs/resources/guide/guideSchema.ts new file mode 100644 index 00000000000..546ac1a3fd3 --- /dev/null +++ b/apps/docs/resources/guide/guideSchema.ts @@ -0,0 +1,56 @@ +import { GraphQLObjectType, GraphQLString } from 'graphql' +import { GraphQLInterfaceTypeSearchResult } from '../globalSearch/globalSearchSchema' +import { createCollectionType, GraphQLCollectionBuilder } from '../utils/connections' +import { GuideModel, SubsectionModel } from './guideModel' + +export const GraphQLObjectTypeSubsection = new GraphQLObjectType({ + name: 'Subsection', + isTypeOf: (value: unknown) => value instanceof SubsectionModel, + description: 'A content chunk taken from a larger document in the Supabase docs', + fields: { + title: { + type: GraphQLString, + description: 'The title of the subsection', + }, + href: { + type: GraphQLString, + description: 'The URL of the subsection', + }, + content: { + type: GraphQLString, + description: 'The content of the subsection', + }, + }, +}) + +export const GraphQLObjectTypeGuide = new GraphQLObjectType({ + name: 'Guide', + interfaces: [GraphQLInterfaceTypeSearchResult], + isTypeOf: (value: unknown) => value instanceof GuideModel, + description: + 'A document containing content from the Supabase docs. This is a guide, which might describe a concept, or explain the steps for using or implementing a feature.', + fields: { + title: { + type: GraphQLString, + description: 'The title of the document', + }, + href: { + type: GraphQLString, + description: 'The URL of the document', + }, + content: { + type: GraphQLString, + description: + 'The full content of the document, including all subsections (both those matching and not matching any query string) and possibly more content', + }, + subsections: { + type: createCollectionType(GraphQLObjectTypeSubsection, { + skipPageInfo: true, + description: 'A collection of content chunks from a larger document in the Supabase docs.', + }), + description: + 'The subsections of the document. If the document is returned from a search match, only matching content chunks are returned. For the full content of the original document, use the content field in the parent Guide.', + resolve: (node: GuideModel) => GraphQLCollectionBuilder.create({ items: node.subsections }), + }, + }, +}) diff --git a/apps/docs/resources/utils/connections.ts b/apps/docs/resources/utils/connections.ts new file mode 100644 index 00000000000..19ca714cfc3 --- /dev/null +++ b/apps/docs/resources/utils/connections.ts @@ -0,0 +1,357 @@ +import { + GraphQLBoolean, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + type GraphQLOutputType, + GraphQLString, +} from 'graphql' +import { nanoId } from '~/features/helpers.misc' + +/** + * Extracts the name from a a GraphQLOutputType. + */ +function extractNodeTypeName( + /** + * The GraphQL Output Type to extract the name from. + */ + nodeType: GraphQLOutputType +): string | undefined { + if ('name' in nodeType) { + return nodeType.name + } else { + return `AnonymousNode(${nanoId()})` + } +} + +/** + * Creates an Edge type for a specific node type. An Edge type wraps the node + * alongside an optional associated cursor, for example: + * + * ``` + * { + * edges: [ + * { + * node: { + * id: '123', + * name: 'John Doe' + * }, + * cursor: 'YXJyYXljb25uZWN0aW9uOjE=' + * } + * ] + * } + * ``` + */ +function createEdgeType( + nodeType: GraphQLOutputType, + { + name, + skipCursor = false, + }: { + /** + * The name of the created Edge node. If not provided, defaults to + * NameOfInnerNodeEdge. + */ + name?: string + /** + * Whether to skip the cursor field. + */ + skipCursor?: boolean + } = {} +): GraphQLObjectType { + const edgeName = name || `${extractNodeTypeName(nodeType)}Edge` + + return new GraphQLObjectType({ + name: edgeName, + description: `An edge in a collection of ${extractNodeTypeName(nodeType)}s`, + fields: { + node: { + type: new GraphQLNonNull(nodeType), + description: `The ${extractNodeTypeName(nodeType)} at the end of the edge`, + }, + ...(!skipCursor && { + cursor: { + type: new GraphQLNonNull(GraphQLString), + description: 'A cursor for use in pagination', + }, + }), + }, + }) +} + +/** + * Standard GraphQL type for pagination information on connections + */ +const PageInfoType = new GraphQLObjectType({ + name: 'PageInfo', + description: 'Pagination information for a collection', + fields: { + hasNextPage: { + type: new GraphQLNonNull(GraphQLBoolean), + description: 'Whether there are more items after the current page', + }, + hasPreviousPage: { + type: new GraphQLNonNull(GraphQLBoolean), + description: 'Whether there are more items before the current page', + }, + startCursor: { + type: GraphQLString, + description: 'Cursor pointing to the start of the current page', + }, + endCursor: { + type: GraphQLString, + description: 'Cursor pointing to the end of the current page', + }, + }, +}) + +/** + * Creates a Collection type for a specific node type. A collection type + * represents a list of nodes with pagination information, and has the shape: + * + * ``` + * { + * edges: [ + * { + * node: { ... }, + * cursor: '...' + * }, + * ... + * ], + * nodes: [ ... ], + * pageInfo: { + * hasNextPage: true, + * hasPreviousPage: false, + * startCursor: '...', + * endCursor: '...' + * } + * } + * ``` + */ +export function createCollectionType( + nodeType: GraphQLOutputType, + { + name, + description, + skipPageInfo = false, + }: { + /** + * The name of the generated collection. + * + * If omitted, defaults to NameOfInnerNodeCollection. + */ + name?: string + /** + * A description of the collection that will be outputted in the generated + * schema as documentation. + */ + description?: string + /** + * Whether to skip the pageInfo field. + */ + skipPageInfo?: boolean + } = {} +): GraphQLObjectType { + const collectionName = name || `${extractNodeTypeName(nodeType)}Collection` + const edgeType = createEdgeType(nodeType, { skipCursor: skipPageInfo }) + + return new GraphQLObjectType({ + name: collectionName, + description: description || `A collection of ${extractNodeTypeName(nodeType)}s`, + fields: { + edges: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(edgeType))), + description: 'A list of edges containing nodes in this collection', + }, + nodes: { + type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(nodeType))), + description: 'The nodes in this collection, directly accessible', + }, + ...(!skipPageInfo && { + pageInfo: { + type: new GraphQLNonNull(PageInfoType), + description: 'Pagination information', + }, + }), + totalCount: { + type: new GraphQLNonNull(GraphQLInt), + description: 'The total count of items available in this collection', + }, + }, + }) +} + +/** + * Interface for standard pagination arguments for a GraphQL connection + */ +interface IPaginationArgs { + first?: number | null + after?: string | null + last?: number | null + before?: string | null +} + +/** + * Standard pagination arguments for a GraphQL connection + */ +export const paginationArgs = { + first: { + type: GraphQLInt, + description: 'Returns the first n elements from the list', + }, + after: { + type: GraphQLString, + description: 'Returns elements that come after the specified cursor', + }, + last: { + type: GraphQLInt, + description: 'Returns the last n elements from the list', + }, + before: { + type: GraphQLString, + description: 'Returns elements that come before the specified cursor', + }, +} + +/** + * Interface for a fetch definition used to fetch a collection of items for a + * GraphQL query. Takes standard pagination args and returns standard page + * information. + */ +interface CollectionFetch { + fetch: ( + args?: IPaginationArgs & { + additionalArgs?: FetchArgs + } + ) => Promise<{ + items: Array + totalCount: number + hasNextPage?: boolean + hasPreviousPage?: boolean + }> + args?: IPaginationArgs & { + additionalArgs?: FetchArgs + } + getCursor?: (item: ItemType, idx?: number) => string + items?: never +} + +/** + * Interface for parameters used to build a collection of items from an array + * in memory + */ +interface CollectionInMemory { + items: Array + args?: IPaginationArgs + fetch?: never + getCursor?: never +} + +/** + * Union type for parameters to build a collection. Can be a remote collection + * that needs to be fetched or a local one in memory. + */ +type CollectionBuildArgs = + | CollectionFetch + | CollectionInMemory + +export class GraphQLCollectionBuilder { + static async create( + options: CollectionBuildArgs + ) { + const { fetch, args = {}, getCursor, items } = options + + if (items) { + return GraphQLCollectionBuilder.paginateArray({ items, args }) + } + + const result = await fetch(args) + const { items: fetchedItems, totalCount, hasNextPage = false, hasPreviousPage = false } = result + const edges = fetchedItems.map((item) => { + return { node: item, cursor: getCursor(item) } + }) + + return { + edges, + nodes: fetchedItems, + totalCount, + pageInfo: { + hasNextPage, + hasPreviousPage, + startCursor: edges.length > 0 ? edges[0].cursor : null, + endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null, + }, + } + } + + private static paginateArray({ items, args }: CollectionInMemory) { + const getCursor = (_item: T, idx: number) => String(idx) + const allEdges = items.map((item, idx) => { + return { node: item, cursor: getCursor(item, idx) } + }) + + const { edges, hasPreviousPage, hasNextPage } = getRequestedSlice(allEdges, args) + + return { + edges, + nodes: edges.map((edge) => edge.node), + totalCount: allEdges.length, + pageInfo: { + hasNextPage, + hasPreviousPage, + startCursor: edges.length > 0 ? edges[0].cursor : null, + endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null, + }, + } + } +} + +interface TruncatedPageInfo { + edges: Array + hasNextPage: boolean + hasPreviousPage: boolean +} + +function getRequestedSlice( + allEdges: Array, + pageArgs: IPaginationArgs +): TruncatedPageInfo { + let hasPreviousPage = false + let hasNextPage = false + + let beforeIndex = allEdges.length + let afterIndex = -1 + + const requestedBefore = toNumber(pageArgs.before) + if (requestedBefore && requestedBefore >= 0 && requestedBefore < beforeIndex) { + beforeIndex = requestedBefore + hasNextPage = true + } + const requestedAfter = toNumber(pageArgs.after) + if (requestedAfter && requestedAfter >= 0) { + afterIndex = requestedAfter + hasPreviousPage = true + } + + let edges = allEdges.slice(afterIndex + 1, beforeIndex) + + if (pageArgs.first >= 0 && edges.length > pageArgs.first) { + edges = edges.slice(0, pageArgs.first) + hasNextPage = true + } else if (pageArgs.last >= 0 && edges.length > pageArgs.last) { + edges = edges.slice(edges.length - pageArgs.last) + hasPreviousPage = true + } + + return { + edges, + hasNextPage, + hasPreviousPage, + } +} + +function toNumber(value: string): number | undefined { + const num = Number(value) + return Number.isNaN(num) ? undefined : num +}