-
Notifications
You must be signed in to change notification settings - Fork 631
/
compile-mdx.server.ts
339 lines (309 loc) · 8.71 KB
/
compile-mdx.server.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
import { remarkCodeBlocksShiki } from '@kentcdodds/md-temp'
import remarkEmbedder, { type TransformerInfo } from '@remark-embedder/core'
import oembedTransformer from '@remark-embedder/transformer-oembed'
import type * as H from 'hast'
import type * as M from 'mdast'
import type * as MDX from 'mdast-util-mdx-jsx'
import { bundleMDX } from 'mdx-bundler'
import PQueue from 'p-queue'
import calculateReadingTime from 'reading-time'
import remarkAutolinkHeadings from 'remark-autolink-headings'
import gfm from 'remark-gfm'
import remarkSlug from 'remark-slug'
import type * as U from 'unified'
import { visit } from 'unist-util-visit'
import { type GitHubFile } from '#app/types.ts'
import * as twitter from './twitter.server.ts'
function handleEmbedderError({ url }: { url: string }) {
return `<p>Error embedding <a href="${url}">${url}</a></p>.`
}
type GottenHTML = string | null
function handleEmbedderHtml(html: GottenHTML, info: TransformerInfo) {
if (!html) return null
const url = new URL(info.url)
// matches youtu.be and youtube.com
if (/youtu\.?be/.test(url.hostname)) {
// this allows us to set youtube embeds to 100% width and the
// height will be relative to that width with a good aspect ratio
return makeEmbed(html, 'youtube')
}
if (url.hostname.includes('codesandbox.io')) {
return makeEmbed(html, 'codesandbox', '80%')
}
return html
}
function makeEmbed(html: string, type: string, heightRatio = '56.25%') {
return `
<div class="embed" data-embed-type="${type}">
<div style="padding-bottom: ${heightRatio}">
${html}
</div>
</div>
`
}
function trimCodeBlocks() {
return async function transformer(tree: H.Root) {
visit(tree, 'element', (preNode: H.Element) => {
if (preNode.tagName !== 'pre' || !preNode.children.length) {
return
}
const codeNode = preNode.children[0]
if (
!codeNode ||
codeNode.type !== 'element' ||
codeNode.tagName !== 'code'
) {
return
}
const [codeStringNode] = codeNode.children
if (!codeStringNode) return
if (codeStringNode.type !== 'text') {
console.warn(
`trimCodeBlocks: Unexpected: codeStringNode type is not "text": ${codeStringNode.type}`,
)
return
}
codeStringNode.value = codeStringNode.value.trim()
})
}
}
// yes, I did write this myself 😬
const cloudinaryUrlRegex =
/^https?:\/\/res\.cloudinary\.com\/(?<cloudName>.+?)\/image\/upload\/((?<transforms>(.+?_.+?)+?)\/)?(\/?(?<version>v\d+)\/)?(?<publicId>.+$)/
function optimizeCloudinaryImages() {
return async function transformer(tree: H.Root) {
// @ts-expect-error ugh
visit(
tree,
'mdxJsxFlowElement',
function visitor(node: MDX.MdxJsxFlowElement) {
if (node.name !== 'img') return
const srcAttr = node.attributes.find(
(attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'src',
)
const urlString = srcAttr?.value ? String(srcAttr.value) : null
if (!srcAttr || !urlString) {
console.error('image without url?', node)
return
}
const newUrl = handleImageUrl(urlString)
if (newUrl) {
srcAttr.value = newUrl
}
},
)
visit(tree, 'element', function visitor(node: H.Element) {
if (node.tagName !== 'img') return
const urlString = node.properties?.src
? String(node.properties.src)
: null
if (!node.properties?.src || !urlString) {
console.error('image without url?', node)
return
}
const newUrl = handleImageUrl(urlString)
if (newUrl) {
node.properties.src = newUrl
}
})
}
function handleImageUrl(urlString: string) {
const match = urlString.match(cloudinaryUrlRegex)
const groups = match?.groups
if (groups) {
const { cloudName, transforms, version, publicId } = groups as {
cloudName: string
transforms?: string
version?: string
publicId: string
}
// don't add transforms if they're already included
if (transforms) return
const defaultTransforms = [
'f_auto',
'q_auto',
// gifs can't do dpr transforms
publicId.endsWith('.gif') ? '' : 'dpr_2.0',
'w_1600',
]
.filter(Boolean)
.join(',')
return [
`https://res.cloudinary.com/${cloudName}/image/upload`,
defaultTransforms,
version,
publicId,
]
.filter(Boolean)
.join('/')
}
}
}
const twitterTransformer = {
shouldTransform: twitter.isTwitterUrl,
getHTML: twitter.getTweetEmbedHTML,
}
const eggheadTransformer = {
shouldTransform: (url: string) => {
const { host, pathname } = new URL(url)
return (
host === 'egghead.io' &&
pathname.includes('/lessons/') &&
!pathname.includes('/embed')
)
},
getHTML: (url: string) => {
const { host, pathname, searchParams } = new URL(url)
// Don't preload videos
if (!searchParams.has('preload')) {
searchParams.set('preload', 'false')
}
// Kent's affiliate link
if (!searchParams.has('af')) {
searchParams.set('af', '5236ad')
}
const iframeSrc = `https://${host}${pathname}/embed?${searchParams.toString()}`
return makeEmbed(
`<iframe src="${iframeSrc}" allowfullscreen></iframe>`,
'egghead',
)
},
}
function autoAffiliates() {
return async function affiliateTransformer(tree: M.Root) {
visit(tree, 'link', function visitor(linkNode: M.Link) {
if (linkNode.url.includes('amazon.com')) {
const amazonUrl = new URL(linkNode.url)
if (!amazonUrl.searchParams.has('tag')) {
amazonUrl.searchParams.set('tag', 'kentcdodds-20')
linkNode.url = amazonUrl.toString()
}
}
if (linkNode.url.includes('egghead.io')) {
const eggheadUrl = new URL(linkNode.url)
if (!eggheadUrl.searchParams.has('af')) {
eggheadUrl.searchParams.set('af', '5236ad')
linkNode.url = eggheadUrl.toString()
}
}
})
}
}
function removePreContainerDivs() {
return async function preContainerDivsTransformer(tree: H.Root) {
visit(
tree,
{ type: 'element', tagName: 'pre' },
function visitor(node, index, parent) {
if (parent?.type !== 'element') return
if (parent.tagName !== 'div') return
if (parent.children.length !== 1 && index === 0) return
Object.assign(parent, node)
},
)
}
}
const remarkPlugins: U.PluggableList = [
[
remarkEmbedder,
{
handleError: handleEmbedderError,
handleHTML: handleEmbedderHtml,
transformers: [twitterTransformer, eggheadTransformer, oembedTransformer],
},
],
autoAffiliates,
]
const rehypePlugins: U.PluggableList = [
optimizeCloudinaryImages,
trimCodeBlocks,
remarkCodeBlocksShiki,
removePreContainerDivs,
]
async function compileMdx<FrontmatterType extends Record<string, unknown>>(
slug: string,
githubFiles: Array<GitHubFile>,
) {
const indexRegex = new RegExp(`${slug}\\/index.mdx?$`)
const indexFile = githubFiles.find(({ path }) => indexRegex.test(path))
if (!indexFile) return null
const rootDir = indexFile.path.replace(/index.mdx?$/, '')
const relativeFiles: Array<GitHubFile> = githubFiles.map(
({ path, content }) => ({
path: path.replace(rootDir, './'),
content,
}),
)
const files = arrayToObj(relativeFiles, {
keyName: 'path',
valueName: 'content',
})
try {
const { frontmatter, code } = await bundleMDX({
source: indexFile.content,
files,
mdxOptions(options) {
options.remarkPlugins = [
...(options.remarkPlugins ?? []),
remarkSlug,
[remarkAutolinkHeadings, { behavior: 'wrap' }],
gfm,
...remarkPlugins,
]
options.rehypePlugins = [
...(options.rehypePlugins ?? []),
...rehypePlugins,
]
return options
},
})
const readTime = calculateReadingTime(indexFile.content)
console.log(code)
return {
code,
readTime,
frontmatter: frontmatter as FrontmatterType,
}
} catch (error: unknown) {
console.error(`Compilation error for slug: `, slug)
throw error
}
}
function arrayToObj<ItemType extends Record<string, unknown>>(
array: Array<ItemType>,
{
keyName,
valueName,
}: { keyName: keyof ItemType; valueName: keyof ItemType },
) {
const obj: Record<string, ItemType[keyof ItemType]> = {}
for (const item of array) {
const key = item[keyName]
if (typeof key !== 'string') {
throw new Error(`${String(keyName)} of item must be a string`)
}
const value = item[valueName]
obj[key] = value
}
return obj
}
let _queue: PQueue | null = null
async function getQueue() {
if (_queue) return _queue
_queue = new PQueue({
concurrency: 1,
throwOnTimeout: true,
timeout: 1000 * 30,
})
return _queue
}
// We have to use a queue because we can't run more than one of these at a time
// or we'll hit an out of memory error because esbuild uses a lot of memory...
async function queuedCompileMdx<
FrontmatterType extends Record<string, unknown>,
>(...args: Parameters<typeof compileMdx>) {
const queue = await getQueue()
const result = await queue.add(() => compileMdx<FrontmatterType>(...args))
return result
}
export { queuedCompileMdx as compileMdx }