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
This commit is contained in:
Charis
2025-05-02 12:03:41 -04:00
committed by GitHub
parent 9016b84657
commit f611fa11d9
6 changed files with 494 additions and 0 deletions

View File

@@ -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
}

View File

@@ -0,0 +1,5 @@
export interface SearchResultInterface {
title?: string
href?: string
content?: string
}

View File

@@ -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',
},
},
})

View File

@@ -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<SubsectionModel>
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
}
}

View File

@@ -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 }),
},
},
})

View File

@@ -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<ItemType, FetchArgs = unknown> {
fetch: (
args?: IPaginationArgs & {
additionalArgs?: FetchArgs
}
) => Promise<{
items: Array<ItemType>
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<ItemType> {
items: Array<ItemType>
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<ItemType, FetchArgs = unknown> =
| CollectionFetch<ItemType, FetchArgs>
| CollectionInMemory<ItemType>
export class GraphQLCollectionBuilder {
static async create<ItemType, FetchArgs = unknown>(
options: CollectionBuildArgs<ItemType, FetchArgs>
) {
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<T>({ items, args }: CollectionInMemory<T>) {
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<ItemType> {
edges: Array<ItemType>
hasNextPage: boolean
hasPreviousPage: boolean
}
function getRequestedSlice<ItemType>(
allEdges: Array<ItemType>,
pageArgs: IPaginationArgs
): TruncatedPageInfo<ItemType> {
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
}