mirror of
https://github.com/supabase/supabase.git
synced 2026-06-03 11:23:41 +08:00
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:
17
apps/docs/features/helpers.misc.ts
Normal file
17
apps/docs/features/helpers.misc.ts
Normal 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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface SearchResultInterface {
|
||||
title?: string
|
||||
href?: string
|
||||
content?: string
|
||||
}
|
||||
22
apps/docs/resources/globalSearch/globalSearchSchema.ts
Normal file
22
apps/docs/resources/globalSearch/globalSearchSchema.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
37
apps/docs/resources/guide/guideModel.ts
Normal file
37
apps/docs/resources/guide/guideModel.ts
Normal 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
|
||||
}
|
||||
}
|
||||
56
apps/docs/resources/guide/guideSchema.ts
Normal file
56
apps/docs/resources/guide/guideSchema.ts
Normal 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 }),
|
||||
},
|
||||
},
|
||||
})
|
||||
357
apps/docs/resources/utils/connections.ts
Normal file
357
apps/docs/resources/utils/connections.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user