Skip to content

Commit ef93da7

Browse files
feat(docs): code samples with ts type stripping (supabase#37695)
* feat(docs): code samples with ts type stripping Introduce a new option to `$CodeSample`, `convertToJs`, which takes a code sample written in TypeScript and strips the types to produce a JavaScript version. Adds tests for type stripping. * Clarify instructions --------- Co-authored-by: Chris Chinchilla <[email protected]>
1 parent f5f0876 commit ef93da7

File tree

6 files changed

+344
-25
lines changed

6 files changed

+344
-25
lines changed

apps/docs/app/contributing/content.mdx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ Line numbers are 1-indexed and inclusive.
181181
lines={[[1, 3], [5, -1]]}
182182
{/* Optional, displays as a file name on the code block */}
183183
meta="name=display/path.js"
184+
{/* Optional, strips TypeScript types to produce JavaScript, which you also include with another <CodeSample /> */}
185+
convertToJs={true}
184186
/>
185187
```
186188

@@ -195,11 +197,31 @@ commit="1623aa9b95ec90e21c5bae5a0d50dcf272abe92f"
195197
path="/relative/path/from/root.js"
196198
lines={[[1, 3], [5, -1]]}
197199
meta="name=display/path.js"
200+
convertToJs={true}
198201
/>
199202
```
200203

201204
The repo must be public, the org must be on the allow list, and the commit must be an immutable SHA (not a mutable tag or branch name).
202205

206+
#### Converting TypeScript to JavaScript
207+
208+
You can automatically strip TypeScript types from code samples to produce JavaScript using the `convertToJs` option:
209+
210+
```mdx
211+
<$CodeSample
212+
path="/path/to/typescript-file.ts"
213+
lines={[[1, -1]]}
214+
convertToJs={true}
215+
/>
216+
```
217+
218+
This is useful when you want to show JavaScript examples from TypeScript source files. The conversion:
219+
220+
- Removes all TypeScript type annotations, interfaces, and type definitions
221+
- Converts the language identifier from `typescript` to `javascript` (or `tsx` to `jsx`)
222+
- Happens before any line selection or elision processing
223+
- Defaults to `false` to preserve TypeScript code when not specified
224+
203225
#### Multi-file code samples
204226

205227
Multi-file code samples use the `<$CodeTabs>` annotation:

apps/docs/features/directives/CodeSample.test.ts

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterAll, beforeAll, describe, it, expect, vi } from 'vitest'
22

3+
import { stripIndent } from 'common-tags'
34
import { fromMarkdown } from 'mdast-util-from-markdown'
45
import { mdxFromMarkdown, mdxToMarkdown } from 'mdast-util-mdx'
56
import { toMarkdown } from 'mdast-util-to-markdown'
@@ -18,6 +19,42 @@ vi.mock('~/lib/constants', () => ({
1819
IS_PLATFORM: true,
1920
}))
2021

22+
/**
23+
* Checks if str1 contains str2, ignoring leading whitespace on each line.
24+
* Lines are matched if they have the same content after trimming leading whitespace.
25+
*
26+
* @param str1 - The string to search in
27+
* @param str2 - The string to search for
28+
* @returns true if str1 contains str2 modulo leading whitespace, false otherwise
29+
*/
30+
export function containsStringIgnoringLeadingWhitespace(str1: string, str2: string): boolean {
31+
const lines1 = str1.split('\n').map((line) => line.trimStart())
32+
const lines2 = str2.split('\n').map((line) => line.trimStart())
33+
34+
if (lines2.length === 0) {
35+
return true
36+
}
37+
38+
if (lines2.length > lines1.length) {
39+
return false
40+
}
41+
42+
for (let i = 0; i <= lines1.length - lines2.length; i++) {
43+
let matches = true
44+
for (let j = 0; j < lines2.length; j++) {
45+
if (lines1[i + j] !== lines2[j]) {
46+
matches = false
47+
break
48+
}
49+
}
50+
if (matches) {
51+
return true
52+
}
53+
}
54+
55+
return false
56+
}
57+
2158
describe('$CodeSample', () => {
2259
beforeAll(() => {
2360
env = process.env
@@ -533,6 +570,169 @@ Some more text.
533570

534571
expect(output).toEqual(expected)
535572
})
573+
574+
describe('convertToJs option', () => {
575+
it('should convert TypeScript to JavaScript when convertToJs is true', async () => {
576+
const markdown = `
577+
# Embed code sample
578+
579+
<$CodeSample path="/_internal/fixtures/typescript.ts" lines={[[1, -1]]} convertToJs={true} />
580+
581+
Some more text.
582+
`.trim()
583+
584+
const mdast = fromMarkdown(markdown, {
585+
mdastExtensions: [mdxFromMarkdown()],
586+
extensions: [mdxjs()],
587+
})
588+
const transformed = await transformWithMock(mdast)
589+
const output = toMarkdown(transformed, { extensions: [mdxToMarkdown()] })
590+
591+
const expected = stripIndent`
592+
\`\`\`javascript
593+
const users = [
594+
{ id: 1, name: 'John', email: '[email protected]' },
595+
{ id: 2, name: 'Jane' },
596+
];
597+
598+
function getUserById(id) {
599+
return users.find((user) => user.id === id);
600+
}
601+
602+
function createUser(name, email) {
603+
const newId = Math.max(...users.map((u) => u.id)) + 1;
604+
const newUser = { id: newId, name };
605+
if (email) {
606+
newUser.email = email;
607+
}
608+
users.push(newUser);
609+
return newUser;
610+
}
611+
612+
class UserManager {
613+
users = [];
614+
615+
constructor(initialUsers = []) {
616+
this.users = initialUsers;
617+
}
618+
619+
addUser(user) {
620+
this.users.push(user);
621+
}
622+
623+
getUsers() {
624+
return [...this.users];
625+
}
626+
}
627+
\`\`\`
628+
`.trim()
629+
630+
expect(containsStringIgnoringLeadingWhitespace(output, expected)).toBe(true)
631+
})
632+
633+
it('should preserve TypeScript when convertToJs is false', async () => {
634+
const markdown = `
635+
# Embed code sample
636+
637+
<$CodeSample path="/_internal/fixtures/typescript.ts" lines={[[1, -1]]} convertToJs={false} />
638+
639+
Some more text.
640+
`.trim()
641+
642+
const mdast = fromMarkdown(markdown, {
643+
mdastExtensions: [mdxFromMarkdown()],
644+
extensions: [mdxjs()],
645+
})
646+
const transformed = await transformWithMock(mdast)
647+
const output = toMarkdown(transformed, { extensions: [mdxToMarkdown()] })
648+
649+
// The output should contain TypeScript types
650+
expect(output).toContain('```typescript')
651+
expect(output).toContain('interface User')
652+
expect(output).toContain('type Status')
653+
expect(output).toContain(': User')
654+
expect(output).toContain(': number')
655+
expect(output).toContain(': string')
656+
})
657+
658+
it('should preserve TypeScript when convertToJs is not specified (default)', async () => {
659+
const markdown = `
660+
# Embed code sample
661+
662+
<$CodeSample path="/_internal/fixtures/typescript.ts" lines={[[1, -1]]} />
663+
664+
Some more text.
665+
`.trim()
666+
667+
const mdast = fromMarkdown(markdown, {
668+
mdastExtensions: [mdxFromMarkdown()],
669+
extensions: [mdxjs()],
670+
})
671+
const transformed = await transformWithMock(mdast)
672+
const output = toMarkdown(transformed, { extensions: [mdxToMarkdown()] })
673+
674+
// The output should contain TypeScript types by default
675+
expect(output).toContain('```typescript')
676+
expect(output).toContain('interface User')
677+
expect(output).toContain('type Status')
678+
})
679+
680+
it('should convert types but preserve line selection and elision', async () => {
681+
const markdown = `
682+
# Embed code sample
683+
684+
<$CodeSample path="/_internal/fixtures/typescript.ts" lines={[[1, 4], [10, -1]]} convertToJs={true} />
685+
686+
Some more text.
687+
`.trim()
688+
689+
const mdast = fromMarkdown(markdown, {
690+
mdastExtensions: [mdxFromMarkdown()],
691+
extensions: [mdxjs()],
692+
})
693+
const transformed = await transformWithMock(mdast)
694+
const output = toMarkdown(transformed, { extensions: [mdxToMarkdown()] })
695+
696+
const expected = `
697+
\`\`\`javascript
698+
const users = [
699+
{ id: 1, name: 'John', email: '[email protected]' },
700+
{ id: 2, name: 'Jane' },
701+
];
702+
703+
// ...
704+
705+
function createUser(name, email) {
706+
const newId = Math.max(...users.map((u) => u.id)) + 1;
707+
const newUser = { id: newId, name };
708+
if (email) {
709+
newUser.email = email;
710+
}
711+
users.push(newUser);
712+
return newUser;
713+
}
714+
715+
class UserManager {
716+
users = [];
717+
718+
constructor(initialUsers = []) {
719+
this.users = initialUsers;
720+
}
721+
722+
addUser(user) {
723+
this.users.push(user);
724+
}
725+
726+
getUsers() {
727+
return [...this.users];
728+
}
729+
}
730+
\`\`\`
731+
`.trim()
732+
733+
expect(containsStringIgnoringLeadingWhitespace(output, expected)).toBe(true)
734+
})
735+
})
536736
})
537737

538738
describe('_createElidedLine', () => {

apps/docs/features/directives/CodeSample.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
* lines={[1, 2], [5, 7]} // -1 may be used in end position as an alias for the last line, e.g., [1, -1]
1212
* meta="utils/client.ts" // Optional, for displaying a file path on the code block
1313
* hideElidedLines={true} // Optional, for hiding elided lines in the code block
14+
* convertToJs={true} // Optional, strips TypeScript types to produce JavaScript
1415
* />
1516
* ```
1617
*
@@ -26,15 +27,18 @@
2627
* lines={[1, 2], [5, 7]} // -1 may be used in end position as an alias for the last line, e.g., [1, -1]
2728
* meta="utils/client.ts" // Optional, for displaying a file path on the code block
2829
* hideElidedLines={true} // Optional, for hiding elided lines in the code block
30+
* convertToJs={true} // Optional, strips TypeScript types to produce JavaScript
2931
* />
3032
*/
3133

3234
import * as acorn from 'acorn'
3335
import tsPlugin from 'acorn-typescript'
3436
import { type DefinitionContent, type BlockContent, type Code, type Root } from 'mdast'
3537
import type { MdxJsxAttributeValueExpression, MdxJsxFlowElement } from 'mdast-util-mdx-jsx'
38+
import assert from 'node:assert'
3639
import { readFile } from 'node:fs/promises'
3740
import { join } from 'node:path'
41+
import { removeTypes } from 'remove-types'
3842
import { type Parent } from 'unist'
3943
import { visitParents } from 'unist-util-visit-parents'
4044
import { z, type SafeParseError } from 'zod'
@@ -69,6 +73,12 @@ type AdditionalMeta = {
6973
codeHikeAncestorParent: Parent | null
7074
}
7175

76+
const booleanValidator = z.union([z.boolean(), z.string(), z.undefined()]).transform((v) => {
77+
if (typeof v === 'boolean') return v
78+
if (typeof v === 'string') return v === 'true'
79+
return false
80+
})
81+
7282
const codeSampleExternalSchema = z.object({
7383
external: z.coerce.boolean().refine((v) => v === true),
7484
org: z.enum(ALLOW_LISTED_GITHUB_ORGS, {
@@ -80,6 +90,7 @@ const codeSampleExternalSchema = z.object({
8090
lines: linesValidator,
8191
meta: z.string().optional(),
8292
hideElidedLines: z.coerce.boolean().default(false),
93+
convertToJs: booleanValidator,
8394
})
8495
type ICodeSampleExternal = z.infer<typeof codeSampleExternalSchema> & AdditionalMeta
8596

@@ -92,6 +103,7 @@ const codeSampleInternalSchema = z.object({
92103
lines: linesValidator,
93104
meta: z.string().optional(),
94105
hideElidedLines: z.coerce.boolean().default(false),
106+
convertToJs: booleanValidator,
95107
})
96108
type ICodeSampleInternal = z.infer<typeof codeSampleInternalSchema> & AdditionalMeta
97109

@@ -114,7 +126,7 @@ interface Dependencies {
114126
export function codeSampleRemark(deps: Dependencies) {
115127
return async function transform(tree: Root) {
116128
const contentMap = await fetchSourceCodeContent(tree, deps)
117-
rewriteNodes(contentMap)
129+
await rewriteNodes(contentMap)
118130

119131
return tree
120132
}
@@ -154,6 +166,7 @@ async function fetchSourceCodeContent(tree: Root, deps: Dependencies) {
154166
const hideElidedLines = getAttributeValueExpression(
155167
getAttributeValue(node, 'hideElidedLines')
156168
)
169+
const convertToJs = getAttributeValueExpression(getAttributeValue(node, 'convertToJs'))
157170

158171
const result = codeSampleExternalSchema.safeParse({
159172
external: isExternal,
@@ -164,6 +177,7 @@ async function fetchSourceCodeContent(tree: Root, deps: Dependencies) {
164177
lines,
165178
meta,
166179
hideElidedLines,
180+
convertToJs,
167181
})
168182

169183
if (!result.success) {
@@ -197,13 +211,15 @@ async function fetchSourceCodeContent(tree: Root, deps: Dependencies) {
197211
const hideElidedLines = getAttributeValueExpression(
198212
getAttributeValue(node, 'hideElidedLines')
199213
)
214+
const convertToJs = getAttributeValueExpression(getAttributeValue(node, 'convertToJs'))
200215

201216
const result = codeSampleInternalSchema.safeParse({
202217
external: isExternal,
203218
path,
204219
lines,
205220
meta,
206221
hideElidedLines,
222+
convertToJs,
207223
})
208224

209225
if (!result.success) {
@@ -234,15 +250,30 @@ async function fetchSourceCodeContent(tree: Root, deps: Dependencies) {
234250
return nodeContentMap
235251
}
236252

237-
function rewriteNodes(contentMap: Map<MdxJsxFlowElement, [CodeSampleMeta, string]>) {
253+
async function rewriteNodes(contentMap: Map<MdxJsxFlowElement, [CodeSampleMeta, string]>) {
238254
for (const [node, [meta, content]] of contentMap) {
239-
const lang = matchLang(meta.path.split('.').pop() || '')
255+
let lang = matchLang(meta.path.split('.').pop() || '')
240256

241257
const source = isExternalSource(meta)
242258
? `https://github.com/${meta.org}/${meta.repo}/blob/${meta.commit}${meta.path}`
243259
: `https://github.com/supabase/supabase/blob/${process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA ?? 'master'}/examples${meta.path}`
244260

245-
const elidedContent = redactLines(content, meta.lines, lang, meta.hideElidedLines)
261+
let processedContent = content
262+
if (meta.convertToJs) {
263+
processedContent = await removeTypes(content)
264+
// Convert TypeScript/TSX language to JavaScript/JSX when converting types
265+
assert(
266+
lang === 'typescript' || lang === 'tsx',
267+
'Type stripping to JS is only supported for TypeScript and TSX'
268+
)
269+
if (lang === 'typescript') {
270+
lang = 'javascript'
271+
} else if (lang === 'tsx') {
272+
lang = 'jsx'
273+
}
274+
}
275+
276+
const elidedContent = redactLines(processedContent, meta.lines, lang, meta.hideElidedLines)
246277

247278
const replacementContent: MdxJsxFlowElement | Code = meta.codeHikeAncestor
248279
? {

apps/docs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
"remark-emoji": "^3.1.2",
108108
"remark-gfm": "^3.0.1",
109109
"remark-math": "^6.0.0",
110+
"remove-types": "1.0.0",
110111
"server-only": "^0.0.1",
111112
"shared-data": "workspace:*",
112113
"ui": "workspace:*",

0 commit comments

Comments
 (0)