Skip to content

Commit b94de30

Browse files
arashsheydaantfu
andauthored
feat: add open graph tab (#209)
Co-authored-by: Anthony Fu <anthonyfu117@hotmail.com>
1 parent ea66558 commit b94de30

32 files changed

+1034
-147
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"@unocss/eslint-config": "^0.51.12",
3232
"bumpp": "^9.1.0",
3333
"conventional-changelog-cli": "^2.2.2",
34-
"eslint": "^8.40.0",
34+
"eslint": "8.39.0",
3535
"esno": "^0.16.3",
3636
"execa": "^7.1.1",
3737
"lint-staged": "^13.2.2",

packages/devtools-ui-kit/src/assets/styles.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ html.dark {
1111
background-color: #151515;
1212
color: white;
1313
}
14+
15+
::selection {
16+
background: #8884;
17+
}

packages/devtools-ui-kit/src/components/NSectionBlock.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const props = withDefaults(
77
text: string
88
description?: string
99
containerClass?: string
10+
headerClass?: string
1011
collapse?: boolean
1112
open?: boolean
1213
padding?: boolean | string
@@ -27,7 +28,7 @@ function onToggle(e: any) {
2728
<template>
2829
<details :open="open" @toggle="onToggle">
2930
<summary class="cursor-pointer select-none hover:bg-active p4" :class="collapse ? '' : 'pointer-events-none'">
30-
<NIconTitle :icon="icon" :text="text" text-xl transition :class="open ? 'op100' : 'op60'">
31+
<NIconTitle :icon="icon" :text="text" text-xl transition :class="[open ? 'op100' : 'op60', headerClass]">
3132
<div>
3233
<div text-base>
3334
<slot name="text">
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<script setup lang="ts">
2+
import NLink from './NLink.vue'
3+
4+
defineProps<{
5+
link?: string
6+
}>()
7+
</script>
8+
9+
<template>
10+
<component
11+
:is="link ? NLink : 'div'"
12+
v-bind="link ? {
13+
href: link,
14+
target: '_blank',
15+
rel: 'noopener noreferrer',
16+
} : {}"
17+
>
18+
<slot />
19+
<div
20+
v-if="link"
21+
i-carbon:arrow-up-right translate-y--1 text-xs op50
22+
/>
23+
</component>
24+
</template>

packages/devtools-ui-kit/src/unocss.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export function unocssPreset(): Preset {
9898

9999
// link
100100
'n-link-base': 'underline underline-offset-2 underline-black/20 dark:underline-white/40',
101-
'n-link-hover': 'decoration-dotted text-context underline-context',
101+
'n-link-hover': 'decoration-dotted text-context underline-context! op100!',
102102

103103
// card
104104
'n-card-base': 'border n-border-base rounded n-bg-base shadow-sm',
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<script setup lang="ts">
2+
import { defu } from 'defu'
3+
import type { ReactiveHead } from '@unhead/vue'
4+
import type { NormalizedHeadTag } from '../../src/types'
5+
import { ogTags } from '../data/open-graph'
6+
7+
const props = defineProps<{
8+
tags: NormalizedHeadTag[]
9+
matchedRouteFilepath?: string
10+
}>()
11+
12+
const missingTags = computed(() => {
13+
return ogTags.filter(define => !props.tags?.some(tag => tag.name === define.name))
14+
})
15+
16+
const missingRequiredTags = computed(() => {
17+
return missingTags.value.filter(i => i.suggestion === 'required')
18+
})
19+
const missingRecommendedTags = computed(() => {
20+
return missingTags.value.filter(i => i.suggestion === 'recommended')
21+
})
22+
23+
const mergedMissingTags = computed(() => {
24+
let data: Partial<ReactiveHead> = {}
25+
missingTags.value
26+
.forEach((tag) => {
27+
data = defu(data, tag.default)
28+
})
29+
return data
30+
})
31+
32+
const codeSnippet = computed(() => {
33+
const body = JSON.stringify(mergedMissingTags.value, null, 2)
34+
.replace(/"([^"]+)":/g, '$1:')
35+
.replace(/"/g, '\'')
36+
return `useHead(${body})`
37+
})
38+
39+
const copy = useCopy()
40+
const openInEditor = useOpenInEditor()
41+
42+
const tabs = [
43+
'Missing Tags',
44+
'Code Snippet',
45+
]
46+
const selectedTab = ref(tabs[0])
47+
</script>
48+
49+
<template>
50+
<template v-if="missingTags.length">
51+
<NSectionBlock
52+
text="Missing Tags"
53+
:description="`${missingTags.length} missing tags (${missingRequiredTags.length} required, ${missingRecommendedTags.length} recommended)`"
54+
icon="carbon:warning-other"
55+
header-class="text-orange op100! [[open]_&]:text-inherit"
56+
:padding="false"
57+
>
58+
<div flex="~ wrap" mt--2 w-full flex-none>
59+
<template v-for="name, idx of tabs" :key="idx">
60+
<button
61+
px4 py2 border="r t base"
62+
hover="bg-active"
63+
:class="name === selectedTab ? '' : 'border-b'"
64+
@click="selectedTab = name"
65+
>
66+
<div :class="name === selectedTab ? '' : 'op30' " capitalize>
67+
{{ name }}
68+
</div>
69+
</button>
70+
</template>
71+
<div border="b base" flex-auto />
72+
</div>
73+
74+
<NCard v-if="selectedTab === tabs[0]" grid="~ cols-[max-content_1fr]" m4 items-center justify-between of-hidden>
75+
<template v-for="item, index of missingTags" :key="index">
76+
<div v-if="index" x-divider />
77+
<div v-if="index" x-divider />
78+
<div flex="~ gap-1 items-center" px4 py2>
79+
<div i-carbon-warning text-orange />
80+
<NTextExternalLink
81+
op50
82+
:link="item.docs"
83+
n="orange"
84+
>
85+
{{ item.name }}
86+
</NTextExternalLink>
87+
</div>
88+
<!-- TODO: use icons instead, show link to documentation -->
89+
<div w-full p2 op75>
90+
{{ item.description }}
91+
</div>
92+
</template>
93+
</NCard>
94+
<div v-else m4 flex="~ col gap-2">
95+
<p flex="~ gap-1 wrap items-center">
96+
<NButton
97+
icon="carbon-copy" n="xs" px-2
98+
@click="copy(codeSnippet)"
99+
>
100+
Copy
101+
</NButton>
102+
the following code snippet and paste it into your
103+
<NButton
104+
v-if="matchedRouteFilepath"
105+
icon="carbon-launch" n="xs" px-2
106+
@click="openInEditor(matchedRouteFilepath)"
107+
>
108+
page component
109+
</NButton>
110+
<span v-else>page component</span>
111+
to full fill the missing tags.
112+
</p>
113+
<NCard relative n-code-block>
114+
<NCodeBlock
115+
:code="codeSnippet"
116+
lang="ts"
117+
:lines="false"
118+
w-full of-auto p3
119+
/>
120+
<div flex="~ gap-2" n="sm primary" absolute right-2 top-2>
121+
<NButton
122+
icon="carbon-copy"
123+
@click="copy(codeSnippet)"
124+
>
125+
Copy
126+
</NButton>
127+
</div>
128+
</NCard>
129+
</div>
130+
</NSectionBlock>
131+
</template>
132+
</template>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Open Graph
2+
3+
Nuxt provides several different ways to manage your meta tags using [`unhead`](https://unhead.harlanzw.com/). Improve your Nuxt app's SEO with powerful head config, composables and components.
4+
5+
[Learn more on the documentation](https://nuxt.com/docs/getting-started/seo-meta)
6+
7+
---
8+
9+
You can also find how open graph specs are defined in:
10+
11+
- [The Open Graph protocol](https://ogp.me/)
12+
- [Twitter Cards](https://developer.twitter.com/en/docs/twitter-for-websites/cards/guides/getting-started)
13+
14+
<!-- and maybe also add reference to SEO modules(https://nuxt.com/modules?category=SEO) ? -->
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<script setup lang="ts">
2+
import type { SocialPreviewResolved } from '~/../src/types'
3+
4+
defineProps<{
5+
card: SocialPreviewResolved
6+
}>()
7+
</script>
8+
9+
<template>
10+
<div class="max-w-[524px] min-w-[524px] cursor-pointer">
11+
<div
12+
class="h-[274px] border border-b-0 border-base bg-cover bg-center bg-no-repeat"
13+
:style="{ backgroundImage: `url(${JSON.stringify(card.image)})` }"
14+
/>
15+
<div class="break-words border border-base px-[12px] py-[10px] antialiased">
16+
<div class="overflow-hidden truncate whitespace-nowrap text-[12px] leading-[11px] uppercase op50">
17+
{{ card.url }}
18+
</div><div class="block h-[46px] max-h-[46px] border-separate select-none overflow-hidden break-words text-left" style="border-spacing: 0px;">
19+
<div class="mt-[3px] truncate pt-[2px] text-[16px] font-semibold leading-[20px]">
20+
{{ card.title }}
21+
</div><div
22+
class="mt-[3px] block h-[18px] max-h-[80px] border-separate select-none overflow-hidden truncate whitespace-nowrap break-words text-left text-[14px] leading-[20px] op50"
23+
style="-webkit-line-clamp: 1; border-spacing: 0px; -webkit-box-orient: vertical;"
24+
>
25+
{{ card.description }}
26+
</div>
27+
</div>
28+
</div>
29+
</div>
30+
</template>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<script setup lang="ts">
2+
import type { SocialPreviewResolved } from '~/../src/types'
3+
4+
defineProps<{
5+
card: SocialPreviewResolved
6+
}>()
7+
</script>
8+
9+
<template>
10+
<div class="max-w-[520px] min-w-[520px] cursor-pointer overflow-hidden border border-base rounded-[2px] shadow-md">
11+
<div
12+
class="h-[270px] border-b border-base bg-cover bg-center bg-no-repeat"
13+
:style="{ backgroundImage: `url(${JSON.stringify(card.image)})` }"
14+
/><div class="break-words p-[10px] antialiased">
15+
<div class="block h-auto max-h-[50px] border-separate select-none break-words text-left" style="border-spacing: 0px;">
16+
<div class="pb-[2px] text-[16px] font-semibold leading-[24px]">
17+
{{ card.title }}
18+
</div><div class="overflow-hidden truncate whitespace-nowrap text-xs font-normal uppercase op85">
19+
{{ card.url }}
20+
</div>
21+
</div>
22+
</div>
23+
</div>
24+
</template>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<script setup lang="ts">
2+
import type { NormalizedHeadTag, SocialPreviewResolved } from '../../../src/types'
3+
4+
const props = defineProps<{
5+
tags: NormalizedHeadTag[]
6+
}>()
7+
8+
const types = [
9+
'twitter',
10+
'facebook',
11+
'linkedin',
12+
]
13+
14+
const selected = ref(types[0])
15+
16+
const card = computed((): SocialPreviewResolved => {
17+
return {
18+
url: window.location.host,
19+
title: props.tags.find(tag => tag.tag === 'title')?.value,
20+
image: props.tags.find(tag => tag.tag === 'meta' && tag.name === 'og:image')?.value,
21+
imageAlt: props.tags.find(tag => tag.tag === 'meta' && tag.name === 'og:image:alt')?.value,
22+
description: props.tags.find(tag => tag.tag === 'meta' && tag.name === 'og:description')?.value,
23+
favicon: props.tags.find(tag => tag.tag === 'link' && tag.name === 'icon')?.value,
24+
}
25+
})
26+
</script>
27+
28+
<template>
29+
<div h-full w-max flex="~ col">
30+
<div flex="~ wrap" w-full flex-none>
31+
<template v-for="name, idx of types" :key="idx">
32+
<button
33+
px4 py2 border="r base"
34+
hover="bg-active"
35+
:class="name === selected ? '' : 'border-b'"
36+
@click="selected = name"
37+
>
38+
<div :class="name === selected ? '' : 'op30' " capitalize>
39+
{{ name }}
40+
</div>
41+
</button>
42+
</template>
43+
<div border="b base" flex-auto />
44+
</div>
45+
<div flex="~ items-center justify-center" flex-auto p4>
46+
<div v-if="selected === 'facebook'">
47+
<SocialFacebook :card="card" />
48+
</div>
49+
<div v-else-if="selected === 'twitter'">
50+
<SocialTwitter :tags="tags" />
51+
</div>
52+
<div v-else-if="selected === 'linkedin'">
53+
<SocialLinkedin :card="card" />
54+
</div>
55+
</div>
56+
</div>
57+
</template>

0 commit comments

Comments
 (0)