Files
supabase/apps/docs/features/directives/CodeSample.ts
Ivan Vasilov 70388e50bb fix: Use amaro for stripping types in code samples (#41229)
* Fix the error for babel/preset-typescript in docs.

* Unfix the version.

* Try using amaro for type stripping.

* Run prettier after stripping types.

* Fix tests.

---------

Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com>
2025-12-10 16:23:46 +01:00

531 lines
17 KiB
TypeScript

/**
* The $CodeSample directive supports inclusion of code samples from a source
* code file, which may be internal to this repo or external from another
* GitHub repo.
*
* The syntax for internal references is:
*
* ```mdx
* <$CodeSample
* path="/path/to/file.ts"
* lines={[1, 2], [5, 7]} // -1 may be used in end position as an alias for the last line, e.g., [1, -1]
* meta="utils/client.ts" // Optional, for displaying a file path on the code block
* hideElidedLines={true} // Optional, for hiding elided lines in the code block
* convertToJs={true} // Optional, strips TypeScript types to produce JavaScript
* />
* ```
*
* The syntax for external references is:
*
* ```mdx
* <$CodeSample
* external={true} // Note you must set the boolean, React pattern of omitting for true doesn't work
* org="supabase"
* repo="wrappers"
* commit="68d5s42hvs7p342kl65ldk90dsafdsa"
* path="/path/to/file.ts"
* lines={[1, 2], [5, 7]} // -1 may be used in end position as an alias for the last line, e.g., [1, -1]
* meta="utils/client.ts" // Optional, for displaying a file path on the code block
* hideElidedLines={true} // Optional, for hiding elided lines in the code block
* convertToJs={true} // Optional, strips TypeScript types to produce JavaScript
* />
*/
import * as acorn from 'acorn'
import tsPlugin from 'acorn-typescript'
import amaro from 'amaro'
import { type BlockContent, type Code, type DefinitionContent, type Root } from 'mdast'
import type { MdxJsxAttributeValueExpression, MdxJsxFlowElement } from 'mdast-util-mdx-jsx'
import assert from 'node:assert'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import prettier from 'prettier'
import { type Parent } from 'unist'
import { visitParents } from 'unist-util-visit-parents'
import { z, type SafeParseError } from 'zod'
import { fetchWithNextOptions } from '~/features/helpers.fetch'
import { IS_PLATFORM } from '~/lib/constants'
import { EXAMPLES_DIRECTORY } from '~/lib/docs'
import { getAttributeValue, getAttributeValueExpression } from './utils.server'
const ALLOW_LISTED_GITHUB_ORGS = ['supabase', 'supabase-community'] as [string, ...string[]]
const linesSchema = z.array(z.tuple([z.coerce.number(), z.coerce.number()]))
const linesValidator = z
.string()
.default('[[1, -1]]')
.transform((v, ctx) => {
try {
const array = JSON.parse(v)
return linesSchema.parse(array)
} catch (e) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Lines should be an array of [number, number] tuples',
})
return z.NEVER
}
})
type AdditionalMeta = {
parent: Parent
codeHikeAncestor: Parent | null
codeHikeAncestorParent: Parent | null
}
const booleanValidator = z.union([z.boolean(), z.string(), z.undefined()]).transform((v) => {
if (typeof v === 'boolean') return v
if (typeof v === 'string') return v === 'true'
return false
})
const codeSampleExternalSchema = z.object({
external: z.coerce.boolean().refine((v) => v === true),
org: z.enum(ALLOW_LISTED_GITHUB_ORGS, {
errorMap: () => ({ message: 'Org must be one of: ' + ALLOW_LISTED_GITHUB_ORGS.join(', ') }),
}),
repo: z.string(),
commit: z.string(),
path: z.string().transform((v) => (v.startsWith('/') ? v : `/${v}`)),
lines: linesValidator,
meta: z.string().optional(),
hideElidedLines: z.coerce.boolean().default(false),
convertToJs: booleanValidator,
})
type ICodeSampleExternal = z.infer<typeof codeSampleExternalSchema> & AdditionalMeta
const codeSampleInternalSchema = z.object({
external: z.coerce
.boolean()
.refine((v) => v === false)
.optional(),
path: z.string().transform((v) => (v.startsWith('/') ? v : `/${v}`)),
lines: linesValidator,
meta: z.string().optional(),
hideElidedLines: z.coerce.boolean().default(false),
convertToJs: booleanValidator,
})
type ICodeSampleInternal = z.infer<typeof codeSampleInternalSchema> & AdditionalMeta
type CodeSampleMeta = ICodeSampleExternal | ICodeSampleInternal
function isExternalSource(meta: CodeSampleMeta): meta is ICodeSampleExternal {
return !!meta.external
}
interface Dependencies {
fetchFromGitHub: (params: {
org: string
repo: string
path: string
branch: string
options: { onError: (error: unknown) => void; fetch: (url: string) => Promise<Response> }
}) => Promise<string>
}
export function codeSampleRemark(deps: Dependencies) {
return async function transform(tree: Root) {
const contentMap = await fetchSourceCodeContent(tree, deps)
await rewriteNodes(contentMap)
return tree
}
}
async function fetchSourceCodeContent(tree: Root, deps: Dependencies) {
const codeSampleNodes = [] as MdxJsxFlowElement[]
const metadata = [] as CodeSampleMeta[]
const pendingFetches = [] as Promise<string>[]
visitParents(tree, 'mdxJsxFlowElement', (node: MdxJsxFlowElement, ancestors) => {
if (node.name !== '$CodeSample') return
const codeHikeAncestorIndex = ancestors.findLastIndex(
(ancestor) => ancestor.type === 'mdxJsxFlowElement' && ancestor.name === 'CH.Code'
)
const codeHikeAncestor = codeHikeAncestorIndex === -1 ? null : ancestors[codeHikeAncestorIndex]
const codeHikeAncestorParent =
codeHikeAncestorIndex <= 0 ? null : ancestors[codeHikeAncestorIndex - 1]
const parent = ancestors[ancestors.length - 1]
const isExternal = getAttributeValueExpression(getAttributeValue(node, 'external')) === 'true'
if (isExternal) {
if (!IS_PLATFORM) {
node.name = 'CodeSampleDummy'
node.attributes = []
return
}
const org = getAttributeValue(node, 'org')
const repo = getAttributeValue(node, 'repo')
const commit = getAttributeValue(node, 'commit')
const path = getAttributeValue(node, 'path')
const lines = getAttributeValueExpression(getAttributeValue(node, 'lines'))
const meta = getAttributeValue(node, 'meta')
const hideElidedLines = getAttributeValueExpression(
getAttributeValue(node, 'hideElidedLines')
)
const convertToJs = getAttributeValueExpression(getAttributeValue(node, 'convertToJs'))
const result = codeSampleExternalSchema.safeParse({
external: isExternal,
org,
repo,
commit,
path,
lines,
meta,
hideElidedLines,
convertToJs,
})
if (!result.success) {
throw new Error(
`Invalid $CodeSample directive: ${(result as SafeParseError<ICodeSampleExternal>).error.message}`
)
}
const fetchTask = deps.fetchFromGitHub({
org: result.data.org,
repo: result.data.repo,
path: result.data.path,
branch: result.data.commit,
options: {
onError: (error: unknown) => {
throw Error(
`Failed to fetch code sample from ${org}/${repo}@${commit} at path ${path}: ${error}`
)
},
fetch: fetchWithNextOptions({ cache: 'force-cache' }),
},
})
codeSampleNodes.push(node)
metadata.push({ ...result.data, parent, codeHikeAncestor, codeHikeAncestorParent })
pendingFetches.push(fetchTask)
} else {
const path = getAttributeValue(node, 'path')
const lines = getAttributeValueExpression(getAttributeValue(node, 'lines'))
const meta = getAttributeValue(node, 'meta')
const hideElidedLines = getAttributeValueExpression(
getAttributeValue(node, 'hideElidedLines')
)
const convertToJs = getAttributeValueExpression(getAttributeValue(node, 'convertToJs'))
const result = codeSampleInternalSchema.safeParse({
external: isExternal,
path,
lines,
meta,
hideElidedLines,
convertToJs,
})
if (!result.success) {
throw new Error(
`Invalid $CodeSample directive: ${(result as SafeParseError<ICodeSampleInternal>).error.message}`
)
}
const filePath = join(EXAMPLES_DIRECTORY, result.data.path)
if (!filePath.startsWith(EXAMPLES_DIRECTORY)) {
throw new Error(`Invalid $CodeSample settings: Path must be inside ${EXAMPLES_DIRECTORY}`)
}
const fetchTask = readFile(filePath, 'utf-8')
codeSampleNodes.push(node)
metadata.push({ ...result.data, parent, codeHikeAncestor, codeHikeAncestorParent })
pendingFetches.push(fetchTask)
}
})
const resolvedContent = await Promise.all(pendingFetches)
const nodeContentMap = new Map<MdxJsxFlowElement, [CodeSampleMeta, string]>()
codeSampleNodes.forEach((node, index) => {
nodeContentMap.set(node, [metadata[index], resolvedContent[index]])
})
return nodeContentMap
}
async function rewriteNodes(contentMap: Map<MdxJsxFlowElement, [CodeSampleMeta, string]>) {
for (const [node, [meta, content]] of contentMap) {
let lang = matchLang(meta.path.split('.').pop() || '')
const source = isExternalSource(meta)
? `https://github.com/${meta.org}/${meta.repo}/blob/${meta.commit}${meta.path}`
: `https://github.com/supabase/supabase/blob/${process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA ?? 'master'}/examples${meta.path}`
let processedContent = content
if (meta.convertToJs) {
const { code } = amaro.transformSync(content, { mode: 'strip-only' })
const prettierConfig = await prettier.resolveConfig('./.prettierrc.js')
processedContent = await prettier.format(code, { ...prettierConfig, parser: 'typescript' })
// Convert TypeScript/TSX language to JavaScript/JSX when converting types
assert(
lang === 'typescript' || lang === 'tsx',
'Type stripping to JS is only supported for TypeScript and TSX'
)
if (lang === 'typescript') {
lang = 'javascript'
} else if (lang === 'tsx') {
lang = 'jsx'
}
}
const elidedContent = redactLines(processedContent, meta.lines, lang, meta.hideElidedLines)
const replacementContent: MdxJsxFlowElement | Code = meta.codeHikeAncestor
? {
type: 'code',
lang,
meta: meta.meta,
value: elidedContent,
}
: {
type: 'mdxJsxFlowElement',
name: 'CodeSampleWrapper',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'source',
value: source,
},
],
children: [
{
type: 'code',
lang,
meta: meta.meta,
value: elidedContent,
},
],
}
meta.parent.children.splice(meta.parent.children.indexOf(node), 1, replacementContent)
if (meta.codeHikeAncestor && meta.codeHikeAncestorParent) {
const existingWrapper = meta.codeHikeAncestorParent.children.find(
(child) =>
child.type === 'mdxJsxFlowElement' &&
(child as MdxJsxFlowElement).name === 'CodeSampleWrapper' &&
(child as MdxJsxFlowElement).children?.[0] === meta.codeHikeAncestor
) as MdxJsxFlowElement | undefined
if (existingWrapper) {
const existingSource = getAttributeValue(existingWrapper, 'source')
if (typeof existingSource === 'string' && existingSource !== source) {
const newSource = createArrayAttributeValueExpression(existingSource, source)
existingWrapper.attributes[0].value = newSource
} else if (
typeof existingSource !== 'string' &&
existingSource &&
existingSource.type === 'mdxJsxAttributeValueExpression'
) {
const existingSourceArray =
// @ts-ignore
existingSource.data.estree.body[0]?.expression?.elements?.map(
(element) => element.value
) ?? []
const newSource = createArrayAttributeValueExpression(...existingSourceArray, source)
existingWrapper.attributes[0].value = newSource
}
} else {
const codeSampleWrapper: MdxJsxFlowElement = {
type: 'mdxJsxFlowElement',
name: 'CodeSampleWrapper',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'source',
value: source,
},
],
children: [meta.codeHikeAncestor as BlockContent],
}
meta.codeHikeAncestorParent.children.splice(
meta.codeHikeAncestorParent.children.indexOf(meta.codeHikeAncestor),
1,
codeSampleWrapper
)
}
}
}
}
function matchLang(lang: string) {
switch (lang) {
case 'tsx':
return 'tsx'
case 'ts':
return 'typescript'
case 'jsx':
return 'jsx'
case 'js':
return 'javascript'
case 'json':
return 'json'
case 'py':
return 'python'
case 'sh':
return 'bash'
case 'kt':
return 'kotlin'
case 'dart':
return 'dart'
case 'swift':
return 'swift'
case 'sql':
return 'sql'
case 'svelte':
return 'svelte'
default:
return null
}
}
function redactLines(
content: string,
lines: [number, number, ...unknown[]][],
lang: string | null,
hideElidedLines: boolean = false
) {
const contentLines = content.split('\n')
const preservedLines = lines.reduce((acc, [start, end], index, arr) => {
if (!hideElidedLines && (index !== 0 || start !== 1)) {
acc.push(_createElidedLine(lang, contentLines, start, end))
}
// Start and end are 1-indexed and inclusive
acc.push(...contentLines.slice(start - 1, end === -1 ? contentLines.length : end))
if (!hideElidedLines && index === arr.length - 1 && end !== -1 && end !== contentLines.length) {
acc.push(_createElidedLine(lang, contentLines, start, end))
}
return acc
}, [] as string[])
return preservedLines.join('\n').trim()
}
export function _createElidedLine(
lang: string | null,
lines: string[],
start: number,
end: number
) {
const indentation = lines[start - 1].match(/^\s*/)?.[0] ?? ''
switch (lang) {
case 'sql':
return `\n${indentation}-- ...\n`
case 'jsx':
case 'tsx':
// @ts-ignore
const acornTree = acorn.Parser.extend(tsPlugin()).parse(lines.join('\n'), {
ecmaVersion: 'latest',
sourceType: 'module',
locations: true,
})
const isWithinJsx = isContainedInJsx(acornTree, start)
if (isWithinJsx) {
return `\n${indentation}{/* ... */}\n`
} else {
return `\n${indentation}// ...\n`
}
default:
return `\n${indentation}// ...\n`
}
}
function isContainedInJsx(tree: acorn.Node, line: number) {
const acornNodeContainsLine = (node: acorn.Node, line) =>
node.loc?.start?.line != null &&
node.loc?.end?.line != null &&
node.loc.start.line <= line &&
node.loc.end.line >= line
if (!acornNodeContainsLine(tree, line)) {
return false
}
let candidateNarrowestContainingNode = tree
function getNarrowestContainingNode(node: acorn.Node, line: number) {
for (const key of Object.keys(node)) {
const value = node[key]
if (!value || typeof value !== 'object') {
continue
}
if (!Array.isArray(value)) {
if (acornNodeContainsLine(value, line)) {
candidateNarrowestContainingNode = value
getNarrowestContainingNode(value, line)
}
} else {
for (const child of value) {
if (!acornNodeContainsLine(child, line)) {
continue
} else {
if (
(child.loc?.start?.line != null &&
candidateNarrowestContainingNode.loc?.start?.line != null &&
child.loc.start.line > candidateNarrowestContainingNode.loc.start.line) ||
(child.loc?.end?.line != null &&
candidateNarrowestContainingNode.loc?.end?.line != null &&
child.loc.end.line < candidateNarrowestContainingNode.loc.end.line) ||
(child.loc?.start?.column != null &&
candidateNarrowestContainingNode.loc?.start?.column != null &&
child.loc.start.column > candidateNarrowestContainingNode.loc.start.column) ||
(child.loc?.end?.column != null &&
candidateNarrowestContainingNode.loc?.end?.column != null &&
child.loc.end.column < candidateNarrowestContainingNode.loc.end.column)
) {
candidateNarrowestContainingNode = child
getNarrowestContainingNode(child, line)
}
}
}
}
}
}
getNarrowestContainingNode(tree, line)
return candidateNarrowestContainingNode.type.startsWith('JSX')
}
function createArrayAttributeValueExpression(...arrayElements: string[]) {
const expression: MdxJsxAttributeValueExpression = {
type: 'mdxJsxAttributeValueExpression',
value: '[' + arrayElements.map((element) => `'${element}'`).join(', ') + ']',
data: {
estree: {
type: 'Program',
sourceType: 'module',
body: [
{
type: 'ExpressionStatement',
expression: {
type: 'ArrayExpression',
elements: arrayElements.map((element) => ({
type: 'Literal',
value: element,
raw: element,
})),
},
},
],
},
},
}
return expression
}
export function isCodeSampleWrapper(node: BlockContent | DefinitionContent) {
return node.type === 'mdxJsxFlowElement' && node.name === 'CodeSampleWrapper'
}