Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add open graph tab #209

Merged
merged 15 commits into from May 8, 2023
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -31,7 +31,7 @@
"@unocss/eslint-config": "^0.51.12",
"bumpp": "^9.1.0",
"conventional-changelog-cli": "^2.2.2",
"eslint": "^8.40.0",
"eslint": "8.39.0",
"esno": "^0.16.3",
"execa": "^7.1.1",
"lint-staged": "^13.2.2",
Expand Down
4 changes: 4 additions & 0 deletions packages/devtools-ui-kit/src/assets/styles.css
Expand Up @@ -11,3 +11,7 @@ html.dark {
background-color: #151515;
color: white;
}

::selection {
background: #8884;
}
3 changes: 2 additions & 1 deletion packages/devtools-ui-kit/src/components/NSectionBlock.vue
Expand Up @@ -7,6 +7,7 @@ const props = withDefaults(
text: string
description?: string
containerClass?: string
headerClass?: string
collapse?: boolean
open?: boolean
padding?: boolean | string
Expand All @@ -27,7 +28,7 @@ function onToggle(e: any) {
<template>
<details :open="open" @toggle="onToggle">
<summary class="cursor-pointer select-none hover:bg-active p4" :class="collapse ? '' : 'pointer-events-none'">
<NIconTitle :icon="icon" :text="text" text-xl transition :class="open ? 'op100' : 'op60'">
<NIconTitle :icon="icon" :text="text" text-xl transition :class="[open ? 'op100' : 'op60', headerClass]">
<div>
<div text-base>
<slot name="text">
Expand Down
24 changes: 24 additions & 0 deletions packages/devtools-ui-kit/src/components/NTextExternalLink.vue
@@ -0,0 +1,24 @@
<script setup lang="ts">
import NLink from './NLink.vue'

defineProps<{
link?: string
}>()
</script>

<template>
<component
:is="link ? NLink : 'div'"
v-bind="link ? {
href: link,
target: '_blank',
rel: 'noopener noreferrer',
} : {}"
>
<slot />
<div
v-if="link"
i-carbon:arrow-up-right translate-y--1 text-xs op50
/>
</component>
</template>
2 changes: 1 addition & 1 deletion packages/devtools-ui-kit/src/unocss.ts
Expand Up @@ -98,7 +98,7 @@ export function unocssPreset(): Preset {

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

// card
'n-card-base': 'border n-border-base rounded n-bg-base shadow-sm',
Expand Down
132 changes: 132 additions & 0 deletions packages/devtools/client/components/OpenGraphMissingTabs.vue
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { defu } from 'defu'
import type { ReactiveHead } from '@unhead/vue'
import type { NormalizedHeadTag } from '../../src/types'
import { ogTags } from '../data/open-graph'

const props = defineProps<{
tags: NormalizedHeadTag[]
matchedRouteFilepath?: string
}>()

const missingTags = computed(() => {
return ogTags.filter(define => !props.tags?.some(tag => tag.name === define.name))
})

const missingRequiredTags = computed(() => {
return missingTags.value.filter(i => i.suggestion === 'required')
})
const missingRecommendedTags = computed(() => {
return missingTags.value.filter(i => i.suggestion === 'recommended')
})

const mergedMissingTags = computed(() => {
let data: Partial<ReactiveHead> = {}
missingTags.value
.forEach((tag) => {
data = defu(data, tag.default)
})
return data
})

const codeSnippet = computed(() => {
const body = JSON.stringify(mergedMissingTags.value, null, 2)
.replace(/"([^"]+)":/g, '$1:')
.replace(/"/g, '\'')
return `useHead(${body})`
})

const copy = useCopy()
const openInEditor = useOpenInEditor()

const tabs = [
'Missing Tags',
'Code Snippet',
]
const selectedTab = ref(tabs[0])
</script>

<template>
<template v-if="missingTags.length">
<NSectionBlock
text="Missing Tags"
:description="`${missingTags.length} missing tags (${missingRequiredTags.length} required, ${missingRecommendedTags.length} recommended)`"
icon="carbon:warning-other"
header-class="text-orange op100! [[open]_&]:text-inherit"
:padding="false"
>
<div flex="~ wrap" mt--2 w-full flex-none>
<template v-for="name, idx of tabs" :key="idx">
<button
px4 py2 border="r t base"
hover="bg-active"
:class="name === selectedTab ? '' : 'border-b'"
@click="selectedTab = name"
>
<div :class="name === selectedTab ? '' : 'op30' " capitalize>
{{ name }}
</div>
</button>
</template>
<div border="b base" flex-auto />
</div>

<NCard v-if="selectedTab === tabs[0]" grid="~ cols-[max-content_1fr]" m4 items-center justify-between of-hidden>
<template v-for="item, index of missingTags" :key="index">
<div v-if="index" x-divider />
<div v-if="index" x-divider />
<div flex="~ gap-1 items-center" px4 py2>
<div i-carbon-warning text-orange />
<NTextExternalLink
op50
:link="item.docs"
n="orange"
>
{{ item.name }}
</NTextExternalLink>
</div>
<!-- TODO: use icons instead, show link to documentation -->
<div w-full p2 op75>
{{ item.description }}
</div>
</template>
</NCard>
<div v-else m4 flex="~ col gap-2">
<p flex="~ gap-1 wrap items-center">
<NButton
icon="carbon-copy" n="xs" px-2
@click="copy(codeSnippet)"
>
Copy
</NButton>
the following code snippet and paste it into your
<NButton
v-if="matchedRouteFilepath"
icon="carbon-launch" n="xs" px-2
@click="openInEditor(matchedRouteFilepath)"
>
page component
</NButton>
<span v-else>page component</span>
to full fill the missing tags.
</p>
<NCard relative n-code-block>
<NCodeBlock
:code="codeSnippet"
lang="ts"
:lines="false"
w-full of-auto p3
/>
<div flex="~ gap-2" n="sm primary" absolute right-2 top-2>
<NButton
icon="carbon-copy"
@click="copy(codeSnippet)"
>
Copy
</NButton>
</div>
</NCard>
</div>
</NSectionBlock>
</template>
</template>
14 changes: 14 additions & 0 deletions packages/devtools/client/components/docs/open-graph.md
@@ -0,0 +1,14 @@
# Open Graph

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.

[Learn more on the documentation](https://nuxt.com/docs/getting-started/seo-meta)

---

You can also find how open graph specs are defined in:

- [The Open Graph protocol](https://ogp.me/)
- [Twitter Cards](https://developer.twitter.com/en/docs/twitter-for-websites/cards/guides/getting-started)

<!-- and maybe also add reference to SEO modules(https://nuxt.com/modules?category=SEO) ? -->
30 changes: 30 additions & 0 deletions packages/devtools/client/components/social/SocialFacebook.vue
@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { SocialPreviewResolved } from '~/../src/types'

defineProps<{
card: SocialPreviewResolved
}>()
</script>

<template>
<div class="max-w-[524px] min-w-[524px] cursor-pointer">
<div
class="h-[274px] border border-b-0 border-base bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: `url(${JSON.stringify(card.image)})` }"
/>
<div class="break-words border border-base px-[12px] py-[10px] antialiased">
<div class="overflow-hidden truncate whitespace-nowrap text-[12px] leading-[11px] uppercase op50">
{{ card.url }}
</div><div class="block h-[46px] max-h-[46px] border-separate select-none overflow-hidden break-words text-left" style="border-spacing: 0px;">
<div class="mt-[3px] truncate pt-[2px] text-[16px] font-semibold leading-[20px]">
{{ card.title }}
</div><div
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"
style="-webkit-line-clamp: 1; border-spacing: 0px; -webkit-box-orient: vertical;"
>
{{ card.description }}
</div>
</div>
</div>
</div>
</template>
24 changes: 24 additions & 0 deletions packages/devtools/client/components/social/SocialLinkedin.vue
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { SocialPreviewResolved } from '~/../src/types'

defineProps<{
card: SocialPreviewResolved
}>()
</script>

<template>
<div class="max-w-[520px] min-w-[520px] cursor-pointer overflow-hidden border border-base rounded-[2px] shadow-md">
<div
class="h-[270px] border-b border-base bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: `url(${JSON.stringify(card.image)})` }"
/><div class="break-words p-[10px] antialiased">
<div class="block h-auto max-h-[50px] border-separate select-none break-words text-left" style="border-spacing: 0px;">
<div class="pb-[2px] text-[16px] font-semibold leading-[24px]">
{{ card.title }}
</div><div class="overflow-hidden truncate whitespace-nowrap text-xs font-normal uppercase op85">
{{ card.url }}
</div>
</div>
</div>
</div>
</template>
57 changes: 57 additions & 0 deletions packages/devtools/client/components/social/SocialPreviewGroup.vue
@@ -0,0 +1,57 @@
<script setup lang="ts">
import type { NormalizedHeadTag, SocialPreviewResolved } from '../../../src/types'

const props = defineProps<{
tags: NormalizedHeadTag[]
}>()

const types = [
'twitter',
'facebook',
'linkedin',
]

const selected = ref(types[0])

const card = computed((): SocialPreviewResolved => {
return {
url: window.location.host,
title: props.tags.find(tag => tag.tag === 'title')?.value,
image: props.tags.find(tag => tag.tag === 'meta' && tag.name === 'og:image')?.value,
imageAlt: props.tags.find(tag => tag.tag === 'meta' && tag.name === 'og:image:alt')?.value,
description: props.tags.find(tag => tag.tag === 'meta' && tag.name === 'og:description')?.value,
favicon: props.tags.find(tag => tag.tag === 'link' && tag.name === 'icon')?.value,
}
})
</script>

<template>
<div h-full w-max flex="~ col">
<div flex="~ wrap" w-full flex-none>
<template v-for="name, idx of types" :key="idx">
<button
px4 py2 border="r base"
hover="bg-active"
:class="name === selected ? '' : 'border-b'"
@click="selected = name"
>
<div :class="name === selected ? '' : 'op30' " capitalize>
{{ name }}
</div>
</button>
</template>
<div border="b base" flex-auto />
</div>
<div flex="~ items-center justify-center" flex-auto p4>
<div v-if="selected === 'facebook'">
<SocialFacebook :card="card" />
</div>
<div v-else-if="selected === 'twitter'">
<SocialTwitter :tags="tags" />
</div>
<div v-else-if="selected === 'linkedin'">
<SocialLinkedin :card="card" />
</div>
</div>
</div>
</template>
54 changes: 54 additions & 0 deletions packages/devtools/client/components/social/SocialTwitter.vue
@@ -0,0 +1,54 @@
<script setup lang="ts">
import type { NormalizedHeadTag, SocialPreviewResolved } from '~/../src/types'

const props = defineProps<{
tags: NormalizedHeadTag[]
}>()

const card = computed((): SocialPreviewResolved => {
return {
url: window.location.host,
title: props.tags.find(tag => tag.tag === 'meta' && tag.name === 'twitter:title')?.value || props.tags.find(tag => tag.tag === 'title')?.value,
image: props.tags.find(tag => tag.tag === 'meta' && tag.name === 'twitter:image')?.value,
imageAlt: props.tags.find(tag => tag.tag === 'meta' && tag.name === 'twitter:image:alt')?.value,
description: props.tags.find(tag => tag.tag === 'meta' && tag.name === 'twitter:description')?.value || props.tags.find(tag => tag.tag === 'meta' && tag.name === 'description')?.value,
favicon: props.tags.find(tag => tag.tag === 'link' && tag.name === 'icon')?.value,
}
})

const type = computed(() => {
if (!card.value.image)
return 'summary'
return props.tags.find(tag => tag.tag === 'meta' && tag.name === 'twitter:card')?.value || 'summary_large_image'
})
</script>

<template>
<div
class="max-w-[438px] min-w-[438px] cursor-pointer overflow-hidden border border-base rounded-[0.85714em] leading-[1.3em] -outline-offset-1"
:class="type === 'summary_large_image' ? '' : 'flex'"
hover="bg-[#88888805]"
>
<div
v-if="type === 'summary_large_image'"
class="h-[220px] border-b border-base bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: `url(${JSON.stringify(card.image)})` }"
/>
<div
v-else
class="h-[122px] w-[122px] flex-none border-r border-base bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: `url(${JSON.stringify(card.image)})` }"
/>
<div class="break-words border-base p-[0.75em] antialiased" flex="~ col justify-center gap-1">
<div class="mt-[0.32333em] overflow-hidden truncate whitespace-nowrap text-[14px] leading-[18px] lowercase op50">
{{ card.url }}
</div>
<div class="m-0 truncate text-[14px] font-semibold leading-[19px]">
{{ card.title }}
</div>
<div class="line-clamp-2 block select-none overflow-hidden break-words text-left text-[14px] leading-[18px] op50">
{{ card.description }}
</div>
</div>
</div>
</template>