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.
Files changed (35) hide show
  1. package/admin/dist/server/assets/chunk-CNvmzFzq.js +35 -0
  2. package/admin/dist/server/assets/{init-AJSQ7K_l.js → init-DJr2glb3.js} +5 -38
  3. package/admin/dist/server/assets/{jobs-CwDb0Zyp.js → jobs-FXffC7LH.js} +2 -2
  4. package/admin/dist/server/assets/{misc-CqYhnW23.js → misc-y6t3-UOP.js} +3 -3
  5. package/admin/dist/server/assets/{models-D9Sd95EX.js → models-YNa3F3nn.js} +1 -1
  6. package/admin/dist/server/assets/react-dom-BryASgrS.js +2159 -0
  7. package/admin/dist/server/assets/redirect-BHRifpCK.js +51 -0
  8. package/admin/dist/server/assets/router-CAX08MEI.js +897 -0
  9. package/admin/dist/server/assets/routes-Bk6XCM2I.js +2139 -0
  10. package/admin/dist/server/assets/routes-CMOVc2RM.js +2132 -0
  11. package/admin/dist/server/assets/{status-D48jcwYI.js → status-CM7Azp4n.js} +2 -2
  12. package/admin/dist/server/server.js +15789 -4447
  13. package/admin/vite.config.ts +13 -0
  14. package/dist/cli.js +1 -1
  15. package/dist/{upload-XL6KG6S2.js → upload-KYKJVERO.js} +1 -1
  16. package/package.json +1 -1
  17. package/template/app/components/BlogArticle.tsx +3 -0
  18. package/template/app/components/Doc.tsx +4 -0
  19. package/template/app/components/markdown/MarkdownContent.tsx +6 -2
  20. package/template/app/site.config.ts +2 -0
  21. package/template/app/types/index.ts +4 -0
  22. package/template/app/utils/content-loader.ts +85 -32
  23. package/template/app/utils/docs.server.ts +38 -6
  24. package/template/app/utils/markdown/plugins/index.ts +1 -0
  25. package/template/app/utils/markdown/plugins/mdxJsxToRaw.ts +127 -0
  26. package/template/app/utils/markdown/processor.ts +14 -1
  27. package/template/app/utils/sidebar-generator.ts +185 -0
  28. package/template/app/utils/url-mapper.ts +22 -0
  29. package/template/package.json +2 -1
  30. package/admin/dist/server/assets/router-D00bP5CU.js +0 -67
  31. package/admin/dist/server/assets/routes-C2UFxDWZ.js +0 -24
  32. package/admin/dist/server/assets/routes-vEKXnl0r.js +0 -1574
  33. /package/admin/dist/server/assets/{_tanstack-start-manifest_v-sC90W3ET.js → _tanstack-start-manifest_v-mK4S3Lga.js} +0 -0
  34. /package/admin/dist/server/assets/{createServerRpc-CMjjCE8A.js → createServerRpc-C3JHS5ky.js} +0 -0
  35. /package/admin/dist/server/assets/{start-BrsoKfWS.js → start-3avuCbOL.js} +0 -0
@@ -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-XL6KG6S2.js");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docs-i18n",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
4
4
  "description": "Universal documentation translation engine — parse, translate, cache, assemble, manage.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 (
@@ -136,6 +136,8 @@ export const siteConfig: SiteConfig = {
136
136
  versionSelector: true,
137
137
  editOnGithub: true,
138
138
  },
139
+ /** Custom MDX components. Consumers override this to register their own JSX components. */
140
+ components: {},
139
141
  }
140
142
 
141
143
  /**
@@ -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(projectRoot: string): ContentLoader {
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
- * Returns the raw file content and resolved path, or null.
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 candidates = [
77
- resolve(projectRoot, 'content', project, version, lang, `${slug}.md`),
78
- resolve(projectRoot, 'content', project, lang, `${slug}.md`),
79
- resolve(projectRoot, 'content', lang, `${slug}.md`),
82
+ const baseDirs = [
83
+ resolve(projectRoot, 'content', project, version, lang),
84
+ resolve(projectRoot, 'content', project, lang),
85
+ resolve(projectRoot, 'content', lang),
80
86
  ]
81
- for (const filePath of candidates) {
82
- if (existsSync(filePath)) {
83
- return { raw: readFileSync(filePath, 'utf-8'), filePath }
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
- slugs.push(relative(base, join(dir, entry.name)).replace(/\.md$/, ''))
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
- const sourceFilePath = `${slug}.md`
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
- return {
245
- async loadDoc(project, version, lang, slug) {
246
- const path = `${project}/${version}/${lang}/${slug}.md`
247
-
248
- // Try requested language
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
- if (row) {
256
- const { data, content } = matter(row.body)
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 enPath = `${project}/${version}/en/${slug}.md`
269
- const enRow = await db
270
- .select()
271
- .from(schema.content)
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: enPath,
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(/\.md$/, ''))
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
- * Delegates to the active ContentLoader.
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 (config.json for sidebar structure).
77
- * Delegates to the active ContentLoader, with auto-scan fallback.
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(/\.md$/, '')
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')
@@ -6,3 +6,4 @@ export {
6
6
  } from './transformFrameworkComponent'
7
7
  export { transformTabsComponent } from './transformTabsComponent'
8
8
  export { type MarkdownHeading, rehypeCollectHeadings } from './collectHeadings'
9
+ export { rehypeMdxJsxToRaw } from './mdxJsxToRaw'
@@ -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, '&quot;')}"`
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, '&quot;')}"`
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, { allowDangerousHtml: true })
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, {