docs-i18n 0.7.4 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/dist/server/assets/chunk-CNvmzFzq.js +35 -0
- package/admin/dist/server/assets/{init-AJSQ7K_l.js → init-DJr2glb3.js} +5 -38
- package/admin/dist/server/assets/{jobs-CwDb0Zyp.js → jobs-FXffC7LH.js} +2 -2
- package/admin/dist/server/assets/{misc-CqYhnW23.js → misc-y6t3-UOP.js} +3 -3
- package/admin/dist/server/assets/{models-D9Sd95EX.js → models-YNa3F3nn.js} +1 -1
- package/admin/dist/server/assets/react-dom-BryASgrS.js +2159 -0
- package/admin/dist/server/assets/redirect-BHRifpCK.js +51 -0
- package/admin/dist/server/assets/router-CAX08MEI.js +897 -0
- package/admin/dist/server/assets/routes-Bk6XCM2I.js +2139 -0
- package/admin/dist/server/assets/routes-CMOVc2RM.js +2132 -0
- package/admin/dist/server/assets/{status-D48jcwYI.js → status-CM7Azp4n.js} +2 -2
- package/admin/dist/server/server.js +15789 -4447
- package/admin/vite.config.ts +13 -0
- package/dist/cli.js +1 -1
- package/dist/{upload-XL6KG6S2.js → upload-KYKJVERO.js} +1 -1
- package/package.json +1 -1
- package/template/app/components/BlogArticle.tsx +3 -0
- package/template/app/components/Doc.tsx +4 -0
- package/template/app/components/markdown/MarkdownContent.tsx +6 -2
- package/template/app/site.config.ts +2 -0
- package/template/app/types/index.ts +4 -0
- package/template/app/utils/content-loader.ts +85 -32
- package/template/app/utils/docs.server.ts +38 -6
- package/template/app/utils/markdown/plugins/index.ts +1 -0
- package/template/app/utils/markdown/plugins/mdxJsxToRaw.ts +127 -0
- package/template/app/utils/markdown/processor.ts +14 -1
- package/template/app/utils/sidebar-generator.ts +185 -0
- package/template/app/utils/url-mapper.ts +22 -0
- package/template/package.json +2 -1
- package/admin/dist/server/assets/router-D00bP5CU.js +0 -67
- package/admin/dist/server/assets/routes-C2UFxDWZ.js +0 -24
- package/admin/dist/server/assets/routes-vEKXnl0r.js +0 -1574
- /package/admin/dist/server/assets/{_tanstack-start-manifest_v-sC90W3ET.js → _tanstack-start-manifest_v-mK4S3Lga.js} +0 -0
- /package/admin/dist/server/assets/{createServerRpc-CMjjCE8A.js → createServerRpc-C3JHS5ky.js} +0 -0
- /package/admin/dist/server/assets/{start-BrsoKfWS.js → start-3avuCbOL.js} +0 -0
package/admin/vite.config.ts
CHANGED
|
@@ -10,4 +10,17 @@ export default defineConfig({
|
|
|
10
10
|
}),
|
|
11
11
|
react(),
|
|
12
12
|
],
|
|
13
|
+
// Bundle all deps into server build so it can run without node_modules
|
|
14
|
+
environments: {
|
|
15
|
+
ssr: {
|
|
16
|
+
build: {
|
|
17
|
+
rollupOptions: {
|
|
18
|
+
external: ['node:*'],
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
resolve: {
|
|
22
|
+
noExternal: true,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
13
26
|
});
|
package/dist/cli.js
CHANGED
|
@@ -72,7 +72,7 @@ async function handleSiteCommand() {
|
|
|
72
72
|
child.on("exit", (code) => process.exit(code ?? 0));
|
|
73
73
|
} else if (subCommand === "upload") {
|
|
74
74
|
console.log("Uploading content to D1...");
|
|
75
|
-
const { collectContentFiles, generateContentSql, collectTranslations, generateTranslationSql } = await import("./upload-
|
|
75
|
+
const { collectContentFiles, generateContentSql, collectTranslations, generateTranslationSql } = await import("./upload-KYKJVERO.js");
|
|
76
76
|
const projectRoot = process.cwd();
|
|
77
77
|
const contentRows = collectContentFiles(projectRoot);
|
|
78
78
|
const contentSql = generateContentSql(contentRows);
|
|
@@ -21,7 +21,7 @@ function walkDir(dir, contentRoot, rows) {
|
|
|
21
21
|
const fullPath = join(dir, entry.name);
|
|
22
22
|
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
23
23
|
walkDir(fullPath, contentRoot, rows);
|
|
24
|
-
} else if (entry.isFile() && (entry.name.endsWith(".md") || entry.name.endsWith(".json"))) {
|
|
24
|
+
} else if (entry.isFile() && (entry.name.endsWith(".md") || entry.name.endsWith(".mdx") || entry.name.endsWith(".json"))) {
|
|
25
25
|
const relativePath = relative(contentRoot, fullPath);
|
|
26
26
|
const body = readFileSync(fullPath, "utf-8");
|
|
27
27
|
const parts = relativePath.split("/");
|
package/package.json
CHANGED
|
@@ -19,6 +19,7 @@ import { MarkdownContent } from '~/components/markdown'
|
|
|
19
19
|
import { Toc } from '~/components/Toc'
|
|
20
20
|
import { Breadcrumbs } from '~/components/Breadcrumbs'
|
|
21
21
|
import { FallbackBanner } from '~/components/FallbackBanner'
|
|
22
|
+
import { useSiteConfig } from '~/utils/site-config'
|
|
22
23
|
import type { LoadedBlogPost } from '~/utils/blog'
|
|
23
24
|
|
|
24
25
|
type BlogArticleProps = {
|
|
@@ -29,6 +30,7 @@ type BlogArticleProps = {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export function BlogArticle({ post, lang, locale }: BlogArticleProps) {
|
|
33
|
+
const siteConfig = useSiteConfig()
|
|
32
34
|
const { title, content, authors, published, filePath, isFallback } = post
|
|
33
35
|
|
|
34
36
|
// Prepend byline to content (matches tanstack.com pattern)
|
|
@@ -137,6 +139,7 @@ ${content}`
|
|
|
137
139
|
branch=""
|
|
138
140
|
filePath={filePath}
|
|
139
141
|
containerRef={markdownContainerRef}
|
|
142
|
+
customComponents={siteConfig.components}
|
|
140
143
|
/>
|
|
141
144
|
</div>
|
|
142
145
|
{isTocVisible && (
|
|
@@ -11,6 +11,7 @@ import { MarkdownContent } from '~/components/markdown'
|
|
|
11
11
|
import type { DocsConfig, MarkdownHeading } from '~/types'
|
|
12
12
|
import { useLocalCurrentFramework } from './FrameworkSelect'
|
|
13
13
|
import { useParams } from '@tanstack/react-router'
|
|
14
|
+
import { useSiteConfig } from '~/utils/site-config'
|
|
14
15
|
|
|
15
16
|
type DocProps = {
|
|
16
17
|
title: string
|
|
@@ -50,6 +51,8 @@ export function Doc({
|
|
|
50
51
|
isFallback = false,
|
|
51
52
|
locale,
|
|
52
53
|
}: DocProps) {
|
|
54
|
+
const siteConfig = useSiteConfig()
|
|
55
|
+
|
|
53
56
|
// Extract headings synchronously during render to avoid hydration mismatch
|
|
54
57
|
const { headings, markup } = React.useMemo(
|
|
55
58
|
() => renderMarkdown(content),
|
|
@@ -154,6 +157,7 @@ export function Doc({
|
|
|
154
157
|
htmlMarkup={markup}
|
|
155
158
|
containerRef={markdownContainerRef}
|
|
156
159
|
currentFramework={currentFramework}
|
|
160
|
+
customComponents={siteConfig.components}
|
|
157
161
|
titleBarActions={
|
|
158
162
|
setIsFullWidth ? (
|
|
159
163
|
<button
|
|
@@ -3,6 +3,7 @@ import { SquarePen } from 'lucide-react'
|
|
|
3
3
|
import { twMerge } from 'tailwind-merge'
|
|
4
4
|
import { Markdown } from './Markdown'
|
|
5
5
|
import { Button } from '../ui/Button'
|
|
6
|
+
import type { SiteConfig } from '~/types'
|
|
6
7
|
|
|
7
8
|
type MarkdownContentProps = {
|
|
8
9
|
title: string
|
|
@@ -23,6 +24,8 @@ type MarkdownContentProps = {
|
|
|
23
24
|
containerRef?: React.RefObject<HTMLDivElement | null>
|
|
24
25
|
/** Current framework for filtering markdown content */
|
|
25
26
|
currentFramework?: string
|
|
27
|
+
/** Custom components from SiteConfig for rendering MDX JSX elements */
|
|
28
|
+
customComponents?: SiteConfig['components']
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
export function MarkdownContent({
|
|
@@ -36,11 +39,12 @@ export function MarkdownContent({
|
|
|
36
39
|
titleBarActions,
|
|
37
40
|
proseClassName,
|
|
38
41
|
containerRef,
|
|
42
|
+
customComponents,
|
|
39
43
|
}: MarkdownContentProps) {
|
|
40
44
|
const markdownElement = htmlMarkup ? (
|
|
41
|
-
<Markdown htmlMarkup={htmlMarkup} />
|
|
45
|
+
<Markdown htmlMarkup={htmlMarkup} customComponents={customComponents} />
|
|
42
46
|
) : rawContent ? (
|
|
43
|
-
<Markdown rawContent={rawContent} renderMarkdown={renderMarkdown} />
|
|
47
|
+
<Markdown rawContent={rawContent} renderMarkdown={renderMarkdown} customComponents={customComponents} />
|
|
44
48
|
) : null
|
|
45
49
|
|
|
46
50
|
return (
|
|
@@ -36,6 +36,10 @@ export interface ProjectConfig {
|
|
|
36
36
|
badge?: string
|
|
37
37
|
tagline?: string
|
|
38
38
|
description?: string
|
|
39
|
+
/** Transform file path to URL slug. Strips numeric prefixes, extensions, etc. */
|
|
40
|
+
urlMapper?: (filePath: string) => string
|
|
41
|
+
/** How to generate sidebar: 'config' reads docs.config.json, 'filesystem' auto-generates from directory structure */
|
|
42
|
+
sidebarSource?: 'config' | 'filesystem'
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
export interface MarkdownHeading {
|
|
@@ -62,10 +62,16 @@ function getTranslationCache(projectRoot: string): TranslationCache | null {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
export function createFsLoader(
|
|
65
|
+
export function createFsLoader(
|
|
66
|
+
projectRoot: string,
|
|
67
|
+
urlMapper?: (filePath: string) => string,
|
|
68
|
+
): ContentLoader {
|
|
69
|
+
/** Supported markdown extensions, in priority order. */
|
|
70
|
+
const mdExtensions = ['.mdx', '.md']
|
|
71
|
+
|
|
66
72
|
/**
|
|
67
|
-
* Read a raw .md file from one of the candidate base directories.
|
|
68
|
-
*
|
|
73
|
+
* Read a raw .md/.mdx file from one of the candidate base directories.
|
|
74
|
+
* When urlMapper is set, scans directories and matches by mapped slug.
|
|
69
75
|
*/
|
|
70
76
|
function readRawFile(
|
|
71
77
|
project: string,
|
|
@@ -73,14 +79,52 @@ export function createFsLoader(projectRoot: string): ContentLoader {
|
|
|
73
79
|
lang: string,
|
|
74
80
|
slug: string,
|
|
75
81
|
): { raw: string; filePath: string } | null {
|
|
76
|
-
const
|
|
77
|
-
resolve(projectRoot, 'content', project, version, lang
|
|
78
|
-
resolve(projectRoot, 'content', project, lang
|
|
79
|
-
resolve(projectRoot, 'content', lang
|
|
82
|
+
const baseDirs = [
|
|
83
|
+
resolve(projectRoot, 'content', project, version, lang),
|
|
84
|
+
resolve(projectRoot, 'content', project, lang),
|
|
85
|
+
resolve(projectRoot, 'content', lang),
|
|
80
86
|
]
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
87
|
+
|
|
88
|
+
if (!urlMapper) {
|
|
89
|
+
for (const baseDir of baseDirs) {
|
|
90
|
+
for (const ext of mdExtensions) {
|
|
91
|
+
const filePath = resolve(baseDir, `${slug}${ext}`)
|
|
92
|
+
if (existsSync(filePath)) {
|
|
93
|
+
return { raw: readFileSync(filePath, 'utf-8'), filePath }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// With urlMapper: scan files and match by mapped slug
|
|
101
|
+
for (const baseDir of baseDirs) {
|
|
102
|
+
if (!existsSync(baseDir)) continue
|
|
103
|
+
const match = findFileByMappedSlug(baseDir, baseDir, slug)
|
|
104
|
+
if (match) return match
|
|
105
|
+
}
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Recursively scan for .md/.mdx files, apply urlMapper, match target slug. */
|
|
110
|
+
function findFileByMappedSlug(
|
|
111
|
+
dir: string,
|
|
112
|
+
baseDir: string,
|
|
113
|
+
targetSlug: string,
|
|
114
|
+
): { raw: string; filePath: string } | null {
|
|
115
|
+
let entries: ReturnType<typeof readdirSync>
|
|
116
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return null }
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (entry.name.startsWith('.')) continue
|
|
119
|
+
const fullPath = join(dir, entry.name)
|
|
120
|
+
if (entry.isFile() && /\.mdx?$/.test(entry.name)) {
|
|
121
|
+
const mapped = urlMapper!(relative(baseDir, fullPath))
|
|
122
|
+
if (mapped === targetSlug) {
|
|
123
|
+
return { raw: readFileSync(fullPath, 'utf-8'), filePath: fullPath }
|
|
124
|
+
}
|
|
125
|
+
} else if (entry.isDirectory()) {
|
|
126
|
+
const result = findFileByMappedSlug(fullPath, baseDir, targetSlug)
|
|
127
|
+
if (result) return result
|
|
84
128
|
}
|
|
85
129
|
}
|
|
86
130
|
return null
|
|
@@ -114,8 +158,9 @@ export function createFsLoader(projectRoot: string): ContentLoader {
|
|
|
114
158
|
try {
|
|
115
159
|
const entries = readdirSync(dir, { withFileTypes: true })
|
|
116
160
|
for (const entry of entries) {
|
|
117
|
-
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
118
|
-
|
|
161
|
+
if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.mdx'))) {
|
|
162
|
+
const rawSlug = relative(base, join(dir, entry.name)).replace(/\.mdx?$/, '')
|
|
163
|
+
slugs.push(urlMapper ? urlMapper(relative(base, join(dir, entry.name))) : rawSlug)
|
|
119
164
|
} else if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
120
165
|
slugs.push(...scanDirectory(join(dir, entry.name), base))
|
|
121
166
|
}
|
|
@@ -149,7 +194,9 @@ export function createFsLoader(projectRoot: string): ContentLoader {
|
|
|
149
194
|
const cache = getTranslationCache(projectRoot)
|
|
150
195
|
if (cache) {
|
|
151
196
|
try {
|
|
152
|
-
|
|
197
|
+
// Derive the extension from the resolved file path for cache lookup
|
|
198
|
+
const ext = en.filePath.endsWith('.mdx') ? '.mdx' : '.md'
|
|
199
|
+
const sourceFilePath = `${slug}${ext}`
|
|
153
200
|
const result = assemble(
|
|
154
201
|
en.raw,
|
|
155
202
|
lang,
|
|
@@ -241,45 +288,51 @@ import type { Db } from '~/db'
|
|
|
241
288
|
import { createDb, schema } from '~/db'
|
|
242
289
|
|
|
243
290
|
export function createD1Loader(db: Db): ContentLoader {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
291
|
+
/** Try loading a row by slug with .mdx first, then .md. */
|
|
292
|
+
async function loadBySlug(
|
|
293
|
+
prefix: string,
|
|
294
|
+
slug: string,
|
|
295
|
+
): Promise<{ row: { body: string; path: string } } | null> {
|
|
296
|
+
for (const ext of ['.mdx', '.md']) {
|
|
297
|
+
const path = `${prefix}/${slug}${ext}`
|
|
249
298
|
const row = await db
|
|
250
299
|
.select()
|
|
251
300
|
.from(schema.content)
|
|
252
301
|
.where(eq(schema.content.path, path))
|
|
253
302
|
.get()
|
|
303
|
+
if (row) return { row: { body: row.body, path } }
|
|
304
|
+
}
|
|
305
|
+
return null
|
|
306
|
+
}
|
|
254
307
|
|
|
255
|
-
|
|
256
|
-
|
|
308
|
+
return {
|
|
309
|
+
async loadDoc(project, version, lang, slug) {
|
|
310
|
+
// Try requested language
|
|
311
|
+
const result = await loadBySlug(`${project}/${version}/${lang}`, slug)
|
|
312
|
+
|
|
313
|
+
if (result) {
|
|
314
|
+
const { data, content } = matter(result.row.body)
|
|
257
315
|
return {
|
|
258
316
|
content,
|
|
259
317
|
meta: { title: (data.title as string) || '', ...data },
|
|
260
318
|
locale: lang,
|
|
261
319
|
isFallback: false,
|
|
262
|
-
filePath: path,
|
|
320
|
+
filePath: result.row.path,
|
|
263
321
|
}
|
|
264
322
|
}
|
|
265
323
|
|
|
266
324
|
// Fallback to English
|
|
267
325
|
if (lang !== 'en') {
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
.where(eq(schema.content.path, enPath))
|
|
273
|
-
.get()
|
|
274
|
-
|
|
275
|
-
if (enRow) {
|
|
276
|
-
const { data, content } = matter(enRow.body)
|
|
326
|
+
const enResult = await loadBySlug(`${project}/${version}/en`, slug)
|
|
327
|
+
|
|
328
|
+
if (enResult) {
|
|
329
|
+
const { data, content } = matter(enResult.row.body)
|
|
277
330
|
return {
|
|
278
331
|
content,
|
|
279
332
|
meta: { title: (data.title as string) || '', ...data },
|
|
280
333
|
locale: 'en',
|
|
281
334
|
isFallback: true,
|
|
282
|
-
filePath:
|
|
335
|
+
filePath: enResult.row.path,
|
|
283
336
|
}
|
|
284
337
|
}
|
|
285
338
|
}
|
|
@@ -348,7 +401,7 @@ export function createD1Loader(db: Db): ContentLoader {
|
|
|
348
401
|
.all()
|
|
349
402
|
|
|
350
403
|
return rows
|
|
351
|
-
.map((r) => r.path.replace(prefix, '').replace(/\.
|
|
404
|
+
.map((r) => r.path.replace(prefix, '').replace(/\.mdx?$/, ''))
|
|
352
405
|
.filter((s) => s.length > 0)
|
|
353
406
|
},
|
|
354
407
|
}
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
9
9
|
import { resolve, relative, join } from 'node:path'
|
|
10
10
|
import matter from 'gray-matter'
|
|
11
|
-
import type { LoadedDoc, DocsConfig, DocsConfigItem } from '~/types'
|
|
11
|
+
import type { LoadedDoc, DocsConfig, DocsConfigItem, ProjectConfig } from '~/types'
|
|
12
12
|
import { type ContentLoader, createFsLoader } from './content-loader'
|
|
13
|
+
import { generateSidebarFromFilesystem } from './sidebar-generator'
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Get the project root directory.
|
|
@@ -61,25 +62,35 @@ export function setContentLoader(loader: ContentLoader) {
|
|
|
61
62
|
|
|
62
63
|
/**
|
|
63
64
|
* Load a document with i18n fallback.
|
|
64
|
-
*
|
|
65
|
+
* When projectConfig has urlMapper, creates a mapper-aware loader.
|
|
65
66
|
*/
|
|
66
67
|
export async function loadDoc(
|
|
67
68
|
project: string,
|
|
68
69
|
version: string,
|
|
69
70
|
lang: string,
|
|
70
71
|
slug: string,
|
|
72
|
+
projectConfig?: ProjectConfig,
|
|
71
73
|
): Promise<LoadedDoc> {
|
|
74
|
+
if (projectConfig?.urlMapper) {
|
|
75
|
+
const loader = createFsLoader(getProjectRoot(), projectConfig.urlMapper)
|
|
76
|
+
return loader.loadDoc(project, version, lang, slug)
|
|
77
|
+
}
|
|
72
78
|
return getLoader().loadDoc(project, version, lang, slug)
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
/**
|
|
76
|
-
* Load docs config (
|
|
77
|
-
*
|
|
82
|
+
* Load docs config (sidebar structure).
|
|
83
|
+
* When sidebarSource is 'filesystem', generates from directory tree.
|
|
78
84
|
*/
|
|
79
85
|
export async function loadDocsConfig(
|
|
80
86
|
project: string,
|
|
81
87
|
version: string,
|
|
88
|
+
projectConfig?: ProjectConfig,
|
|
82
89
|
): Promise<DocsConfig> {
|
|
90
|
+
if (projectConfig?.sidebarSource === 'filesystem') {
|
|
91
|
+
return loadFilesystemSidebar(project, version, projectConfig)
|
|
92
|
+
}
|
|
93
|
+
|
|
83
94
|
const config = await getLoader().loadDocsConfig(project, version)
|
|
84
95
|
if (config) return config
|
|
85
96
|
|
|
@@ -87,6 +98,27 @@ export async function loadDocsConfig(
|
|
|
87
98
|
return autoScanDocs(getProjectRoot(), project, version)
|
|
88
99
|
}
|
|
89
100
|
|
|
101
|
+
/** Generate sidebar from filesystem using numeric prefix ordering. */
|
|
102
|
+
function loadFilesystemSidebar(
|
|
103
|
+
project: string,
|
|
104
|
+
version: string,
|
|
105
|
+
projectConfig: ProjectConfig,
|
|
106
|
+
): DocsConfig {
|
|
107
|
+
const root = getProjectRoot()
|
|
108
|
+
const candidates = [
|
|
109
|
+
resolve(root, 'content', project, version, 'en'),
|
|
110
|
+
resolve(root, 'content', project, 'en'),
|
|
111
|
+
resolve(root, 'content', 'en'),
|
|
112
|
+
]
|
|
113
|
+
for (const dir of candidates) {
|
|
114
|
+
if (existsSync(dir) && statSync(dir).isDirectory()) {
|
|
115
|
+
const config = generateSidebarFromFilesystem(dir, projectConfig.urlMapper)
|
|
116
|
+
if (config.sections.length > 0) return config
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { sections: [] }
|
|
120
|
+
}
|
|
121
|
+
|
|
90
122
|
/**
|
|
91
123
|
* Auto-generate sidebar config by scanning .md files in the content directory.
|
|
92
124
|
*/
|
|
@@ -126,9 +158,9 @@ function scanDirectory(dir: string, base: string): DocsConfigItem[] {
|
|
|
126
158
|
try {
|
|
127
159
|
const entries = readdirSync(dir, { withFileTypes: true })
|
|
128
160
|
for (const entry of entries) {
|
|
129
|
-
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
161
|
+
if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.mdx'))) {
|
|
130
162
|
const relativePath = relative(base, join(dir, entry.name))
|
|
131
|
-
const slug = relativePath.replace(/\.
|
|
163
|
+
const slug = relativePath.replace(/\.mdx?$/, '')
|
|
132
164
|
// Read frontmatter for title
|
|
133
165
|
const filePath = join(dir, entry.name)
|
|
134
166
|
const raw = readFileSync(filePath, 'utf-8')
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rehype plugin that converts passed-through MDX JSX AST nodes into
|
|
3
|
+
* raw HTML strings so that rehype-raw can parse them into standard
|
|
4
|
+
* hast element nodes.
|
|
5
|
+
*
|
|
6
|
+
* This is needed because remark-mdx produces mdxJsxFlowElement /
|
|
7
|
+
* mdxJsxTextElement nodes which remark-rehype can pass through (via
|
|
8
|
+
* the `passThrough` option) but rehype-raw does not understand. By
|
|
9
|
+
* serialising them to raw HTML first, the existing pipeline
|
|
10
|
+
* (rehype-raw -> rehype-stringify) handles them naturally as HTML
|
|
11
|
+
* elements, which html-react-parser can then match to custom
|
|
12
|
+
* components.
|
|
13
|
+
*
|
|
14
|
+
* MDX expression nodes (mdxFlowExpression, mdxTextExpression) and
|
|
15
|
+
* ESM nodes (mdxjsEsm) are stripped silently — they have no
|
|
16
|
+
* meaningful HTML representation in a server-rendered pipeline.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { visit, SKIP } from 'unist-util-visit'
|
|
20
|
+
import type { Root } from 'hast'
|
|
21
|
+
|
|
22
|
+
interface MdxJsxAttribute {
|
|
23
|
+
type: 'mdxJsxAttribute'
|
|
24
|
+
name: string
|
|
25
|
+
value: string | { type: string; value: string } | null | undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface MdxJsxNode {
|
|
29
|
+
type: string
|
|
30
|
+
name: string | null
|
|
31
|
+
attributes?: MdxJsxAttribute[]
|
|
32
|
+
children?: any[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function serializeAttributes(attrs?: MdxJsxAttribute[]): string {
|
|
36
|
+
if (!attrs || attrs.length === 0) return ''
|
|
37
|
+
return attrs
|
|
38
|
+
.map((attr) => {
|
|
39
|
+
if (attr.type !== 'mdxJsxAttribute') return ''
|
|
40
|
+
const name = attr.name
|
|
41
|
+
if (attr.value == null) return ` ${name}`
|
|
42
|
+
const val =
|
|
43
|
+
typeof attr.value === 'string'
|
|
44
|
+
? attr.value
|
|
45
|
+
: typeof attr.value === 'object' && attr.value.value
|
|
46
|
+
? attr.value.value
|
|
47
|
+
: ''
|
|
48
|
+
return ` ${name}="${val.replace(/"/g, '"')}"`
|
|
49
|
+
})
|
|
50
|
+
.join('')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function serializeChildren(children: any[]): string {
|
|
54
|
+
return children
|
|
55
|
+
.map((child: any) => {
|
|
56
|
+
if (child.type === 'text') return child.value ?? ''
|
|
57
|
+
if (child.type === 'raw') return child.value ?? ''
|
|
58
|
+
if (
|
|
59
|
+
child.type === 'mdxJsxFlowElement' ||
|
|
60
|
+
child.type === 'mdxJsxTextElement'
|
|
61
|
+
) {
|
|
62
|
+
return serializeMdxJsx(child)
|
|
63
|
+
}
|
|
64
|
+
// For standard hast element nodes that ended up as children
|
|
65
|
+
if (child.type === 'element' && child.tagName) {
|
|
66
|
+
const attrs = serializeHastProps(child.properties)
|
|
67
|
+
const inner = child.children ? serializeChildren(child.children) : ''
|
|
68
|
+
return `<${child.tagName}${attrs}>${inner}</${child.tagName}>`
|
|
69
|
+
}
|
|
70
|
+
return ''
|
|
71
|
+
})
|
|
72
|
+
.join('')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function serializeHastProps(props?: Record<string, any>): string {
|
|
76
|
+
if (!props) return ''
|
|
77
|
+
return Object.entries(props)
|
|
78
|
+
.map(([key, val]) => {
|
|
79
|
+
if (val === true) return ` ${key}`
|
|
80
|
+
if (val === false || val == null) return ''
|
|
81
|
+
return ` ${key}="${String(val).replace(/"/g, '"')}"`
|
|
82
|
+
})
|
|
83
|
+
.join('')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function serializeMdxJsx(node: MdxJsxNode): string {
|
|
87
|
+
const tag = node.name
|
|
88
|
+
if (!tag) {
|
|
89
|
+
// Fragment — just render children
|
|
90
|
+
return node.children ? serializeChildren(node.children) : ''
|
|
91
|
+
}
|
|
92
|
+
const attrs = serializeAttributes(node.attributes as MdxJsxAttribute[])
|
|
93
|
+
const inner = node.children ? serializeChildren(node.children) : ''
|
|
94
|
+
if (!inner && (!node.children || node.children.length === 0)) {
|
|
95
|
+
return `<${tag}${attrs}></${tag}>`
|
|
96
|
+
}
|
|
97
|
+
return `<${tag}${attrs}>${inner}</${tag}>`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function rehypeMdxJsxToRaw() {
|
|
101
|
+
return (tree: Root) => {
|
|
102
|
+
visit(tree, (node: any, index, parent: any) => {
|
|
103
|
+
if (
|
|
104
|
+
node.type === 'mdxJsxFlowElement' ||
|
|
105
|
+
node.type === 'mdxJsxTextElement'
|
|
106
|
+
) {
|
|
107
|
+
const html = serializeMdxJsx(node as MdxJsxNode)
|
|
108
|
+
if (parent && typeof index === 'number') {
|
|
109
|
+
parent.children[index] = { type: 'raw', value: html }
|
|
110
|
+
}
|
|
111
|
+
return SKIP
|
|
112
|
+
}
|
|
113
|
+
// Strip expression / ESM nodes
|
|
114
|
+
if (
|
|
115
|
+
node.type === 'mdxFlowExpression' ||
|
|
116
|
+
node.type === 'mdxTextExpression' ||
|
|
117
|
+
node.type === 'mdxjsEsm'
|
|
118
|
+
) {
|
|
119
|
+
if (parent && typeof index === 'number') {
|
|
120
|
+
parent.children.splice(index, 1)
|
|
121
|
+
return [SKIP, index] as any
|
|
122
|
+
}
|
|
123
|
+
return SKIP
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { unified } from 'unified'
|
|
2
2
|
import remarkParse from 'remark-parse'
|
|
3
|
+
import remarkMdx from 'remark-mdx'
|
|
3
4
|
import remarkGfm from 'remark-gfm'
|
|
4
5
|
import remarkRehype from 'remark-rehype'
|
|
5
6
|
import rehypeCallouts from 'rehype-callouts'
|
|
@@ -10,6 +11,7 @@ import rehypeStringify from 'rehype-stringify'
|
|
|
10
11
|
|
|
11
12
|
import {
|
|
12
13
|
rehypeCollectHeadings,
|
|
14
|
+
rehypeMdxJsxToRaw,
|
|
13
15
|
rehypeParseCommentComponents,
|
|
14
16
|
rehypeTransformCommentComponents,
|
|
15
17
|
rehypeTransformFrameworkComponents,
|
|
@@ -29,9 +31,20 @@ export function renderMarkdown(content: string): MarkdownRenderResult {
|
|
|
29
31
|
|
|
30
32
|
const processor = unified()
|
|
31
33
|
.use(remarkParse)
|
|
34
|
+
.use(remarkMdx)
|
|
32
35
|
.use(remarkGfm)
|
|
33
|
-
.use(remarkRehype, {
|
|
36
|
+
.use(remarkRehype, {
|
|
37
|
+
allowDangerousHtml: true,
|
|
38
|
+
passThrough: [
|
|
39
|
+
'mdxjsEsm',
|
|
40
|
+
'mdxFlowExpression',
|
|
41
|
+
'mdxJsxFlowElement',
|
|
42
|
+
'mdxJsxTextElement',
|
|
43
|
+
'mdxTextExpression',
|
|
44
|
+
],
|
|
45
|
+
})
|
|
34
46
|
.use(extractCodeMeta)
|
|
47
|
+
.use(rehypeMdxJsxToRaw)
|
|
35
48
|
.use(rehypeRaw)
|
|
36
49
|
.use(rehypeParseCommentComponents)
|
|
37
50
|
.use(rehypeCallouts, {
|