diff --git a/.gitignore b/.gitignore index e5a8d2c5b..aa43edca1 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ cypress.log src/tests/cypress/screenshots src/tests/cypress/videos RapiDoc + +# Planning docs (versioned separately) +/plans diff --git a/next-sitemap.config.js b/next-sitemap.config.js index 9bb92eb23..d1ba47b02 100644 --- a/next-sitemap.config.js +++ b/next-sitemap.config.js @@ -1,7 +1,9 @@ /** @type {import('next').NextConfig} */ // eslint-disable-next-line @typescript-eslint/no-var-requires const fs = require('fs') -const siteUrl = process.env.NEXT_PUBLIC_DOMAIN_URL +const siteUrl = ( + process.env.NEXT_PUBLIC_SITE_URL || 'https://developers.vtex.com' +).replace(/\/+$/, '') module.exports = { transform: async (config, path) => { @@ -37,6 +39,6 @@ module.exports = { }, { userAgent: '*', allow: '/' }, ], - additionalSitemaps: [`${siteUrl}server-sitemap.xml`], + additionalSitemaps: [`${siteUrl}/server-sitemap.xml`], }, } diff --git a/next.config.js b/next.config.js index 8d5c76a0a..3989d6103 100644 --- a/next.config.js +++ b/next.config.js @@ -1,13 +1,68 @@ /* eslint-disable @typescript-eslint/no-var-requires */ /** @type {import('next').NextConfig} */ const { withPlaiceholder } = require('@plaiceholder/next') -const withLitSSR = require('@lit-labs/nextjs')() + +const withPatchedLitSSR = (baseConfig = {}) => ({ + ...baseConfig, + webpack: (config, options) => { + const { isServer, nextRuntime, webpack } = options + + config.module.rules.unshift({ + test: /\/pages\/.*\.(?:jsx?|tsx?)$/, + exclude: /next\/dist\//, + loader: 'imports-loader', + options: { + imports: ['side-effects @lit-labs/ssr-react/enable-lit-ssr.js'], + }, + }) + + if (isServer && nextRuntime === 'nodejs') { + const nextHandleExternals = config.externals[0] + config.externals = [ + (opt) => { + if ( + opt.request === 'react/jsx-dev-runtime' || + opt.request === 'react/jsx-runtime' + ) { + return Promise.resolve() + } + + return nextHandleExternals(opt) + }, + ] + } + + config.plugins.push( + new webpack.NormalModuleReplacementPlugin(/react/, (resource) => { + const normalizedContext = (resource.context || '').replace(/\\/g, '/') + const isLitSsrRuntime = /labs\/ssr-react/.test(normalizedContext) + + if (resource.request === 'react/jsx-runtime' && !isLitSsrRuntime) { + resource.request = '@lit-labs/ssr-react/jsx-runtime' + } + + if (resource.request === 'react/jsx-dev-runtime' && !isLitSsrRuntime) { + resource.request = '@lit-labs/ssr-react/jsx-dev-runtime' + } + }) + ) + + if (typeof baseConfig.webpack === 'function') { + return baseConfig.webpack(config, options) + } + + return config + }, +}) const nextConfig = { experimental: { largePageDataBytes: 500 * 1000, workerThreads: false, - cpus: 4, + // Next 13 writes build traces into `.next/trace`; Windows can hit EPERM + // when multiple build workers race on that file, so keep local Windows + // builds single-threaded while preserving the current Linux/CI behavior. + cpus: process.platform === 'win32' ? 1 : 4, }, reactStrictMode: true, swcMinify: true, @@ -55,7 +110,7 @@ const nextConfig = { } module.exports = () => { - const plugins = [withPlaiceholder, withLitSSR] + const plugins = [withPlaiceholder, withPatchedLitSSR] return plugins.reduce((acc, plugin) => plugin(acc), { ...nextConfig, }) diff --git a/src/pages/docs/api-reference/[slug].tsx b/src/pages/docs/api-reference/[slug].tsx index 316545200..7d5285b63 100644 --- a/src/pages/docs/api-reference/[slug].tsx +++ b/src/pages/docs/api-reference/[slug].tsx @@ -7,13 +7,31 @@ import SwaggerParser from '@apidevtools/swagger-parser' import ArticlePagination from 'components/article-pagination' import { Box } from '@vtex/brand-ui' import jp from 'jsonpath' +import { marked } from 'marked' import getReferencePaths from 'utils/getReferencePaths' import getNavigation from 'utils/getNavigation' import { MethodType, isMethodType } from 'utils/typings/unionTypes' -import '../../../../RapiDoc/src/rapidoc.js' import { flattenWithChildren } from 'utils/navigation-utils' import { getLogger } from 'utils/logging/log-util' +import getSiteUrl from 'utils/getSiteUrl' +import { stripHTML } from 'utils/string-utils' +import { + enhanceCalloutHtml, + replaceCalloutBlocks, +} from 'utils/replaceCalloutBlocks' +import { + buildOverviewEndpointGroups, + buildOverviewMetaDescription, + getOverviewEndpointHash, + type OverviewEndpoint, + type OverviewEndpointGroup, + type OverviewEndpointWithTags, + type OverviewTagDefinition, +} from 'utils/api-reference-overview' +import apiReferenceStyles, { + getOverviewEndpointMethodBadgeSx, +} from 'styles/api-reference' // Client-side logger const clientLogger = { @@ -42,8 +60,11 @@ interface Pagination { interface Props { slug: string - doc: string + descriptionHtml: string endpoints: { [key: string]: Endpoint } + overviewEndpoints: OverviewEndpoint[] + overviewEndpointGroups: OverviewEndpointGroup[] + overviewTitle: string pagination: { [key: string]: Pagination } endpointNames: { [key: string]: string } } @@ -136,9 +157,7 @@ function getAbsoluteUrl(path: string): string { if (process.env.NODE_ENV === 'development') { return `http://localhost:3000${path}` } - const baseUrl = - process.env.NEXT_PUBLIC_SITE_URL || 'https://developers.vtex.com' - return `${baseUrl}${path}` + return `${getSiteUrl()}${path}` } // In browser return path @@ -149,8 +168,11 @@ const slugs = Object.keys(await getReferencePaths()) const APIPage: NextPage = ({ slug, - doc, + descriptionHtml, endpoints, + overviewEndpoints, + overviewEndpointGroups, + overviewTitle, pagination, endpointNames, }) => { @@ -164,14 +186,32 @@ const APIPage: NextPage = ({ // State for client-side resolved spec const [resolvedSpec, setResolvedSpec] = useState(null) const [isLoadingSpec, setIsLoadingSpec] = useState(false) + const [isRapiDocReady, setIsRapiDocReady] = useState(false) const [errorLoadingSpec, setErrorLoadingSpec] = useState(null) const pageTitle = capitalize(slug.replaceAll('-', ' ').replace('api', '')) + ' API' - const hasHashTag = router.asPath.indexOf('#') > -1 - const cleanPath = hasHashTag - ? router.asPath.split('#')[1] - : router.asPath.split('?endpoint=')[1] || '' + + // Track the URL hash from `window.location` directly. `router.asPath` does + // not include the hash on SSR or on the initial client hydration of a + // directly-loaded URL, so relying on it alone would force the overview view + // for every deep link like `/docs/api-reference/foo#get--endpoint`. + const [clientHash, setClientHash] = useState('') + + useEffect(() => { + const syncHash = () => + setClientHash(decodeURIComponent(window.location.hash.slice(1))) + + syncHash() + window.addEventListener('hashchange', syncHash) + return () => window.removeEventListener('hashchange', syncHash) + }, []) + + const routerHash = + router.asPath.indexOf('#') > -1 + ? router.asPath.split('#')[1] + : router.asPath.split('?endpoint=')[1] || '' + const cleanPath = clientHash || routerHash const getMethod = () => { const method = cleanPath.split('/')[0].replace('-', '').toUpperCase() @@ -180,6 +220,14 @@ const APIPage: NextPage = ({ const httpMethod: MethodType | '' = getMethod() const endpointPath = cleanPath ? `#${cleanPath}` : slug + const isOverview = endpointPath === slug + const headTitle = isOverview ? overviewTitle : endpointNames[endpointPath] + const defaultFocusedEndpointId = overviewEndpoints[0] + ? getOverviewEndpointHash( + overviewEndpoints[0].method, + overviewEndpoints[0].path + ) + : undefined const pag: Pagination = { previousDoc: { name: null, @@ -195,6 +243,30 @@ const APIPage: NextPage = ({ // Generate the absolute spec URL const specUrl = getAbsoluteUrl(`/api/openapi/${slug}`) + useEffect(() => { + let isMounted = true + + const loadRapiDoc = async () => { + try { + await import('../../../../RapiDoc/src/rapidoc.js') + if (isMounted) { + setIsRapiDocReady(true) + } + } catch (error) { + if (isMounted) { + setErrorLoadingSpec('Failed to load the interactive API viewer') + } + clientLogger.error(`Failed to load RapiDoc: ${error}`) + } + } + + loadRapiDoc() + + return () => { + isMounted = false + } + }, []) + // Effect to handle client-side fetching and reference resolution useEffect(() => { // Only run in the browser, not during SSR @@ -237,6 +309,10 @@ const APIPage: NextPage = ({ } }, []) + // Mirror non-router hash changes (back/forward, raw clicks, + // RapiDoc's internal scroll-spy mutating window.location.hash) back into the + // Next router so `router.asPath` — and the derived `cleanPath` above — stay + // in sync with the URL the browser is actually showing. useEffect(() => { const handleHashChange = () => { router.push(window.location.href) @@ -255,31 +331,22 @@ const APIPage: NextPage = ({ ) }, [endpointPath]) - // Display an error message if the spec couldn't be loaded - if (errorLoadingSpec && !doc) { - return ( - -

Error Loading API Reference

-

{errorLoadingSpec}

-

- Please try refreshing the page. If the problem persists, contact - support. -

-
- ) - } - return ( <> - {endpointNames[endpointPath]} - {endpointPath === slug && } + {headTitle} + {endpoints && ( <> - + {endpoints[endpointPath]?.description && ( + + )} = ({ {httpMethod && } - {isLoadingSpec && !doc && ( - -

Loading API specification...

+ + + +

{overviewTitle}

+
+ {descriptionHtml && ( + + )} + {!!overviewEndpointGroups.length && ( + +

Endpoints

+ {overviewEndpointGroups.map(({ tagName, endpoints }) => ( + +

{tagName}

+ + + + + Summary + Method + Path + + + + {endpoints.map(({ method, path, summary }) => { + const endpointHash = getOverviewEndpointHash( + method, + path + ) + + return ( + + + + {summary || `Open ${method} ${path}`} + + + + + {method.toUpperCase()} + + + + + {path} + + + + ) + })} + + + +
+ ))} +
+ )}
- )} - +
+ + {errorLoadingSpec && ( + + Interactive API reference unavailable. +

{errorLoadingSpec}

+
+ )} + {(isLoadingSpec || !isRapiDocReady) && ( + +

Loading API specification...

+
+ )} + {isRapiDocReady && ( + + )} +
{ if (slugs.includes(slug as string)) { // Use the production URL for fetching specs during build time - const baseUrl = - process.env.NEXT_PUBLIC_SITE_URL || 'https://developers.vtex.com' - const url = `${baseUrl}/api/openapi/${slug}` + const url = `${getSiteUrl()}/api/openapi/${slug}` let apiSpec: string try { @@ -412,14 +580,43 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { // Use Oas to process the spec string const endpointFile = new Oas(apiSpec) - const { info, paths } = endpointFile.getDefinition() + const specDefinition = endpointFile.getDefinition() + const { info, paths } = specDefinition + const overviewTitle = info?.title || (slug as string) + const normalizedDescription = replaceCalloutBlocks(info?.description || '') + const descriptionHtml = enhanceCalloutHtml( + await marked.parse(normalizedDescription) + ) + const overviewEndpoints: OverviewEndpoint[] = [] + const overviewEndpointsWithTags: OverviewEndpointWithTags[] = [] + const overviewTagDefinitions = Array.isArray(specDefinition.tags) + ? specDefinition.tags.reduce( + ( + tagDefinitions: OverviewTagDefinition[], + tagDefinition: { name?: unknown } + ) => { + if ( + tagDefinition && + typeof tagDefinition.name === 'string' && + tagDefinition.name.trim() + ) { + tagDefinitions.push({ + name: tagDefinition.name.trim(), + }) + } + + return tagDefinitions + }, + [] as OverviewTagDefinition[] + ) + : [] const endpoints: { [key: string]: Endpoint } = {} endpoints[slug as string] = { - title: info?.title || (slug as string), - description: getDescription(info?.description || ''), + title: overviewTitle, + description: '', } if (paths) { @@ -428,14 +625,35 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { Object.entries(value).forEach( // eslint-disable-next-line @typescript-eslint/no-explicit-any ([endpointKey, endpointValue]: any) => { - if ( - isMethodType(endpointKey.toUpperCase()) && - endpointValue && - endpointValue.description - ) { - endpoints[`#${endpointKey}-${key.replaceAll(/{|}/g, '-')}`] = { + if (isMethodType(endpointKey.toUpperCase()) && endpointValue) { + const operationTags = Array.isArray(endpointValue.tags) + ? endpointValue.tags.reduce( + (tags: string[], tag: unknown) => { + if (typeof tag === 'string' && tag.trim()) { + tags.push(tag.trim()) + } + + return tags + }, + [] as string[] + ) + : [] + const overviewEndpoint = { + method: endpointKey.toUpperCase(), + path: key, + summary: endpointValue.summary || '', + } + + overviewEndpoints.push(overviewEndpoint) + overviewEndpointsWithTags.push({ + ...overviewEndpoint, + tags: operationTags, + }) + endpoints[`#${getOverviewEndpointHash(endpointKey, key)}`] = { title: endpointValue.summary || '', - description: getDescription(endpointValue.description), + description: getDescription( + endpointValue.description || endpointValue.summary || '' + ), } } } @@ -443,6 +661,17 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { } }) } + + const overviewEndpointGroups = buildOverviewEndpointGroups( + overviewTagDefinitions, + overviewEndpointsWithTags + ) + + endpoints[slug as string].description = buildOverviewMetaDescription( + overviewTitle, + stripHTML(descriptionHtml), + overviewEndpoints + ) const docsListEndpoint = jp.query( sidebarfallback, `$..[?(@.type=='openapi')]` @@ -454,9 +683,10 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { docsList.map((doc) => { const path = doc.method - ? `/docs/api-reference/${doc.slug}#${doc.method.toLowerCase()}-${ - doc.endpoint - }` + ? `/docs/api-reference/${doc.slug}#${getOverviewEndpointHash( + doc.method, + doc.endpoint ?? '' + )}` : `/docs/api-reference/${doc.slug}` doc['route'] = path }) @@ -480,6 +710,8 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { endpointNames[`${endpoint}`] = currentEndpointObject?.name ? currentEndpointObject.name + : endpoint === slug + ? overviewTitle : '' pagination[`${endpoint}`] = { previousDoc: { @@ -517,12 +749,15 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { return { props: { slug, - doc: apiSpec, - sectionSelected, - sidebarfallback, + descriptionHtml, endpoints, + overviewEndpoints, + overviewEndpointGroups, + overviewTitle, pagination, endpointNames, + sectionSelected, + sidebarfallback, }, } } else { diff --git a/src/pages/server-sitemap.xml/index.ts b/src/pages/server-sitemap.xml/index.ts index e66f3d8a4..bfe584389 100644 --- a/src/pages/server-sitemap.xml/index.ts +++ b/src/pages/server-sitemap.xml/index.ts @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { getServerSideSitemap } from 'next-sitemap' import getNavigation from 'utils/getNavigation' +import getSiteUrl from 'utils/getSiteUrl' -const DOMAIN_URL = 'https://developers.vtex.com/docs/api-reference' +const API_REFERENCE_PATH = '/docs/api-reference' +const apiReferenceUrl = `${getSiteUrl()}${API_REFERENCE_PATH}` function getEndpoint(element: any) { let urls: any = [] @@ -13,14 +15,9 @@ function getEndpoint(element: any) { urls = children } - if (element.type === 'openapi') { + if (element.type === 'openapi' && !element.method) { const url: any = {} - const pathSuffix = element.method - ? `?endpoint=${element.method.toLowerCase()}-${element.endpoint - .replaceAll('{', '-') - .replaceAll('}', '-')}` - : '' - url.loc = `${DOMAIN_URL}/${element.slug}${pathSuffix}` + url.loc = `${apiReferenceUrl}/${element.slug}` url.lastmod = new Date().toISOString() urls.push(url) } diff --git a/src/styles/api-reference.ts b/src/styles/api-reference.ts new file mode 100644 index 000000000..71a93eadf --- /dev/null +++ b/src/styles/api-reference.ts @@ -0,0 +1,288 @@ +import type { SxStyleProp } from '@vtex/brand-ui' +import { isMethodType } from 'utils/typings/unionTypes' +import { methodsColors } from 'components/method-category/functions' + +const overviewArticleStyles: SxStyleProp = { + maxWidth: '960px', + mx: 'auto', + mb: '2.5rem', + color: '#4A596B', + fontSize: '0.95em', + lineHeight: '1.5em', +} + +const overviewHeaderStyles: SxStyleProp = { + mt: 0, + mb: '1.5rem', + '*': { + margin: '0px', + }, + '& h1': { + fontSize: ['20px', '28px'], + lineHeight: ['30px', '38px'], + fontWeight: '400', + color: '#142032', + }, +} + +const overviewContentStyles: SxStyleProp = { + color: '#4A596B', + '& p': { + lineHeight: '1.5em', + mb: '1rem', + }, + '& ul, & ol': { + mb: '1rem', + pl: '1.5rem', + }, + '& ul li, & ol li': { + mt: '0.5em', + mb: '0.5em', + }, + '& h2': { + fontSize: '1.375em', + lineHeight: '2em', + fontWeight: '400', + mt: '1.3em', + mb: '0.875em', + color: '#142032', + }, + '& h3': { + fontSize: ['1.125rem', '1.25rem'], + lineHeight: '1.75rem', + fontWeight: '600', + mt: '1.5rem', + mb: '0.75rem', + color: '#142032', + }, + '& h4': { + fontSize: '1rem', + lineHeight: '1.5rem', + fontWeight: '600', + mt: '1.25rem', + mb: '0.75rem', + color: '#142032', + }, + '& a': { + color: '#E31C58', + textDecoration: 'underline', + textUnderlineOffset: '0.18em', + }, + '& strong': { + fontWeight: '600', + }, + '& blockquote': { + borderLeft: '4px solid #E7E9EE', + color: '#4A596B', + ml: 0, + my: '1.5rem', + pl: '1rem', + }, + '& .overview-callout': { + display: 'grid', + columnGap: '20px', + rowGap: '0.75rem', + width: '100%', + pl: 0, + ml: 0, + mt: '1rem', + mb: '1.5rem', + p: '20px', + borderRadius: '4px', + alignItems: 'start', + gridTemplateColumns: '20px 1fr', + wordBreak: 'break-word', + border: '1px solid #CCCED8', + '&::before': { + display: 'inline-block', + height: '20px', + width: '20px', + content: '""', + backgroundRepeat: 'no-repeat', + backgroundPosition: '0 0', + backgroundSize: '20px 20px', + gridColumn: '1', + gridRow: '1', + mt: '2px', + }, + }, + '& .overview-callout p, & .overview-callout div': { + m: 0, + gridColumn: '2 / -1', + }, + '& .overview-callout a': { + wordBreak: 'break-word', + overflowWrap: 'break-word', + }, + '& .overview-callout--info': { + bg: '#F8F7FC', + borderColor: '#CCCED8', + '&::before': { + backgroundImage: + 'url(https://vtex-dev-portal-navigation.fra1.digitaloceanspaces.com/info.svg)', + }, + '& code': { + bg: '#ECEBF3', + }, + }, + '& .overview-callout--warning': { + bg: '#FFF2D4', + borderColor: '#FFB100', + '&::before': { + backgroundImage: + 'url(https://vtex-dev-portal-navigation.fra1.digitaloceanspaces.com/warning.svg)', + }, + '& code': { + bg: '#FFE5B5', + }, + }, + '& .overview-callout--danger': { + bg: '#FDEFEF', + borderColor: '#DC5A41', + '&::before': { + backgroundImage: + 'url(https://vtex-dev-portal-navigation.fra1.digitaloceanspaces.com/danger.svg)', + }, + }, + '& .overview-callout--success': { + bg: '#F3F8F3', + borderColor: '#80BE80', + '&::before': { + backgroundImage: + 'url(https://vtex-dev-portal-navigation.fra1.digitaloceanspaces.com/success.svg)', + }, + }, + '& code': { + fontFamily: 'mono', + fontSize: '0.875rem', + bg: '#F7F8FA', + borderRadius: '4px', + px: '0.25rem', + py: '0.125rem', + }, + '& pre': { + bg: '#F7F8FA', + border: '1px solid #E7E9EE', + borderRadius: '4px', + overflowX: 'auto', + p: '1rem', + mb: '1.5rem', + }, + '& pre code': { + bg: 'transparent', + px: 0, + py: 0, + }, + '& table': { + width: '100%', + borderCollapse: 'collapse', + mb: '1.5rem', + }, + '& th, & td': { + borderBottom: '1px solid #E7E9EE', + px: '0.75rem', + py: '0.625rem', + textAlign: 'left', + verticalAlign: 'top', + }, +} + +const overviewTableWrapperStyles: SxStyleProp = { + overflowX: 'auto', + border: '1px solid #E7E9EE', + borderRadius: '4px', + bg: '#FFFFFF', +} + +const overviewTableStyles: SxStyleProp = { + width: '100%', + minWidth: '640px', + borderCollapse: 'collapse', + '& th': { + textAlign: 'left', + padding: '0.875rem 1rem', + borderBottom: '1px solid #E7E9EE', + bg: '#F7F8FA', + color: '#4A596B', + fontSize: '0.75rem', + fontWeight: '600', + letterSpacing: '0.04em', + textTransform: 'uppercase', + }, + '& td': { + padding: '0.875rem 1rem', + borderBottom: '1px solid #E7E9EE', + verticalAlign: 'top', + color: '#4A596B', + }, + '& td:first-of-type': { + wordBreak: 'break-word', + whiteSpace: 'normal', + }, + '& td:nth-of-type(2)': { + whiteSpace: 'nowrap', + }, + '& td:nth-of-type(3)': { + wordBreak: 'break-word', + }, + '& tbody tr:last-of-type td': { + borderBottom: 'none', + }, +} + +const endpointPathStyles: SxStyleProp = { + fontFamily: 'mono', + fontSize: '0.875rem', + bg: '#F7F8FA', + borderRadius: '4px', + px: '0.25rem', + py: '0.125rem', + wordBreak: 'break-word', +} + +const endpointLinkStyles: SxStyleProp = { + color: '#E31C58', + textDecoration: 'underline', + textUnderlineOffset: '0.18em', + fontWeight: '500', +} + +// Style factory for the per-endpoint method badge. Returns a method-specific +// palette when available, or a sensible red fallback for unknown HTTP verbs. +export function getOverviewEndpointMethodBadgeSx(method: string): SxStyleProp { + const upper = method.toUpperCase() + const palette = + isMethodType(upper) && methodsColors[upper] + ? methodsColors[upper] + : { + border: '1px solid #F49494', + color: '#CC3D3D', + background: '#F8E3E3', + } + + return { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '2px', + fontSize: '12px', + fontWeight: '600', + minHeight: '24px', + px: '6px', + textTransform: 'uppercase', + whiteSpace: 'nowrap', + ...palette, + } +} + +const apiReferenceStyles = { + overviewArticleStyles, + overviewHeaderStyles, + overviewContentStyles, + overviewTableWrapperStyles, + overviewTableStyles, + endpointPathStyles, + endpointLinkStyles, +} + +export default apiReferenceStyles diff --git a/src/utils/api-reference-overview.ts b/src/utils/api-reference-overview.ts new file mode 100644 index 000000000..7895f20c7 --- /dev/null +++ b/src/utils/api-reference-overview.ts @@ -0,0 +1,164 @@ +import { + getFirstSentence, + normalizeWhitespace, + removeTitlePrefix, + trimToLength, +} from 'utils/string-utils' + +export interface OverviewEndpoint { + method: string + path: string + summary: string +} + +export interface OverviewEndpointGroup { + tagName: string + endpoints: OverviewEndpoint[] +} + +export interface OverviewEndpointWithTags extends OverviewEndpoint { + tags: string[] +} + +export interface OverviewTagDefinition { + name: string +} + +export const GENERAL_OVERVIEW_TAG_NAME = 'General' + +const META_DESCRIPTION_MAX_LENGTH = 160 +const MAX_SUMMARIES_IN_META_DESCRIPTION = 5 + +export function getOverviewEndpointHash(method: string, path: string) { + return `${method.toLowerCase()}-${path.replaceAll(/{|}/g, '-')}` +} + +// Builds an SEO meta description that fits inside Google's ~160-char budget. +// Tries to include as many endpoint summaries as possible, dropping them one +// by one until the resulting string fits. +export function buildOverviewMetaDescription( + apiTitle: string, + descriptionText: string, + overviewEndpoints: OverviewEndpoint[] +) { + const descriptionSentence = removeTitlePrefix( + getFirstSentence(descriptionText), + apiTitle + ) + + const uniqueSummaries = overviewEndpoints.reduce( + (summaries, { summary }) => { + const cleanedSummary = normalizeWhitespace(summary).replace(/[.!?]+$/, '') + + if (!cleanedSummary) { + return summaries + } + + const isDuplicate = summaries.some( + (existingSummary) => + existingSummary.toLowerCase() === cleanedSummary.toLowerCase() + ) + + if (isDuplicate) { + return summaries + } + + summaries.push(cleanedSummary) + return summaries + }, + [] + ) + + const maxSummaryCount = Math.min( + uniqueSummaries.length, + MAX_SUMMARIES_IN_META_DESCRIPTION + ) + + for (let summaryCount = maxSummaryCount; summaryCount >= 0; summaryCount--) { + const summaryText = uniqueSummaries.slice(0, summaryCount).join(', ') + const suffix = summaryText ? `${summaryText}.` : '' + const availableDescriptionLength = + META_DESCRIPTION_MAX_LENGTH - + apiTitle.length - + 3 - + (suffix ? suffix.length + 1 : 0) + const trimmedDescription = trimToLength( + descriptionSentence, + availableDescriptionLength + ) + + const candidate = normalizeWhitespace( + `${apiTitle} - ${trimmedDescription}${ + trimmedDescription && suffix ? ' ' : '' + }${suffix}` + ) + + if (candidate.length <= META_DESCRIPTION_MAX_LENGTH) { + return candidate + } + } + + if (uniqueSummaries.length) { + return trimToLength( + `${apiTitle} - ${uniqueSummaries.join(', ')}.`, + META_DESCRIPTION_MAX_LENGTH + ) + } + + return trimToLength( + `${apiTitle} - ${descriptionSentence}`, + META_DESCRIPTION_MAX_LENGTH + ) +} + +// Groups endpoints by their first OpenAPI tag, preserving the tag order from +// the spec definition and pushing untagged endpoints into a "General" bucket +// at the end. +export function buildOverviewEndpointGroups( + tagDefinitions: OverviewTagDefinition[], + overviewEndpoints: OverviewEndpointWithTags[] +) { + const groupedEndpoints = new Map() + const definedTagNames = new Set(tagDefinitions.map(({ name }) => name)) + + overviewEndpoints.forEach(({ tags, ...endpoint }) => { + const tagName = tags[0] || GENERAL_OVERVIEW_TAG_NAME + const existingGroup = groupedEndpoints.get(tagName) + + if (existingGroup) { + existingGroup.endpoints.push(endpoint) + return + } + + groupedEndpoints.set(tagName, { + tagName, + endpoints: [endpoint], + }) + }) + + const orderedTagNames = [ + ...tagDefinitions + .map(({ name }) => name) + .filter((name) => groupedEndpoints.has(name)), + ...Array.from(groupedEndpoints.keys()).filter( + (name) => !definedTagNames.has(name) && name !== GENERAL_OVERVIEW_TAG_NAME + ), + ] + + if ( + groupedEndpoints.has(GENERAL_OVERVIEW_TAG_NAME) && + !orderedTagNames.includes(GENERAL_OVERVIEW_TAG_NAME) + ) { + orderedTagNames.push(GENERAL_OVERVIEW_TAG_NAME) + } + + return orderedTagNames.reduce((groups, tagName) => { + const group = groupedEndpoints.get(tagName) + + if (group) { + groups.push(group) + } + + return groups + }, []) +} diff --git a/src/utils/getSiteUrl.ts b/src/utils/getSiteUrl.ts new file mode 100644 index 000000000..2e2a5f8df --- /dev/null +++ b/src/utils/getSiteUrl.ts @@ -0,0 +1,7 @@ +const DEFAULT_SITE_URL = 'https://developers.vtex.com' + +export default function getSiteUrl() { + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || DEFAULT_SITE_URL + + return siteUrl.replace(/\/+$/, '') +} diff --git a/src/utils/replaceCalloutBlocks.ts b/src/utils/replaceCalloutBlocks.ts new file mode 100644 index 000000000..77d2436ac --- /dev/null +++ b/src/utils/replaceCalloutBlocks.ts @@ -0,0 +1,127 @@ +/** + * Parses Readme-style `[block:callout]` Magic Blocks into Markdown blockquotes + * and decorates the resulting `
` HTML with callout-type classes. + * + * Two-stage flow: + * 1. `replaceCalloutBlocks` runs on the raw Markdown source before + * `marked.parse`, converting each `[block:callout]` into a `> …` + * blockquote prefixed with a type-specific emoji. + * 2. `enhanceCalloutHtml` runs on the parsed HTML, adding + * `overview-callout overview-callout--` classes so styles can target + * each callout variant. + * + * `src/utils/replaceMagicBlocks.ts` already handles the `callout` block type, + * but it produces inline JSX intended for guide pages. The API reference + * overview needs a Markdown-only path, so the parsing logic lives here. + */ + +export type CalloutType = 'info' | 'warning' | 'danger' | 'success' + +export const calloutIconByType: Record = { + info: '\u2139\uFE0F', + warning: '\u26A0\uFE0F', + danger: '\u2757', + success: '\u2705', +} + +export const calloutPatternByType: Record = { + info: /^(?:

)?\s*(?:\u2139\uFE0F|\u2139)\s*/u, + warning: /^(?:

)?\s*(?:\u26A0\uFE0F?|\u26A0)\s*/u, + danger: /^(?:

)?\s*(?:\u2757\uFE0F?|\u2757)\s*/u, + success: /^(?:

)?\s*(?:\u2705)\s*/u, +} + +export function isCalloutType(value: unknown): value is CalloutType { + return ( + value === 'info' || + value === 'warning' || + value === 'danger' || + value === 'success' + ) +} + +export function getCalloutType(value: string): CalloutType | null { + const matchingType = ( + Object.entries(calloutPatternByType) as [CalloutType, RegExp][] + ).find(([, pattern]) => pattern.test(value)) + + return matchingType ? matchingType[0] : null +} + +export function replaceCalloutBlocks(markdown: string) { + return markdown.replace( + /\[block:callout\]\s*([\s\S]*?)\s*\[\/block\]/g, + (match, blockContent: string) => { + try { + const parsedBlock = JSON.parse(blockContent) as { + type?: unknown + title?: unknown + body?: unknown + } + const calloutType = isCalloutType(parsedBlock.type) + ? parsedBlock.type + : 'info' + const title = + typeof parsedBlock.title === 'string' ? parsedBlock.title.trim() : '' + const body = + typeof parsedBlock.body === 'string' ? parsedBlock.body.trim() : '' + const calloutLines: string[] = [] + + if (title) { + calloutLines.push(`> ${calloutIconByType[calloutType]} **${title}**`) + } + + if (body) { + const bodyLines = body.split(/\r?\n/) + + if (title) { + calloutLines.push('>') + } else { + const firstNonEmptyLineIndex = bodyLines.findIndex((line) => + line.trim() + ) + + if (firstNonEmptyLineIndex > -1) { + bodyLines[firstNonEmptyLineIndex] = `${ + calloutIconByType[calloutType] + } ${bodyLines[firstNonEmptyLineIndex].trimStart()}` + } + } + + bodyLines.forEach((line) => { + calloutLines.push(line ? `> ${line}` : '>') + }) + } + + if (!calloutLines.length) { + return match + } + + return `${calloutLines.join('\n')}\n` + } catch { + return match + } + } + ) +} + +export function enhanceCalloutHtml(content: string) { + return content.replace( + /

\s*([\s\S]*?)<\/blockquote>/g, + (blockquote, innerContent: string) => { + const trimmedInnerContent = innerContent.trim() + const calloutType = getCalloutType(trimmedInnerContent) + + if (!calloutType) { + return blockquote + } + + const normalizedInnerContent = trimmedInnerContent.replace( + calloutPatternByType[calloutType], + '

' + ) + + return `

${normalizedInnerContent}
` + } + ) +} diff --git a/src/utils/string-utils.ts b/src/utils/string-utils.ts index f4a517eec..a2499553d 100644 --- a/src/utils/string-utils.ts +++ b/src/utils/string-utils.ts @@ -1,5 +1,47 @@ export const removeHTML = (str: string) => str.replace(/<\/?[^>]+>/g, '') +export const normalizeWhitespace = (str: string) => + str.replace(/\s+/g, ' ').trim() + +// Like removeHTML, but replaces tags with a single space and collapses +// surrounding whitespace, so adjacent inline elements don't get glued together. +export const stripHTML = (str: string) => + normalizeWhitespace(str.replace(/<[^>]+>/g, ' ')) + +export const getFirstSentence = (str: string) => { + const normalized = normalizeWhitespace(str) + if (!normalized) return '' + + const match = normalized.match(/^.*?[.!?](?=\s|$)/) + return match ? match[0].trim() : normalized +} + +// Truncates `str` to at most `maxLength` characters, breaking on the last +// whole word when possible and appending an ellipsis when truncation occurs. +export const trimToLength = (str: string, maxLength: number) => { + if (maxLength <= 0 || !str) return '' + if (str.length <= maxLength) return str + + const truncated = str.slice(0, maxLength - 1) + const lastSpaceIndex = truncated.lastIndexOf(' ') + + if (lastSpaceIndex > 0) { + return `${truncated.slice(0, lastSpaceIndex)}…` + } + + return `${truncated}…` +} + +// Strips a leading "Title", "Title:", "Title -", or "Title." prefix from `str`, +// case-insensitively, so a value like "Foo API: does X" becomes "does X". +export const removeTitlePrefix = (str: string, title: string) => { + const escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + + return str + .replace(new RegExp(`^${escapedTitle}\\s*[:\\-.]?\\s*`, 'i'), '') + .trim() +} + export const capitalizeFirstLetter = (str: string) => { return str.charAt(0).toUpperCase() + str.slice(1) } diff --git a/typings/index.d.ts b/typings/index.d.ts index 66716f6b1..531e79f81 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -1,2 +1,3 @@ declare module '@octokit/auth-app' declare module '@octokit/plugin-throttling' +declare module 'marked'