Skip to content

Commit 4573b78

Browse files
authored
perf(www): cache and parallelize registry source loading (#931)
* perf(www): cache and parallelize registry source loading * fix(www): preserve registry file entries for schema validation
1 parent db54166 commit 4573b78

2 files changed

Lines changed: 86 additions & 77 deletions

File tree

apps/www/lib/docs.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,30 @@ export const replaceComponentSource = async (content: string) => {
2323
const componentSourceMatches = [
2424
...content.matchAll(/<ComponentSource\s+name="([^"]+)"[^>]*>/g),
2525
]
26-
for (const [fullMatch, name] of componentSourceMatches) {
27-
const component = await getRegistryItem(name)
28-
if (component?.files?.[0]?.content) {
29-
const sourceCode = component.files[0].content
30-
const replacement = `\`\`\`tsx\n${sourceCode}\n\`\`\``
31-
content = content.replace(fullMatch, replacement)
32-
} else {
33-
content = content.replace(fullMatch, fullMatch)
34-
}
26+
if (componentSourceMatches.length === 0) {
27+
return content
3528
}
3629

37-
return content
30+
const replacements = await Promise.all(
31+
componentSourceMatches.map(async ([fullMatch, name]) => {
32+
const component = await getRegistryItem(name)
33+
if (!component?.files?.[0]?.content) {
34+
return fullMatch
35+
}
36+
37+
return `\`\`\`tsx\n${component.files[0].content}\n\`\`\``
38+
})
39+
)
40+
41+
let previousIndex = 0
42+
let nextContent = ""
43+
44+
for (const [index, match] of componentSourceMatches.entries()) {
45+
const startIndex = match.index ?? previousIndex
46+
nextContent += content.slice(previousIndex, startIndex)
47+
nextContent += replacements[index]
48+
previousIndex = startIndex + match[0].length
49+
}
50+
51+
return `${nextContent}${content.slice(previousIndex)}`
3852
}

apps/www/lib/registry.ts

Lines changed: 62 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,72 @@
11
import fs from "node:fs/promises"
2-
import { tmpdir } from "os"
3-
import path from "path"
4-
import { RegistryItem, registryItemSchema } from "shadcn/schema"
5-
import { Project, ScriptKind } from "ts-morph"
2+
import path from "node:path"
3+
import { cache } from "react"
4+
import { registryItemSchema } from "shadcn/schema"
5+
import type { RegistryItem } from "shadcn/schema"
66

77
import { Index } from "@/registry/__index__"
88

99
// Fumadocs zod v4 compat type fix. Temporary.
1010
interface RegistryItemFile {
1111
path: string
12-
content: string
13-
type: RegistryItem["type"]
14-
target: string
12+
type?: RegistryItem["type"]
13+
target?: string
1514
}
1615

1716
export function getRegistryComponent(name: string) {
1817
return Index[name]?.component
1918
}
2019

21-
export async function getRegistryItem(name: string) {
20+
interface RegistryItemFileWithContent extends RegistryItemFile {
21+
content: string
22+
}
23+
24+
const registryImportPattern =
25+
/@\/(.+?)\/((?:.*?\/)?(?:components|ui|hooks|lib))\/([\w-]+)/g
26+
27+
const getCachedFileContent = cache(
28+
async (filePath: string, fileType: RegistryItem["type"] | undefined) => {
29+
let code = await fs.readFile(filePath, "utf-8")
30+
31+
// Some registry items use default exports, but published snippets should not.
32+
if (fileType !== "registry:page") {
33+
code = code.replaceAll("export default", "export")
34+
}
35+
36+
return fixImport(code)
37+
}
38+
)
39+
40+
const getCachedRegistryItem = cache(async (name: string) => {
2241
const item = Index[name]
2342

2443
if (!item) {
2544
return null
2645
}
2746

28-
// Convert all file paths to object.
29-
// TODO: remove when we migrate to new registry.
30-
item.files = item.files.map((file: unknown) =>
31-
typeof file === "string" ? { path: file } : file
32-
)
47+
const normalizedItem = {
48+
...item,
49+
files: normalizeRegistryItemFiles(item.files),
50+
}
3351

3452
// Fail early before doing expensive file operations.
35-
const result = registryItemSchema.safeParse(item)
53+
const result = registryItemSchema.safeParse(normalizedItem)
3654
if (!result.success) {
3755
return null
3856
}
3957

40-
let files: typeof result.data.files = []
41-
for (const file of item.files) {
42-
const content = await getFileContent(file)
43-
const relativePath = path.relative(process.cwd(), file.path)
44-
45-
files.push({
46-
...file,
47-
path: relativePath,
48-
content,
49-
})
50-
}
51-
52-
// Fix file paths.
53-
files = fixFilePaths(files as RegistryItemFile[])
58+
const files = fixFilePaths(
59+
await Promise.all(
60+
(result.data.files ?? []).map(async (file) => {
61+
const content = await getCachedFileContent(file.path, file.type)
62+
return {
63+
...file,
64+
path: path.relative(process.cwd(), file.path),
65+
content,
66+
}
67+
})
68+
)
69+
)
5470

5571
const parsed = registryItemSchema.safeParse({
5672
...result.data,
@@ -63,45 +79,31 @@ export async function getRegistryItem(name: string) {
6379
}
6480

6581
return parsed.data
66-
}
67-
68-
async function getFileContent(file: RegistryItemFile) {
69-
const raw = await fs.readFile(file.path, "utf-8")
70-
71-
const project = new Project({
72-
compilerOptions: {},
73-
})
74-
75-
const tempFile = await createTempSourceFile(file.path)
76-
const sourceFile = project.createSourceFile(tempFile, raw, {
77-
scriptKind: ScriptKind.TSX,
78-
})
79-
80-
// Remove meta variables.
81-
// removeVariable(sourceFile, "iframeHeight")
82-
// removeVariable(sourceFile, "containerClassName")
83-
// removeVariable(sourceFile, "description")
82+
})
8483

85-
let code = sourceFile.getFullText()
84+
export async function getRegistryItem(name: string) {
85+
return getCachedRegistryItem(name)
86+
}
8687

87-
// Some registry items uses default export.
88-
// We want to use named export instead.
89-
// TODO: do we really need this? - @shadcn.
90-
if (file.type !== "registry:page") {
91-
code = code.replaceAll("export default", "export")
88+
function normalizeRegistryItemFiles(files: unknown) {
89+
if (!Array.isArray(files)) {
90+
return files
9291
}
9392

94-
// Fix imports.
95-
code = fixImport(code)
93+
return files.map((file) => {
94+
if (typeof file === "string") {
95+
return { path: file }
96+
}
9697

97-
return code
98+
return file
99+
})
98100
}
99101

100102
function getFileTarget(file: RegistryItemFile) {
101103
let target = file.target
102104

103105
if (!target || target === "") {
104-
const fileName = file.path.split("/").pop()
106+
const fileName = path.basename(file.path)
105107
if (
106108
file.type === "registry:block" ||
107109
file.type === "registry:component" ||
@@ -126,13 +128,8 @@ function getFileTarget(file: RegistryItemFile) {
126128
return target ?? ""
127129
}
128130

129-
async function createTempSourceFile(filename: string) {
130-
const dir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-"))
131-
return path.join(dir, filename)
132-
}
133-
134-
function fixFilePaths(files: RegistryItemFile[]) {
135-
if (!files) {
131+
function fixFilePaths(files: RegistryItemFileWithContent[]) {
132+
if (files.length === 0) {
136133
return []
137134
}
138135

@@ -150,11 +147,9 @@ function fixFilePaths(files: RegistryItemFile[]) {
150147
}
151148

152149
export function fixImport(content: string) {
153-
const regex = /@\/(.+?)\/((?:.*?\/)?(?:components|ui|hooks|lib))\/([\w-]+)/g
154-
155150
const replacement = (
156151
match: string,
157-
path: string,
152+
_sourcePath: string,
158153
type: string,
159154
component: string
160155
) => {
@@ -171,7 +166,7 @@ export function fixImport(content: string) {
171166
return match
172167
}
173168

174-
return content.replace(regex, replacement)
169+
return content.replace(registryImportPattern, replacement)
175170
}
176171

177172
export type FileTree = {

0 commit comments

Comments
 (0)