Skip to content

Commit ad31b65

Browse files
authoredMar 26, 2025··
feat(devtools): eject templates (#346)
1 parent f6bc1d0 commit ad31b65

File tree

9 files changed

+170
-11
lines changed

9 files changed

+170
-11
lines changed
 

‎client/app.vue

+28-4
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ import { ref } from 'vue'
1717
import { fetchGlobalDebug } from '~/composables/fetch'
1818
import { devtoolsClient } from '~/composables/rpc'
1919
import { loadShiki } from '~/composables/shiki'
20+
import { CreateOgImageDialogPromise } from '~/composables/templates'
2021
import { separateProps } from '../src/runtime/shared'
22+
import CreateOgImageDialog from './components/CreateOgImageDialog.vue'
23+
import { ogImageRpc } from './composables/rpc'
2124
import {
2225
description,
2326
hasMadeChanges,
@@ -165,7 +168,16 @@ function toggleSocialPreview(preview?: string) {
165168
}
166169
167170
const activeComponentName = computed(() => {
168-
return optionsOverrides.value?.component || options.value?.component || 'NuxtSeo'
171+
let componentName = optionsOverrides.value?.component || options.value?.component || 'NuxtSeo'
172+
for (const componentDirName of (globalDebug?.value?.runtimeConfig.componentDirs || [])) {
173+
componentName = componentName.replace(componentDirName, '')
174+
}
175+
return componentName
176+
})
177+
178+
const isOgImageTemplate = computed(() => {
179+
const component = globalDebug.value?.componentNames?.find(c => c.pascalName === activeComponentName.value)
180+
return component?.path.includes('node_modules') || component?.path.includes('og-image/src/runtime/app/components/Templates/Community/')
169181
})
170182
171183
const renderer = computed(() => {
@@ -261,10 +273,21 @@ const currentPageFile = computed(() => {
261273
// get the path only from the `pages/<path>`
262274
return `pages/${path?.split('pages/')[1]}`
263275
})
276+
277+
async function ejectComponent(component: string) {
278+
const dir = await CreateOgImageDialogPromise.start(component)
279+
if (!dir)
280+
return
281+
// do fix
282+
const v = await ogImageRpc.value!.ejectCommunityTemplate(`${dir}/${component}.vue`)
283+
// open
284+
await devtoolsClient.value?.devtools.rpc.openInEditor(v)
285+
}
264286
</script>
265287

266288
<template>
267289
<div class="relative n-bg-base flex flex-col">
290+
<CreateOgImageDialog />
268291
<header class="sticky top-0 z-2 px-4 pt-4">
269292
<div class="flex justify-between items-start" mb2>
270293
<div class="flex space-x-5">
@@ -468,9 +491,10 @@ const currentPageFile = computed(() => {
468491
<NButton v-if="!isPageScreenshot" icon="carbon:html" :border="imageFormat === 'html'" @click="patchOptions({ extension: 'html' })" />
469492
</div>
470493
<div class="text-xs">
471-
<div v-if="!isPageScreenshot" class="opacity-70 space-x-1 hover:opacity-90 transition cursor-pointer" @click="openCurrentComponent">
472-
<span>{{ activeComponentName.replace('OgImage', '') }}</span>
473-
<span class="underline">View source</span>
494+
<div v-if="!isPageScreenshot" class="opacity-70 space-x-1 hover:opacity-90 transition cursor-pointer">
495+
<span>{{ activeComponentName }}</span>
496+
<span v-if="isOgImageTemplate" class="underline" @click="ejectComponent(activeComponentName)">Eject Component</span>
497+
<span v-else class="underline" @click="openCurrentComponent">View Source</span>
474498
</div>
475499
<div v-else>
476500
Screenshot of the current page.
+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<script setup lang="ts">
2+
import {
3+
RadioGroup,
4+
RadioGroupLabel,
5+
RadioGroupOption,
6+
} from '@headlessui/vue'
7+
import { fetchGlobalDebug } from '~/composables/fetch'
8+
import { CreateOgImageDialogPromise } from '../composables/templates'
9+
10+
function handleClose(a: any, resolve: (value: boolean) => void) {
11+
resolve(false)
12+
}
13+
14+
const { data: globalDebug } = await fetchGlobalDebug()
15+
16+
const component = ref(globalDebug.value?.runtimeConfig?.componentDirs?.[0])
17+
</script>
18+
19+
<template>
20+
<CreateOgImageDialogPromise v-slot="{ resolve, args }">
21+
<div my-10>
22+
<NDialog :model-value="true" style="max-height: 80vh;" @update:model-value="handleClose('a', resolve)" @close="handleClose('b', resolve)">
23+
<div flex="~ col gap-2" w-200 p4 border="t base">
24+
<h2 text-xl class="text-primary">
25+
Eject Component
26+
</h2>
27+
28+
<p>Copy a community template to a OG Image component directory in your project. You can configure directories using <span class="opacity-50">componentDirs</span> in nuxt.config.</p>
29+
30+
<RadioGroup v-model="component">
31+
<div class="mb-3 mt-6 font-medium text-sm">
32+
<RadioGroupLabel>Choose Output Path</RadioGroupLabel>
33+
</div>
34+
<div class="space-y-2">
35+
<RadioGroupOption
36+
v-for="dir in globalDebug?.runtimeConfig?.componentDirs"
37+
:key="dir"
38+
v-slot="{ active, checked }"
39+
as="template"
40+
:value="dir"
41+
>
42+
<div
43+
:class="[
44+
active
45+
? 'ring-2 ring-white/60 ring-offset-2 ring-offset-sky-300'
46+
: '',
47+
checked ? 'bg-sky-700/75 text-white ' : 'bg-white ',
48+
]"
49+
class="relative flex cursor-pointer rounded-lg px-5 py-4 shadow-md focus:outline-none"
50+
>
51+
<div class="flex w-full items-center justify-between">
52+
<div class="flex items-center">
53+
<div class="text-sm">
54+
<RadioGroupLabel
55+
as="p"
56+
:class="checked ? 'text-white' : 'text-gray-900'"
57+
class="font-medium"
58+
>
59+
./components/{{ dir }}/{{ args[0] }}.vue
60+
</RadioGroupLabel>
61+
</div>
62+
</div>
63+
<div v-show="checked" class="shrink-0 text-white">
64+
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="none">
65+
<circle
66+
cx="12"
67+
cy="12"
68+
r="12"
69+
fill="#fff"
70+
fill-opacity="0.2"
71+
/>
72+
<path
73+
d="M7 13l3 3 7-7"
74+
stroke="#fff"
75+
stroke-width="1.5"
76+
stroke-linecap="round"
77+
stroke-linejoin="round"
78+
/>
79+
</svg>
80+
</div>
81+
</div>
82+
</div>
83+
</RadioGroupOption>
84+
</div>
85+
</RadioGroup>
86+
87+
<div flex="~ gap-3" mt2 justify-end>
88+
<NButton @click="resolve(false)">
89+
Cancel
90+
</NButton>
91+
<NButton n="solid" capitalize class="n-blue px-3 py-1.5 rounded" @click="resolve(component)">
92+
Create Component
93+
</NButton>
94+
</div>
95+
</div>
96+
</NDialog>
97+
</div>
98+
</CreateOgImageDialogPromise>
99+
</template>

‎client/components/TemplateComponentPreview.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const loadStats = ref<{ timeTaken: string, sizeKb: string }>()
3232

3333
<template>
3434
<div class="group">
35-
<div class="opacity-70 text-sm transition group-hover:opacity-100">
35+
<div class="opacity-70 flex px-3 justify-between text-sm transition group-hover:opacity-100">
3636
<NLink :href="creditSite" external class="underline">
3737
{{ component.pascalName }}
3838
</NLink>

‎client/composables/fetch.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export function fetchPathDebug() {
4242
}
4343

4444
export function fetchGlobalDebug() {
45-
return useAsyncData<{ runtimeConfig: OgImageRuntimeConfig, componentNames: OgImageComponent[] }>(() => {
45+
return useAsyncData<{ runtimeConfig: OgImageRuntimeConfig, componentNames: OgImageComponent[] }>('global-debug', () => {
4646
if (!appFetch.value)
4747
return { runtimeConfig: {} }
4848
return appFetch.value('/__og-image__/debug.json')

‎client/composables/rpc.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { NuxtDevtoolsClient, NuxtDevtoolsIframeClient } from '@nuxt/devtools-kit/types'
2+
import type { BirpcReturn } from 'birpc'
23
import type { $Fetch } from 'nitropack'
34
import type { ClientFunctions, ServerFunctions } from '../../src/rpc-types'
45
import { onDevtoolsClientConnected } from '@nuxt/devtools-kit/iframe-client'
@@ -13,6 +14,8 @@ export const devtoolsClient = ref<NuxtDevtoolsIframeClient>()
1314

1415
export const colorMode = ref<'dark' | 'light'>('dark')
1516

17+
export const ogImageRpc = ref<BirpcReturn<ServerFunctions>>()
18+
1619
onDevtoolsClientConnected(async (client) => {
1720
appFetch.value = client.host.app.$fetch
1821
watchEffect(() => {
@@ -28,7 +31,7 @@ onDevtoolsClientConnected(async (client) => {
2831
})
2932
devtools.value = client.devtools
3033
devtoolsClient.value = client
31-
client.devtools.extendClientRpc<ServerFunctions, ClientFunctions>('nuxt-og-image', {
34+
ogImageRpc.value = client.devtools.extendClientRpc<ServerFunctions, ClientFunctions>('nuxt-og-image', {
3235
refreshRouteData(path) {
3336
// if path matches
3437
if (devtoolsClient.value?.host.nuxt.vueApp.config?.globalProperties?.$route.matched[0].components?.default.__file.includes(path) || path.endsWith('.md'))

‎client/composables/templates.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { createTemplatePromise } from '@vueuse/core'
2+
3+
export const CreateOgImageDialogPromise = createTemplatePromise<boolean, [any]>()

‎src/build/devtools.ts

+27-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import type { Resolver } from '@nuxt/kit'
2+
import type { Nitro } from 'nitropack'
23
import type { Nuxt } from 'nuxt/schema'
34
import type { ModuleOptions } from '../module'
45
import type { ClientFunctions, ServerFunctions } from '../rpc-types'
5-
import { existsSync } from 'node:fs'
6+
import { existsSync, mkdirSync } from 'node:fs'
7+
import { readFile, writeFile } from 'node:fs/promises'
68
import { relative } from 'node:path'
79
import { extendServerRpc, onDevToolsInitialized } from '@nuxt/devtools-kit'
8-
import { useNuxt } from '@nuxt/kit'
10+
import { updateTemplates, useNuxt } from '@nuxt/kit'
911

1012
const DEVTOOLS_UI_ROUTE = '/__nuxt-og-image'
1113
const DEVTOOLS_UI_LOCAL_PORT = 3030
@@ -38,9 +40,31 @@ export function setupDevToolsUI(options: ModuleOptions, resolve: Resolver['resol
3840
})
3941
}
4042

43+
const useNitro = new Promise<Nitro>((resolve) => {
44+
nuxt.hooks.hook('nitro:init', resolve)
45+
})
46+
4147
// wait for DevTools to be initialized
4248
onDevToolsInitialized(async () => {
43-
const rpc = extendServerRpc<ClientFunctions, ServerFunctions>('nuxt-og-image', {})
49+
const rpc = extendServerRpc<ClientFunctions, ServerFunctions>('nuxt-og-image', {
50+
async ejectCommunityTemplate(path: string) {
51+
const [dirName, componentName] = path.split('/')
52+
const dir = resolve(nuxt.options.rootDir, 'components', dirName)
53+
if (!existsSync(dir)) {
54+
mkdirSync(dir)
55+
}
56+
const newPath = resolve(dir, componentName)
57+
const templatePath = resolve(`./runtime/app/components/Templates/Community/${componentName}`)
58+
// readFile, we need to modify it
59+
const template = (await readFile(templatePath, 'utf-8')).replace('{{ title }}', `{{ title }} - Ejected!`)
60+
// copy the file over
61+
await writeFile(newPath, template, { encoding: 'utf-8' })
62+
await updateTemplates({ filter: t => t.filename.includes('nuxt-og-image/components.mjs') })
63+
const nitro = await useNitro
64+
await nitro.hooks.callHook('rollup:reload')
65+
return newPath
66+
},
67+
})
4468

4569
nuxt.hook('builder:watch', (e, path) => {
4670
path = relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, path))

‎src/module.ts

+4
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,7 @@ export default defineNuxtModule<ModuleOptions>({
542542
},
543543
options: { mode: 'server' },
544544
})
545+
545546
nuxt.options.nitro.virtual = nuxt.options.nitro.virtual || {}
546547
nuxt.options.nitro.virtual['#og-image-virtual/component-names.mjs'] = () => {
547548
return `export const componentNames = ${JSON.stringify(ogImageComponentCtx.components)}`
@@ -650,6 +651,9 @@ declare module '#og-image/unocss-config' {
650651
// @ts-expect-error runtime type
651652
isNuxtContentDocumentDriven: config.strictNuxtContentPaths || !!nuxt.options.content?.documentDriven,
652653
}
654+
if (nuxt.options.dev) {
655+
runtimeConfig.componentDirs = config.componentDirs
656+
}
653657
// @ts-expect-error untyped
654658
nuxt.hooks.callHook('nuxt-og-image:runtime-config', runtimeConfig)
655659
// @ts-expect-error runtime types

‎src/rpc-types.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
export interface ServerFunctions {}
1+
export interface ServerFunctions {
2+
ejectCommunityTemplate: (path: string) => Promise<string>
3+
}
24

35
export interface ClientFunctions {
46
refresh: () => void

0 commit comments

Comments
 (0)
Please sign in to comment.