@movk/nuxt-docs 1.13.0 → 1.14.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 (43) hide show
  1. package/app/app.config.ts +1 -1
  2. package/app/assets/css/main.css +16 -0
  3. package/app/assets/icons/LICENSE +14 -0
  4. package/app/assets/icons/ai.svg +1 -0
  5. package/app/components/OgImage/Nuxt.vue +2 -4
  6. package/app/components/content/CommitChangelog.vue +110 -28
  7. package/app/components/content/ComponentEmits.vue +1 -1
  8. package/app/components/content/ComponentExample.vue +98 -72
  9. package/app/components/content/ComponentProps.vue +3 -3
  10. package/app/components/content/ComponentPropsSchema.vue +1 -1
  11. package/app/components/content/ComponentSlots.vue +1 -1
  12. package/app/components/content/HighlightInlineType.vue +1 -1
  13. package/app/components/content/PageLastCommit.vue +6 -5
  14. package/app/components/header/HeaderLogo.vue +1 -1
  15. package/app/composables/cachedParseMarkdown.ts +12 -0
  16. package/app/composables/fetchComponentExample.ts +5 -22
  17. package/app/composables/fetchComponentMeta.ts +5 -22
  18. package/app/mdc.config.ts +12 -0
  19. package/app/pages/docs/[...slug].vue +8 -2
  20. package/app/templates/releases.vue +3 -1
  21. package/app/types/index.d.ts +1 -1
  22. package/app/utils/shiki-transformer-icon-highlight.ts +89 -0
  23. package/app/workers/prettier.js +26 -17
  24. package/modules/ai-chat/index.ts +1 -1
  25. package/modules/component-example.ts +65 -30
  26. package/modules/config.ts +24 -1
  27. package/modules/css.ts +1 -1
  28. package/nuxt.config.ts +40 -2
  29. package/nuxt.schema.ts +4 -4
  30. package/package.json +17 -17
  31. package/server/api/component-example.get.ts +5 -5
  32. package/server/api/github/{commits.get.ts → commits.json.get.ts} +7 -4
  33. package/server/api/github/{last-commit.get.ts → last-commit.json.get.ts} +12 -9
  34. package/server/api/github/releases.json.get.ts +28 -0
  35. package/server/mcp/resources/documentation-pages.ts +26 -0
  36. package/server/mcp/resources/examples.ts +17 -0
  37. package/server/mcp/tools/get-example.ts +1 -1
  38. package/server/mcp/tools/list-examples.ts +4 -8
  39. package/server/mcp/tools/list-getting-started-guides.ts +29 -0
  40. package/server/routes/raw/[...slug].md.get.ts +3 -5
  41. package/server/utils/stringifyMinimark.ts +345 -0
  42. package/server/utils/transformMDC.ts +14 -5
  43. package/utils/meta.ts +1 -1
package/app/app.config.ts CHANGED
@@ -96,7 +96,7 @@ export default defineAppConfig({
96
96
  },
97
97
  icons: {
98
98
  loading: 'i-lucide-loader',
99
- trigger: 'i-lucide-sparkles',
99
+ trigger: 'i-custom-ai',
100
100
  explain: 'i-lucide-brain',
101
101
  close: 'i-lucide-x',
102
102
  clearChat: 'i-lucide-trash-2',
@@ -22,3 +22,19 @@
22
22
  :root {
23
23
  --ui-container: var(--container-8xl);
24
24
  }
25
+
26
+ /* Shiki icon highlight transformer styles */
27
+ .shiki-icon-highlight {
28
+ display: inline-block;
29
+ width: 1.25em;
30
+ height: 1.25em;
31
+ vertical-align: -0.25em;
32
+ margin-right: 0.125em;
33
+ background-color: var(--ui-text-highlighted);
34
+ -webkit-mask-repeat: no-repeat;
35
+ mask-repeat: no-repeat;
36
+ -webkit-mask-size: 100% 100%;
37
+ mask-size: 100% 100%;
38
+ -webkit-mask-image: var(--shiki-icon-url);
39
+ mask-image: var(--shiki-icon-url);
40
+ }
@@ -0,0 +1,14 @@
1
+ Copyright © Nucleo
2
+
3
+ Version 1.3, January 3, 2024
4
+
5
+ Nucleo Icons
6
+
7
+ https://nucleoapp.com/
8
+
9
+ - Redistribution of icons is prohibited.
10
+ - Icons are restricted for use only within the product they are bundled with.
11
+
12
+ For more details:
13
+
14
+ https://nucleoapp.com/license
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" id="artificial-intelligence" aria-hidden="true" viewBox="0 0 20 20"><title>artificial-intelligence</title><g fill="currentColor"><polyline fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" points="10.5 15 7 5 6 5 2.5 15"/><line x1="9.586" x2="3.414" y1="12.5" y2="12.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/><polyline fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" points="13.5 9 15.5 9 15.5 15"/><line x1="13.5" x2="17.5" y1="15" y2="15" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/><path fill="currentColor" stroke-width="0" d="m17.6527,3.9647l-1.2005-.4503-.4506-1.2005c-.1562-.4185-.8468-.4185-1.0031,0l-.4506,1.2005-1.2005.4503c-.2086.0785-.3474.2783-.3474.5015s.1388.4231.3474.5015l1.2005.4503.4506,1.2005c.0781.2093.2783.3477.5015.3477s.4234-.1385.5015-.3477l.4506-1.2005,1.2005-.4503c.2086-.0785.3474-.2783.3474-.5015s-.1388-.4231-.3474-.5015h0Z"/></g></svg>
@@ -3,16 +3,14 @@
3
3
  * @credits NuxtLabs <https://nuxtlabs.com/>
4
4
  * @see https://github.com/nuxt/nuxt.com/blob/main/components/OgImage/Docs.vue
5
5
  */
6
- import { computed } from 'vue'
7
-
8
6
  const {
9
7
  title = 'title',
10
8
  description = 'description',
11
9
  headline = 'headline'
12
10
  } = defineProps<{ title?: string, description?: string, headline?: string }>()
13
11
 
14
- const computedTitle = computed(() => (title || '').slice(0, 60))
15
- const computedDescription = computed(() => (description || '').slice(0, 200))
12
+ const computedTitle = (title || '').slice(0, 60)
13
+ const computedDescription = (description || '').slice(0, 200)
16
14
  </script>
17
15
 
18
16
  <template>
@@ -1,11 +1,27 @@
1
1
  <script setup lang="ts">
2
- import { camelCase, kebabCase, upperFirst } from '@movk/core'
2
+ import { camelCase, kebabCase, upperFirst } from 'scule'
3
3
 
4
4
  interface Commit {
5
5
  sha: string
6
+ date: string
6
7
  message: string
7
8
  }
8
9
 
10
+ interface Release {
11
+ tag_name: string
12
+ published_at: string
13
+ html_url: string
14
+ }
15
+
16
+ interface ReleaseGroup {
17
+ tag: string
18
+ url?: string
19
+ icon?: string
20
+ title: string
21
+ commits: Commit[]
22
+ published_at?: string
23
+ }
24
+
9
25
  const props = defineProps<{
10
26
  /**
11
27
  * 仓库中的文件路径
@@ -45,7 +61,6 @@ const SHA_SHORT_LENGTH = 5
45
61
  const { github } = useAppConfig()
46
62
  const route = useRoute()
47
63
 
48
- // 计算文件路径相关的值
49
64
  const routeName = computed(() => route.path.split('/').pop() ?? '')
50
65
  const githubUrl = computed(() => (github && typeof github === 'object' ? github.url : ''))
51
66
 
@@ -55,7 +70,6 @@ const filePath = computed(() => {
55
70
  const fileExtension = props.suffix ?? (github && typeof github === 'object' ? github.suffix : 'vue')
56
71
  const fileName = props.name ?? routeName.value
57
72
 
58
- // 根据 casing 参数转换文件名
59
73
  const transformedName = (() => {
60
74
  const casing = props.casing ?? (github && typeof github === 'object' ? github.casing : undefined) ?? 'auto'
61
75
 
@@ -77,43 +91,111 @@ const filePath = computed(() => {
77
91
  return `${basePath}/${filePrefix}${transformedName}.${fileExtension}`
78
92
  })
79
93
 
80
- const { data: commits } = await useLazyFetch<Commit[]>('/api/github/commits', {
94
+ const { data: commits } = useLazyFetch<Commit[]>('/api/github/commits.json', {
81
95
  key: `commit-changelog-${props.name ?? routeName.value}-${props.author ?? 'all'}`,
82
- query: { path: [filePath.value], author: props.author }
96
+ query: { path: [filePath.value], author: props.author },
97
+ server: false,
98
+ getCachedData: (key, nuxtApp) => nuxtApp.payload.data[key]
83
99
  })
84
100
 
85
- // 格式化提交消息
86
- const formattedCommits = computed(() => {
101
+ const { data: releases } = useLazyFetch<Release[]>('/api/github/releases.json', {
102
+ server: false,
103
+ getCachedData: (key, nuxtApp) => nuxtApp.payload.data[key]
104
+ })
105
+
106
+ const groupedByRelease = computed<ReleaseGroup[]>(() => {
87
107
  if (!commits.value?.length) return []
88
108
 
89
- return commits.value.map((commit) => {
90
- const shortSha = commit.sha.slice(0, SHA_SHORT_LENGTH)
91
- const commitLink = `[\`${shortSha}\`](${githubUrl.value}/commit/${commit.sha})`
109
+ const sortedReleases = (releases.value ?? [])
110
+ .filter(r => r.published_at)
111
+ .sort((a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime())
112
+
113
+ const releasesOldestFirst = [...sortedReleases].reverse()
114
+ const groups: ReleaseGroup[] = []
115
+ const unreleased: Commit[] = []
116
+
117
+ for (const commit of commits.value) {
118
+ const commitDate = new Date(commit.date).getTime()
119
+ const release = releasesOldestFirst.find(r => new Date(r.published_at).getTime() >= commitDate)
120
+
121
+ if (release) {
122
+ const majorTag = release.tag_name.replace(/-(alpha|beta|rc)\.\d+$/, '')
123
+ let group = groups.find(g => g.tag === majorTag)
124
+ if (!group) {
125
+ group = { tag: majorTag, title: majorTag, icon: 'i-lucide-tag', published_at: release.published_at, url: release.html_url, commits: [] }
126
+ groups.push(group)
127
+ }
128
+ if (new Date(release.published_at) > new Date(group.published_at!)) {
129
+ group.published_at = release.published_at
130
+ group.url = release.html_url
131
+ }
132
+ group.commits.push(commit)
133
+ } else {
134
+ unreleased.push(commit)
135
+ }
136
+ }
137
+
138
+ const result: ReleaseGroup[] = []
139
+ if (unreleased.length) {
140
+ result.push({ tag: 'unreleased', title: 'Soon', icon: 'i-lucide-tag', commits: unreleased })
141
+ }
92
142
 
93
- const content = commit.message
94
- .replace(/\(.*?\)/, '')
95
- .replace(/#(\d+)/g, `<a href='${githubUrl.value}/issues/$1'>#$1</a>`)
96
- .replace(/`(.*?)`/g, '<code class="text-xs">$1</code>')
143
+ const uniqueTags = [...new Set(sortedReleases.map(r => r.tag_name.replace(/-(alpha|beta|rc)\.\d+$/, '')))]
144
+ groups.sort((a, b) => uniqueTags.indexOf(a.tag) - uniqueTags.indexOf(b.tag))
145
+ result.push(...groups)
97
146
 
98
- return {
99
- sha: commit.sha,
100
- formatted: `${commitLink} — ${content}`
101
- }
102
- })
147
+ return result
103
148
  })
149
+
150
+ function normalizeCommitMessage(commit: Commit) {
151
+ const prefix = `[\`${commit.sha.slice(0, SHA_SHORT_LENGTH)}\`](${githubUrl.value}/commit/${commit.sha})`
152
+ const content = commit.message
153
+ .replace(/#(\d+)/g, `<a href='${githubUrl.value}/issues/$1'>#$1</a>`)
154
+ .replace(/`(.*?)`/g, '<code class="text-xs">$1</code>')
155
+
156
+ return `${prefix} — ${content}`
157
+ }
104
158
  </script>
105
159
 
106
160
  <template>
107
- <div v-if="!formattedCommits.length">
161
+ <div v-if="!commits?.length">
108
162
  No recent changes
109
163
  </div>
110
164
 
111
- <div v-else class="flex flex-col gap-1.5 relative">
112
- <div class="bg-accented w-px h-full absolute left-[11px] z-[-1]" />
113
-
114
- <div v-for="commit of formattedCommits" :key="commit.sha" class="flex gap-1.5 items-center">
115
- <div class="bg-accented ring-2 ring-bg size-1.5 mx-[8.5px] rounded-full" />
116
- <MDC :value="commit.formatted" class="text-sm *:py-0 *:my-0 [&_code]:text-xs" tag="div" />
117
- </div>
118
- </div>
165
+ <UTimeline
166
+ v-else
167
+ :items="groupedByRelease"
168
+ size="xs"
169
+ :ui="{ root: '', wrapper: 'mt-0 pb-0', title: 'mb-1.5 flex items-center justify-between' }"
170
+ >
171
+ <template #title="{ item }">
172
+ <UBadge
173
+ v-if="item.tag === 'unreleased'"
174
+ color="neutral"
175
+ variant="subtle"
176
+ :label="item.title"
177
+ class="w-12.5 justify-center"
178
+ />
179
+ <NuxtLink
180
+ v-else
181
+ :to="item.url"
182
+ target="_blank"
183
+ class="hover:underline"
184
+ >
185
+ <UBadge variant="subtle" :label="item.tag" />
186
+ </NuxtLink>
187
+
188
+ <time v-if="item.published_at" :datetime="item.published_at" class="text-xs text-dimmed font-normal">
189
+ {{ useTimeAgo(new Date(item.published_at)) }}
190
+ </time>
191
+ </template>
192
+
193
+ <template #description="{ item }">
194
+ <ul class="flex flex-col gap-1.5">
195
+ <li v-for="commit of item.commits" :key="commit.sha">
196
+ <MDC :value="normalizeCommitMessage(commit)" class="text-sm [&_code]:text-xs" unwrap="p" />
197
+ </li>
198
+ </ul>
199
+ </template>
200
+ </UTimeline>
119
201
  </template>
@@ -12,7 +12,7 @@ const props = defineProps<{
12
12
  const route = useRoute()
13
13
  const componentName = camelCase(props.slug ?? route.path.split('/').pop() ?? '')
14
14
 
15
- const meta = await fetchComponentMeta(componentName as any)
15
+ const { data: meta } = await useFetchComponentMeta(componentName as any)
16
16
  </script>
17
17
 
18
18
  <template>
@@ -1,11 +1,11 @@
1
1
  <script setup lang="ts">
2
2
  import type { ChipProps } from '@nuxt/ui'
3
- import { camelCase } from 'scule'
3
+ import { camelCase, upperFirst } from 'scule'
4
4
  import { hash } from 'ohash'
5
5
  import { useElementSize } from '@vueuse/core'
6
6
  import { get, set } from '#ui/utils'
7
7
 
8
- const { preview = true, source = true, prettier = false, ...props } = defineProps<{
8
+ const props = withDefaults(defineProps<{
9
9
  name: string
10
10
  class?: any
11
11
  /**
@@ -50,6 +50,7 @@ const { preview = true, source = true, prettier = false, ...props } = defineProp
50
50
  * 链接到组件的可变属性列表
51
51
  */
52
52
  options?: Array<{
53
+ type?: string
53
54
  alias?: string
54
55
  name: string
55
56
  label: string
@@ -65,7 +66,21 @@ const { preview = true, source = true, prettier = false, ...props } = defineProp
65
66
  * 是否在包装器上添加 overflow-hidden
66
67
  */
67
68
  overflowHidden?: boolean
68
- }>()
69
+ /**
70
+ * 是否添加 background-elevated 到 wrapper
71
+ */
72
+ elevated?: boolean
73
+ lang?: string
74
+ /**
75
+ * 覆盖用于代码块的文件名
76
+ */
77
+ filename?: string
78
+ }>(), {
79
+ preview: true,
80
+ source: true,
81
+ prettier: false,
82
+ lang: 'vue'
83
+ })
69
84
 
70
85
  const slots = defineSlots<{
71
86
  options(props?: {}): any
@@ -79,7 +94,11 @@ const { width } = useElementSize(el)
79
94
 
80
95
  const camelName = camelCase(props.name)
81
96
 
82
- const data = await fetchComponentExample(camelName)
97
+ const exampleModules = import.meta.glob('~/components/content/examples/**/*.vue')
98
+ const exampleMatch = Object.entries(exampleModules).find(([path]) => path.endsWith(`/${upperFirst(camelName)}.vue`))
99
+ const resolvedComponent = exampleMatch ? defineAsyncComponent(exampleMatch[1] as any) : undefined
100
+
101
+ const { data } = await useFetchComponentExample(camelName)
83
102
 
84
103
  const componentProps = reactive({ ...(props.props || {}) })
85
104
 
@@ -87,7 +106,6 @@ const code = computed(() => {
87
106
  let code = ''
88
107
 
89
108
  if (props.collapse) {
90
- // 构建 code-collapse 的属性
91
109
  const collapseAttrs = typeof props.collapse === 'object'
92
110
  ? Object.entries(props.collapse)
93
111
  .map(([key, value]) => {
@@ -104,8 +122,8 @@ const code = computed(() => {
104
122
  `
105
123
  }
106
124
 
107
- code += `\`\`\`vue ${preview ? '' : ` [${data.pascalName}.vue]`}${props.highlights?.length ? `{${props.highlights.join('-')}}` : ''}
108
- ${data?.code ?? ''}
125
+ code += `\`\`\`${props.lang} ${props.preview ? '' : ` [${props.filename ?? data.value?.pascalName}.${props.lang}]`}${props.highlights?.length ? `{${props.highlights.join('-')}}` : ''}
126
+ ${data.value?.code ?? ''}
109
127
  \`\`\``
110
128
 
111
129
  if (props.collapse) {
@@ -116,9 +134,9 @@ ${data?.code ?? ''}
116
134
  return code
117
135
  })
118
136
 
119
- const { data: ast } = await useAsyncData(`component-example-${camelName}${hash({ props: componentProps, collapse: props.collapse })}`, async () => {
120
- if (!prettier) {
121
- return parseMarkdown(code.value)
137
+ const { data: ast } = useAsyncData(`component-example-${camelName}${hash({ props: componentProps, collapse: props.collapse })}`, async () => {
138
+ if (!props.prettier) {
139
+ return cachedParseMarkdown(code.value)
122
140
  }
123
141
 
124
142
  let formatted = ''
@@ -133,8 +151,8 @@ const { data: ast } = await useAsyncData(`component-example-${camelName}${hash({
133
151
  formatted = code.value
134
152
  }
135
153
 
136
- return parseMarkdown(formatted)
137
- }, { watch: [code] })
154
+ return cachedParseMarkdown(formatted)
155
+ }, { lazy: import.meta.client, watch: [code] })
138
156
 
139
157
  const optionsValues = ref(props.options?.reduce((acc, option) => {
140
158
  if (option.name) {
@@ -167,72 +185,80 @@ const urlSearchParams = computed(() => {
167
185
  <template>
168
186
  <div ref="el" class="my-5" :style="{ '--ui-header-height': '4rem' }">
169
187
  <template v-if="preview">
170
- <div class="border border-muted relative z-1" :class="{ 'border-b-0 rounded-t-md': source, 'rounded-md': !source, 'overflow-hidden': props.overflowHidden }">
171
- <div v-if="props.options?.length || !!slots.options" class="flex gap-4 p-4 border-b border-muted">
172
- <slot name="options" />
173
-
174
- <UFormField
175
- v-for="option in props.options"
176
- :key="option.name"
177
- :label="option.label"
178
- :name="option.name"
179
- size="sm"
180
- class="inline-flex ring ring-accented rounded-sm"
181
- :ui="{
182
- wrapper: 'bg-elevated/50 rounded-l-sm flex border-r border-accented',
183
- label: 'text-muted px-2 py-1.5',
184
- container: 'mt-0'
185
- }"
186
- >
187
- <USelectMenu
188
- v-if="option.items?.length"
189
- :model-value="get(optionsValues, option.name)"
190
- :items="option.items"
191
- :search-input="false"
192
- :value-key="option.name.toLowerCase().endsWith('color') ? 'value' : undefined"
193
- color="neutral"
194
- variant="soft"
195
- class="rounded-sm rounded-l-none min-w-12"
196
- :multiple="option.multiple"
197
- :class="{ 'pl-6': option.name.toLowerCase().endsWith('color') }"
198
- :ui="{ itemLeadingChip: 'w-2' }"
199
- @update:model-value="set(optionsValues, option.name, $event)"
188
+ <div ref="wrapperContainer" class="relative group/component">
189
+ <div class="border border-muted relative z-1" :class="[{ 'border-b-0 rounded-t-md': props.source, 'rounded-md': !props.source, 'overflow-hidden': props.overflowHidden }]">
190
+ <div v-if="props.options?.length || !!slots.options" class="flex gap-4 p-4 border-b border-muted">
191
+ <slot name="options" />
192
+
193
+ <UFormField
194
+ v-for="option in props.options"
195
+ :key="option.name"
196
+ :label="option.label"
197
+ :name="option.name"
198
+ size="sm"
199
+ class="inline-flex ring ring-accented rounded-sm"
200
+ :ui="{
201
+ wrapper: 'bg-elevated/50 rounded-l-sm flex border-r border-accented',
202
+ label: 'text-muted px-2 py-1.5',
203
+ container: 'mt-0'
204
+ }"
200
205
  >
201
- <template v-if="option.name.toLowerCase().endsWith('color')" #leading="{ modelValue, ui }">
202
- <UChip
203
- inset
204
- standalone
205
- :color="(modelValue as any)"
206
- :size="(ui.itemLeadingChipSize() as ChipProps['size'])"
207
- class="size-2"
208
- />
209
- </template>
210
- </USelectMenu>
211
- <UInput
212
- v-else
213
- :model-value="get(optionsValues, option.name)"
214
- color="neutral"
215
- variant="soft"
216
- :ui="{ base: 'rounded-sm rounded-l-none min-w-12' }"
217
- @update:model-value="set(optionsValues, option.name, $event)"
218
- />
219
- </UFormField>
220
- </div>
206
+ <USelectMenu
207
+ v-if="option.items?.length"
208
+ :model-value="get(optionsValues, option.name)"
209
+ :items="option.items"
210
+ :search-input="false"
211
+ :value-key="option.name.toLowerCase().endsWith('color') ? 'value' : undefined"
212
+ color="neutral"
213
+ variant="soft"
214
+ class="rounded-sm rounded-l-none min-w-12"
215
+ :multiple="option.multiple"
216
+ :class="[option.name.toLowerCase().endsWith('color') && 'pl-6']"
217
+ :ui="{ itemLeadingChip: 'w-2' }"
218
+ @update:model-value="set(optionsValues, option.name, $event)"
219
+ >
220
+ <template v-if="option.name.toLowerCase().endsWith('color')" #leading="{ modelValue, ui }">
221
+ <UChip
222
+ inset
223
+ standalone
224
+ :color="(modelValue as any)"
225
+ :size="(ui.itemLeadingChipSize() as ChipProps['size'])"
226
+ class="size-2"
227
+ />
228
+ </template>
229
+ </USelectMenu>
230
+ <UInput
231
+ v-else
232
+ :model-value="get(optionsValues, option.name)"
233
+ :type="option.type"
234
+ color="neutral"
235
+ variant="soft"
236
+ :ui="{ base: 'rounded-sm rounded-l-none min-w-12' }"
237
+ @update:model-value="set(optionsValues, option.name, $event)"
238
+ />
239
+ </UFormField>
240
+ </div>
221
241
 
222
- <iframe
223
- v-if="iframe"
224
- v-bind="typeof iframe === 'object' ? iframe : {}"
225
- :src="`/examples/${name}?${urlSearchParams}`"
226
- class="relative w-full"
227
- :class="[props.class, { 'lg:left-1/2 lg:-translate-x-1/2 lg:w-[1024px]': !iframeMobile }]"
228
- />
229
- <div v-else class="flex justify-center p-4" :class="props.class">
230
- <component :is="camelName" v-bind="{ ...componentProps, ...optionsValues }" />
242
+ <iframe
243
+ v-if="iframe"
244
+ v-bind="typeof iframe === 'object' ? iframe : {}"
245
+ :src="`/examples/${name}?${urlSearchParams}`"
246
+ class="relative w-full"
247
+ :class="[props.class, { 'dark:bg-neutral-950/50 rounded-t-md': props.elevated }, !iframeMobile && 'lg:left-1/2 lg:-translate-x-1/2 lg:w-[1024px]']"
248
+ />
249
+ <div
250
+ v-else-if="resolvedComponent"
251
+ ref="componentContainer"
252
+ class="flex justify-center p-4"
253
+ :class="[props.class, { 'dark:bg-neutral-950/50 rounded-t-md': props.elevated }]"
254
+ >
255
+ <component :is="resolvedComponent" v-bind="{ ...componentProps, ...optionsValues }" />
256
+ </div>
231
257
  </div>
232
258
  </div>
233
259
  </template>
234
260
 
235
- <template v-if="source">
261
+ <template v-if="props.source">
236
262
  <div v-if="!!slots.code" class="[&_pre]:rounded-t-none! [&_div.my-5]:mt-0!">
237
263
  <slot name="code" />
238
264
  </div>
@@ -45,14 +45,14 @@ const { ignore = [
45
45
  const route = useRoute()
46
46
  const camelName = camelCase(slug ?? route.path.split('/').pop() ?? '')
47
47
  const componentName = prose ? `Prose${upperFirst(camelName)}` : `${upperFirst(camelName)}`
48
- const meta = await fetchComponentMeta(componentName as any)
48
+ const { data: meta } = await useFetchComponentMeta(componentName as any)
49
49
 
50
50
  const metaProps: ComputedRef<ComponentMeta['props']> = computed(() => {
51
- if (!meta?.meta?.props?.length) {
51
+ if (!meta.value?.meta?.props?.length) {
52
52
  return []
53
53
  }
54
54
 
55
- return meta.meta.props.filter((prop) => {
55
+ return meta.value.meta.props.filter((prop) => {
56
56
  return !ignore?.includes(prop.name)
57
57
  }).map((prop) => {
58
58
  if (prop.default) {
@@ -48,7 +48,7 @@ const schemaProps = computed(() => {
48
48
  </script>
49
49
 
50
50
  <template>
51
- <ProseCollapsible v-if="schemaProps?.length" class="mt-1 mb-0">
51
+ <ProseCollapsible v-if="schemaProps?.length" :unmount-on-hide="true" class="mt-1 mb-0">
52
52
  <ProseUl>
53
53
  <ProseLi v-for="schemaProp in schemaProps" :key="schemaProp.name">
54
54
  <HighlightInlineType :type="`${schemaProp.name}${schemaProp.required === false ? '?' : ''}: ${schemaProp.type}`" />
@@ -12,7 +12,7 @@ const props = defineProps<{
12
12
  const route = useRoute()
13
13
 
14
14
  const componentName = camelCase(props.slug ?? route.path.split('/').pop() ?? '')
15
- const meta = await fetchComponentMeta(componentName as any)
15
+ const { data: meta } = await useFetchComponentMeta(componentName as any)
16
16
  </script>
17
17
 
18
18
  <template>
@@ -24,7 +24,7 @@ const type = computed(() => {
24
24
  const ast = ref<any>(null)
25
25
 
26
26
  onMounted(async () => {
27
- ast.value = await parseMarkdown(`\`\` ${type.value} \`\`{lang="ts-type"}`)
27
+ ast.value = await cachedParseMarkdown(`\`\` ${type.value} \`\`{lang="ts-type"}`)
28
28
  })
29
29
  </script>
30
30
 
@@ -49,12 +49,12 @@ const filePath = computed(() => {
49
49
  return [rootDir, 'content', `${stem}.${extension}`].filter(Boolean).join('/')
50
50
  })
51
51
 
52
- const { data: commit } = await useFetch<LastCommit | null>('/api/github/last-commit', {
52
+ const { data: commit } = useLazyFetch<LastCommit | null>('/api/github/last-commit.json', {
53
53
  key: `last-commit-${filePath.value}`,
54
54
  query: { path: filePath.value },
55
55
  default: () => null,
56
- lazy: true,
57
- server: false
56
+ server: false,
57
+ getCachedData: (key, nuxtApp) => nuxtApp.payload.data[key]
58
58
  })
59
59
 
60
60
  const commitUrl = computed(() => commit.value?.url ?? '')
@@ -81,6 +81,7 @@ const authorUrl = computed(() => {
81
81
  :src="commit.author.avatar"
82
82
  alt="Author Avatar"
83
83
  size="2xs"
84
+ loading="lazy"
84
85
  />
85
86
  <UBadge color="neutral" variant="outline" size="sm">
86
87
  {{ commit.author.name || commit.author.login }}
@@ -103,7 +104,7 @@ const authorUrl = computed(() => {
103
104
  v-if="commitUrl"
104
105
  :to="commitUrl"
105
106
  target="_blank"
106
- class="hover:opacity-80 transition-opacity max-w-[250px]"
107
+ class="hover:opacity-80 transition-opacity max-w-62.5"
107
108
  >
108
109
  <UBadge
109
110
  color="neutral"
@@ -119,7 +120,7 @@ const authorUrl = computed(() => {
119
120
  color="neutral"
120
121
  variant="outline"
121
122
  size="sm"
122
- class="max-w-[250px] font-mono text-xs"
123
+ class="max-w-62.5 font-mono text-xs"
123
124
  >
124
125
  <span class="truncate">{{ commit.message }}</span>
125
126
  </UBadge>
@@ -4,6 +4,6 @@ const { header } = useAppConfig()
4
4
 
5
5
  <template>
6
6
  <NuxtLink :to="header.to">
7
- <UUser :avatar="{ src: header.avatar, alt: 'Site Logo' }" :name="header.title" />
7
+ <UUser :avatar="{ src: header.avatar, alt: 'Site Logo', loading: 'lazy' }" :name="header.title" />
8
8
  </NuxtLink>
9
9
  </template>
@@ -0,0 +1,12 @@
1
+ import { markRaw } from 'vue'
2
+
3
+ const _cache = new Map<string, any>()
4
+
5
+ export async function cachedParseMarkdown(markdown: string) {
6
+ const cached = _cache.get(markdown)
7
+ if (cached) return cached
8
+
9
+ const result = markRaw(await parseMarkdown(markdown))
10
+ _cache.set(markdown, result)
11
+ return result
12
+ }
@@ -1,17 +1,4 @@
1
- const useComponentExampleState = () => useState<Record<string, any>>('component-example-state', () => ({}))
2
-
3
- export async function fetchComponentExample(name: string) {
4
- const state = useComponentExampleState()
5
-
6
- if (state.value[name]?.then) {
7
- await state.value[name]
8
- return state.value[name]
9
- }
10
- if (state.value[name]) {
11
- return state.value[name]
12
- }
13
-
14
- // Add to nitro prerender
1
+ export function useFetchComponentExample(name: string) {
15
2
  if (import.meta.server) {
16
3
  const event = useRequestEvent()
17
4
  event?.node.res.setHeader(
@@ -20,13 +7,9 @@ export async function fetchComponentExample(name: string) {
20
7
  )
21
8
  }
22
9
 
23
- // Store promise to avoid multiple calls
24
- state.value[name] = $fetch(`/api/component-example/${name}.json`).then((data) => {
25
- state.value[name] = data
26
- }).catch(() => {
27
- state.value[name] = {}
10
+ return useAsyncData(`component-example-${name}`, () => $fetch(`/api/component-example/${name}.json`).catch(() => ({})), {
11
+ lazy: import.meta.client,
12
+ dedupe: 'defer',
13
+ getCachedData: (key, nuxtApp) => nuxtApp.payload.data[key]
28
14
  })
29
-
30
- await state.value[name]
31
- return state.value[name]
32
15
  }