New Research: Supply Chain Attack on Axios Pulls Malicious Dependency from npm.Details
Socket
Book a DemoSign in
Socket

@tanstack/devtools

Package Overview
Dependencies
Maintainers
6
Versions
64
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@tanstack/devtools - npm Package Compare versions

Comparing version
0.11.0
to
0.11.1
dist/devtools/4QORE6HP.js

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

+41
import { DevtoolsProvider, PiPProvider } from '../chunk/AP5L3KAF.js';
import '../chunk/BNGI36V3.js';
import { render, createComponent, Portal } from 'solid-js/web';
import { lazy } from 'solid-js';
import { ClientEventBus } from '@tanstack/devtools-event-bus/client';
function mountDevtools(options) {
const {
el,
plugins,
config,
eventBusConfig,
onSetPlugins
} = options;
const eventBus = new ClientEventBus(eventBusConfig);
eventBus.start();
const Devtools = lazy(() => import('../devtools/4QORE6HP.js'));
const dispose = render(() => createComponent(DevtoolsProvider, {
plugins,
config,
onSetPlugins,
get children() {
return createComponent(PiPProvider, {
get children() {
return createComponent(Portal, {
mount: el,
get children() {
return createComponent(Devtools, {});
}
});
}
});
}
}), el);
return {
dispose,
eventBus
};
}
export { mountDevtools };
import { DevtoolsProvider, PiPProvider } from '../chunk/AP5L3KAF.js';
import '../chunk/BNGI36V3.js';
import { render, createComponent, Portal } from 'solid-js/web';
import { lazy } from 'solid-js';
import { ClientEventBus } from '@tanstack/devtools-event-bus/client';
function mountDevtools(options) {
const {
el,
plugins,
config,
eventBusConfig,
onSetPlugins
} = options;
const eventBus = new ClientEventBus(eventBusConfig);
eventBus.start();
const Devtools = lazy(() => import('../devtools/54BPKEIS.js'));
const dispose = render(() => createComponent(DevtoolsProvider, {
plugins,
config,
onSetPlugins,
get children() {
return createComponent(PiPProvider, {
get children() {
return createComponent(Portal, {
mount: el,
get children() {
return createComponent(Devtools, {});
}
});
}
});
}
}), el);
return {
dispose,
eventBus
};
}
export { mountDevtools };
import { Show, createSignal } from 'solid-js'
import { MainPanel } from '@tanstack/devtools-ui'
import { useStyles } from '../../styles/use-styles'
import { SocialPreviewsSection } from './social-previews'
import { SerpPreviewSection } from './serp-preview'
type SeoSubView = 'social-previews' | 'serp-preview'
export const SeoTab = () => {
const [activeView, setActiveView] =
createSignal<SeoSubView>('social-previews')
const styles = useStyles()
return (
<MainPanel withPadding>
<nav class={styles().seoSubNav} aria-label="SEO sections">
<button
type="button"
class={`${styles().seoSubNavLabel} ${activeView() === 'social-previews' ? styles().seoSubNavLabelActive : ''}`}
onClick={() => setActiveView('social-previews')}
>
Social previews
</button>
<button
type="button"
class={`${styles().seoSubNavLabel} ${activeView() === 'serp-preview' ? styles().seoSubNavLabelActive : ''}`}
onClick={() => setActiveView('serp-preview')}
>
SERP Preview
</button>
</nav>
<Show when={activeView() === 'social-previews'}>
<SocialPreviewsSection />
</Show>
<Show when={activeView() === 'serp-preview'}>
<SerpPreviewSection />
</Show>
</MainPanel>
)
}
import { Section, SectionDescription } from '@tanstack/devtools-ui'
import { For, createMemo, createSignal } from 'solid-js'
import { useHeadChanges } from '../../hooks/use-head-changes'
import { useStyles } from '../../styles/use-styles'
/** Google typically truncates titles at ~60 characters. */
const TITLE_MAX_CHARS = 60
/** Meta description is often trimmed at ~158 characters on desktop. */
const DESCRIPTION_MAX_CHARS = 158
/** Approximate characters that fit in 3 lines at mobile width (~340px, ~14px font). */
const DESCRIPTION_MOBILE_MAX_CHARS = 120
const ELLIPSIS = '...'
type SerpData = {
title: string
description: string
siteName: string
favicon: string | null
url: string
}
type SerpOverflow = {
titleOverflow: boolean
descriptionOverflow: boolean
descriptionOverflowMobile: boolean
}
type SerpCheck = {
message: string
hasIssue: (data: SerpData, overflow: SerpOverflow) => boolean
}
type SerpPreview = {
label: string
isMobile: boolean
extraChecks: Array<SerpCheck>
}
const COMMON_CHECKS: Array<SerpCheck> = [
{
message: 'No favicon or icon set on the page.',
hasIssue: (data) => !data.favicon,
},
{
message: 'No title tag set on the page.',
hasIssue: (data) => !data.title.trim(),
},
{
message: 'No meta description set on the page.',
hasIssue: (data) => !data.description.trim(),
},
{
message:
'The title is wider than 600px and it may not be displayed in full length.',
hasIssue: (_, overflow) => overflow.titleOverflow,
},
]
const SERP_PREVIEWS: Array<SerpPreview> = [
{
label: 'Desktop preview',
isMobile: false,
extraChecks: [
{
message:
'The meta description may get trimmed at ~960 pixels on desktop and at ~680px on mobile. Keep it below ~158 characters.',
hasIssue: (_, overflow) => overflow.descriptionOverflow,
},
],
},
{
label: 'Mobile preview',
isMobile: true,
extraChecks: [
{
message:
'Description exceeds the 3-line limit for mobile view. Please shorten your text to fit within 3 lines.',
hasIssue: (_, overflow) => overflow.descriptionOverflowMobile,
},
],
},
]
function truncateToChars(text: string, maxChars: number): string {
if (text.length <= maxChars) return text
if (maxChars <= ELLIPSIS.length) return ELLIPSIS
return text.slice(0, maxChars - ELLIPSIS.length) + ELLIPSIS
}
function getSerpFromHead(): SerpData {
const title = document.title || ''
const url = typeof window !== 'undefined' ? window.location.href : ''
const metaTags = Array.from(document.head.querySelectorAll('meta'))
const descriptionMeta = metaTags.find(
(m) => m.getAttribute('name')?.toLowerCase() === 'description',
)
const description = descriptionMeta?.getAttribute('content')?.trim() || ''
const siteNameMeta = metaTags.find(
(m) => m.getAttribute('property') === 'og:site_name',
)
const siteName =
siteNameMeta?.getAttribute('content')?.trim() ||
(typeof window !== 'undefined'
? window.location.hostname.replace(/^www\./, '')
: '')
const linkTags = Array.from(document.head.querySelectorAll('link'))
const iconLink = linkTags.find((l) =>
l.getAttribute('rel')?.toLowerCase().split(/\s+/).includes('icon'),
)
let favicon: string | null = iconLink?.getAttribute('href') || null
if (favicon && typeof window !== 'undefined') {
try {
favicon = new URL(favicon, url).href
} catch {
favicon = null
}
}
return { title, description, siteName, favicon, url }
}
function getSerpIssues(
data: SerpData,
overflow: SerpOverflow,
checks: Array<SerpCheck>,
): Array<string> {
return checks.filter((c) => c.hasIssue(data, overflow)).map((c) => c.message)
}
function SerpSnippetPreview(props: {
data: SerpData
displayTitle: string
displayDescription: string
isMobile: boolean
label: string
issues: Array<string>
}) {
const styles = useStyles()
return (
<div class={styles().serpPreviewBlock}>
<div class={styles().serpPreviewLabel}>{props.label}</div>
<div
class={
props.isMobile ? styles().serpSnippetMobile : styles().serpSnippet
}
>
<div class={styles().serpSnippetTopRow}>
{props.data.favicon ? (
<img
src={props.data.favicon}
alt="favicon icon"
class={styles().serpSnippetFavicon}
/>
) : (
<div class={styles().serpSnippetDefaultFavicon} />
)}
<div class={styles().serpSnippetSiteColumn}>
<span class={styles().serpSnippetSiteName}>
{props.data.siteName || props.data.url}
</span>
<span class={styles().serpSnippetSiteUrl}>{props.data.url}</span>
</div>
</div>
<div class={styles().serpSnippetTitle}>
{props.displayTitle || props.data.title || 'No title'}
</div>
{!props.isMobile && (
<div class={styles().serpSnippetDesc}>
{props.displayDescription ||
props.data.description ||
'No meta description.'}
</div>
)}
{props.isMobile && (
<div class={styles().serpSnippetDescMobile}>
{props.displayDescription ||
props.data.description ||
'No meta description.'}
</div>
)}
</div>
{props.issues.length > 0 ? (
<div class={styles().seoMissingTagsSection}>
<strong>Issues for {props.label}:</strong>
<ul class={styles().serpErrorList}>
<For each={props.issues}>
{(issue) => <li class={styles().serpReportItem}>{issue}</li>}
</For>
</ul>
</div>
) : null}
</div>
)
}
export function SerpPreviewSection() {
const [serp, setSerp] = createSignal<SerpData>(getSerpFromHead())
useHeadChanges(() => {
setSerp(getSerpFromHead())
})
const serpPreviewState = createMemo(() => {
const data = serp()
const titleText = data.title || 'No title'
const descText = data.description || 'No meta description.'
const displayTitle = truncateToChars(titleText, TITLE_MAX_CHARS)
const displayDescription = truncateToChars(descText, DESCRIPTION_MAX_CHARS)
return {
displayTitle,
displayDescription,
overflow: {
titleOverflow: titleText.length > TITLE_MAX_CHARS,
descriptionOverflow: descText.length > DESCRIPTION_MAX_CHARS,
descriptionOverflowMobile:
descText.length > DESCRIPTION_MOBILE_MAX_CHARS,
},
}
})
return (
<Section>
<SectionDescription>
See how your title tag and meta description may look in Google search
results. Data is read from the current page.
</SectionDescription>
<For each={SERP_PREVIEWS}>
{(preview) => {
const issues = createMemo(() =>
getSerpIssues(serp(), serpPreviewState().overflow, [
...COMMON_CHECKS,
...preview.extraChecks,
]),
)
return (
<SerpSnippetPreview
data={serp()}
displayTitle={serpPreviewState().displayTitle}
displayDescription={serpPreviewState().displayDescription}
isMobile={preview.isMobile}
label={preview.label}
issues={issues()}
/>
)
}}
</For>
</Section>
)
}
import { For, createSignal } from 'solid-js'
import { Section, SectionDescription } from '@tanstack/devtools-ui'
import { useStyles } from '../../styles/use-styles'
import { useHeadChanges } from '../../hooks/use-head-changes'
const SOCIALS = [
{
network: 'Facebook',
tags: [
{ key: 'og:title', prop: 'title' },
{ key: 'og:description', prop: 'description' },
{ key: 'og:image', prop: 'image' },
{ key: 'og:url', prop: 'url' },
],
color: '#4267B2',
},
{
network: 'X/Twitter',
tags: [
{ key: 'twitter:title', prop: 'title' },
{ key: 'twitter:description', prop: 'description' },
{ key: 'twitter:image', prop: 'image' },
{ key: 'twitter:url', prop: 'url' },
],
color: '#1DA1F2',
},
{
network: 'LinkedIn',
tags: [
{ key: 'og:title', prop: 'title' },
{ key: 'og:description', prop: 'description' },
{ key: 'og:image', prop: 'image' },
{ key: 'og:url', prop: 'url' },
],
color: '#0077B5',
},
{
network: 'Discord',
tags: [
{ key: 'og:title', prop: 'title' },
{ key: 'og:description', prop: 'description' },
{ key: 'og:image', prop: 'image' },
{ key: 'og:url', prop: 'url' },
],
color: '#5865F2',
},
{
network: 'Slack',
tags: [
{ key: 'og:title', prop: 'title' },
{ key: 'og:description', prop: 'description' },
{ key: 'og:image', prop: 'image' },
{ key: 'og:url', prop: 'url' },
],
color: '#4A154B',
},
{
network: 'Mastodon',
tags: [
{ key: 'og:title', prop: 'title' },
{ key: 'og:description', prop: 'description' },
{ key: 'og:image', prop: 'image' },
{ key: 'og:url', prop: 'url' },
],
color: '#6364FF',
},
{
network: 'Bluesky',
tags: [
{ key: 'og:title', prop: 'title' },
{ key: 'og:description', prop: 'description' },
{ key: 'og:image', prop: 'image' },
{ key: 'og:url', prop: 'url' },
],
color: '#1185FE',
},
// Add more networks as needed
]
type SocialMeta = {
title?: string
description?: string
image?: string
url?: string
}
type SocialReport = {
network: string
found: Partial<SocialMeta>
missing: Array<string>
}
function SocialPreview(props: {
meta: SocialMeta
color: string
network: string
}) {
const styles = useStyles()
return (
<div
class={styles().seoPreviewCard}
style={{ 'border-color': props.color }}
>
<div class={styles().seoPreviewHeader} style={{ color: props.color }}>
{props.network} Preview
</div>
{props.meta.image ? (
<img
src={props.meta.image}
alt="Preview"
class={styles().seoPreviewImage}
/>
) : (
<div
class={styles().seoPreviewImage}
style={{
background: '#222',
color: '#888',
display: 'flex',
'align-items': 'center',
'justify-content': 'center',
'min-height': '80px',
width: '100%',
}}
>
No Image
</div>
)}
<div class={styles().seoPreviewTitle}>
{props.meta.title || 'No Title'}
</div>
<div class={styles().seoPreviewDesc}>
{props.meta.description || 'No Description'}
</div>
<div class={styles().seoPreviewUrl}>
{props.meta.url || window.location.href}
</div>
</div>
)
}
export function SocialPreviewsSection() {
const [reports, setReports] = createSignal<Array<SocialReport>>(analyzeHead())
const styles = useStyles()
function analyzeHead(): Array<SocialReport> {
const metaTags = Array.from(document.head.querySelectorAll('meta'))
const reports: Array<SocialReport> = []
for (const social of SOCIALS) {
const found: Partial<SocialMeta> = {}
const missing: Array<string> = []
for (const tag of social.tags) {
const meta = metaTags.find(
(m) =>
(tag.key.includes('twitter:')
? false
: m.getAttribute('property') === tag.key) ||
m.getAttribute('name') === tag.key,
)
if (meta && meta.getAttribute('content')) {
found[tag.prop as keyof SocialMeta] =
meta.getAttribute('content') || undefined
} else {
missing.push(tag.key)
}
}
reports.push({ network: social.network, found, missing })
}
return reports
}
useHeadChanges(() => {
setReports(analyzeHead())
})
return (
<Section>
<SectionDescription>
See how your current page will look when shared on popular social
networks. The tool checks for essential meta tags and highlights any
that are missing.
</SectionDescription>
<div class={styles().seoPreviewSection}>
<For each={reports()}>
{(report, i) => {
const social = SOCIALS[i()]
return (
<div>
<SocialPreview
meta={report.found}
color={social!.color}
network={social!.network}
/>
{report.missing.length > 0 ? (
<>
<div class={styles().seoMissingTagsSection}>
<strong>Missing tags for {social?.network}:</strong>
<ul class={styles().seoMissingTagsList}>
<For each={report.missing}>
{(tag) => (
<li class={styles().seoMissingTag}>{tag}</li>
)}
</For>
</ul>
</div>
</>
) : null}
</div>
)
}}
</For>
</div>
</Section>
)
}
+1
-1

@@ -32,3 +32,3 @@ export { PLUGIN_CONTAINER_ID, PLUGIN_TITLE_CONTAINER_ID } from './chunk/A767CXXU.js';

this.#abortMount = false;
import('./mount-impl/YYPMJI4G.js').then(({ mountDevtools }) => {
import('./mount-impl/3OW54XWT.js').then(({ mountDevtools }) => {
if (this.#abortMount) {

@@ -35,0 +35,0 @@ this.#isMounting = false;

@@ -32,3 +32,3 @@ export { PLUGIN_CONTAINER_ID, PLUGIN_TITLE_CONTAINER_ID } from './chunk/A767CXXU.js';

this.#abortMount = false;
import('./mount-impl/YYPMJI4G.js').then(({ mountDevtools }) => {
import('./mount-impl/3OW54XWT.js').then(({ mountDevtools }) => {
if (this.#abortMount) {

@@ -35,0 +35,0 @@ this.#isMounting = false;

@@ -32,3 +32,3 @@ export { PLUGIN_CONTAINER_ID, PLUGIN_TITLE_CONTAINER_ID } from './chunk/A767CXXU.js';

this.#abortMount = false;
import('./mount-impl/IGHTIX6Y.js').then(({ mountDevtools }) => {
import('./mount-impl/QJF6LBVF.js').then(({ mountDevtools }) => {
if (this.#abortMount) {

@@ -35,0 +35,0 @@ this.#isMounting = false;

{
"name": "@tanstack/devtools",
"version": "0.11.0",
"version": "0.11.1",
"description": "TanStack Devtools is a set of tools for building advanced devtools for your application.",

@@ -5,0 +5,0 @@ "author": "Tanner Linsley",

@@ -122,2 +122,28 @@ import * as goober from 'goober'

`,
seoSubNav: css`
display: flex;
flex-direction: row;
gap: 0;
margin-bottom: 1rem;
border-bottom: 1px solid ${t(colors.gray[200], colors.gray[800])};
`,
seoSubNavLabel: css`
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: ${t(colors.gray[600], colors.gray[400])};
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
cursor: pointer;
font-family: inherit;
&:hover {
color: ${t(colors.gray[800], colors.gray[200])};
}
`,
seoSubNavLabelActive: css`
color: ${t(colors.gray[900], colors.gray[100])};
border-bottom-color: ${t(colors.gray[900], colors.gray[100])};
`,
seoPreviewSection: css`

@@ -209,2 +235,135 @@ display: flex;

`,
serpPreviewBlock: css`
margin-bottom: 1.5rem;
border: 1px solid ${t(colors.gray[200], colors.gray[700])};
border-radius: 10px;
padding: 1rem;
`,
serpPreviewLabel: css`
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: ${t(colors.gray[700], colors.gray[300])};
`,
serpSnippet: css`
border: 1px solid ${t(colors.gray[100], colors.gray[800])};
border-radius: 8px;
padding: 1rem 1.25rem;
background: ${t(colors.white, colors.darkGray[900])};
max-width: 600px;
font-family: ${fontFamily.sans};
box-shadow: 0 1px 2px ${t('rgba(0,0,0,0.04)', 'rgba(0,0,0,0.08)')};
`,
serpSnippetMobile: css`
border: 1px solid ${t(colors.gray[100], colors.gray[800])};
border-radius: 8px;
padding: 1rem 1.25rem;
background: ${t(colors.white, colors.darkGray[900])};
max-width: 380px;
font-family: ${fontFamily.sans};
box-shadow: 0 1px 2px ${t('rgba(0,0,0,0.04)', 'rgba(0,0,0,0.08)')};
`,
serpSnippetDescMobile: css`
font-size: 0.875rem;
color: ${t(colors.gray[700], colors.gray[300])};
margin: 0;
line-height: 1.5;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
`,
serpSnippetTopRow: css`
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
`,
serpSnippetFavicon: css`
width: 28px;
height: 28px;
border-radius: 50%;
flex-shrink: 0;
object-fit: contain;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
`,
serpSnippetDefaultFavicon: css`
width: 28px;
height: 28px;
background-color: ${t(colors.gray[200], colors.gray[800])};
border-radius: 50%;
flex-shrink: 0;
object-fit: contain;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
`,
serpSnippetSiteColumn: css`
display: flex;
flex-direction: column;
gap: 0;
min-width: 0;
`,
serpSnippetSiteName: css`
font-size: 0.875rem;
color: ${t(colors.gray[900], colors.gray[100])};
line-height: 1.4;
margin: 0;
`,
serpSnippetSiteUrl: css`
font-size: 0.75rem;
color: ${t(colors.gray[500], colors.gray[500])};
line-height: 1.4;
margin: 0;
`,
serpSnippetTitle: css`
font-size: 1.25rem;
font-weight: 400;
color: ${t('#1a0dab', '#8ab4f8')};
margin: 0 0 4px 0;
line-height: 1.3;
`,
serpSnippetDesc: css`
font-size: 0.875rem;
color: ${t(colors.gray[700], colors.gray[300])};
margin: 0;
line-height: 1.5;
`,
serpMeasureHidden: css`
position: absolute;
left: -9999px;
top: 0;
visibility: hidden;
pointer-events: none;
box-sizing: border-box;
`,
serpMeasureHiddenMobile: css`
position: absolute;
left: -9999px;
top: 0;
width: 340px;
visibility: hidden;
pointer-events: none;
font-size: 0.875rem;
line-height: 1.5;
`,
serpReportSection: css`
margin-top: 1rem;
font-size: 0.875rem;
color: ${t(colors.gray[700], colors.gray[300])};
`,
serpErrorList: css`
margin: 4px 0 0 0;
padding-left: 1.25rem;
list-style-type: disc;
`,
serpReportItem: css`
margin-top: 0.25rem;
color: ${t(colors.red[700], colors.red[400])};
font-size: 0.875rem;
`,
devtoolsPanelContainer: (

@@ -511,2 +670,8 @@ panelLocation: TanStackDevtoolsConfig['panelLocation'],

& > * > * {
min-width: 0;
min-height: 0;
height: 100%;
}
&:not(:last-child) {

@@ -513,0 +678,0 @@ border-right: 5px solid ${t(colors.purple[200], colors.purple[800])};

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

import { DevtoolsProvider, PiPProvider } from '../chunk/AP5L3KAF.js';
import '../chunk/BNGI36V3.js';
import { render, createComponent, Portal } from 'solid-js/web';
import { lazy } from 'solid-js';
import { ClientEventBus } from '@tanstack/devtools-event-bus/client';
function mountDevtools(options) {
const {
el,
plugins,
config,
eventBusConfig,
onSetPlugins
} = options;
const eventBus = new ClientEventBus(eventBusConfig);
eventBus.start();
const Devtools = lazy(() => import('../devtools/UZDPUP5E.js'));
const dispose = render(() => createComponent(DevtoolsProvider, {
plugins,
config,
onSetPlugins,
get children() {
return createComponent(PiPProvider, {
get children() {
return createComponent(Portal, {
mount: el,
get children() {
return createComponent(Devtools, {});
}
});
}
});
}
}), el);
return {
dispose,
eventBus
};
}
export { mountDevtools };
import { DevtoolsProvider, PiPProvider } from '../chunk/AP5L3KAF.js';
import '../chunk/BNGI36V3.js';
import { render, createComponent, Portal } from 'solid-js/web';
import { lazy } from 'solid-js';
import { ClientEventBus } from '@tanstack/devtools-event-bus/client';
function mountDevtools(options) {
const {
el,
plugins,
config,
eventBusConfig,
onSetPlugins
} = options;
const eventBus = new ClientEventBus(eventBusConfig);
eventBus.start();
const Devtools = lazy(() => import('../devtools/AKRRB3KC.js'));
const dispose = render(() => createComponent(DevtoolsProvider, {
plugins,
config,
onSetPlugins,
get children() {
return createComponent(PiPProvider, {
get children() {
return createComponent(Portal, {
mount: el,
get children() {
return createComponent(Devtools, {});
}
});
}
});
}
}), el);
return {
dispose,
eventBus
};
}
export { mountDevtools };
import { For, createSignal } from 'solid-js'
import {
MainPanel,
Section,
SectionDescription,
SectionIcon,
SectionTitle,
} from '@tanstack/devtools-ui'
import { SocialBubble } from '@tanstack/devtools-ui/icons'
import { useStyles } from '../styles/use-styles'
import { useHeadChanges } from '../hooks/use-head-changes'
type SocialMeta = {
title?: string
description?: string
image?: string
url?: string
}
type SocialReport = {
network: string
found: Partial<SocialMeta>
missing: Array<string>
}
const SOCIALS = [
{
network: 'Facebook',
tags: [
{ key: 'og:title', prop: 'title' },
{ key: 'og:description', prop: 'description' },
{ key: 'og:image', prop: 'image' },
{ key: 'og:url', prop: 'url' },
],
color: '#4267B2',
},
{
network: 'X/Twitter',
tags: [
{ key: 'twitter:title', prop: 'title' },
{ key: 'twitter:description', prop: 'description' },
{ key: 'twitter:image', prop: 'image' },
{ key: 'twitter:url', prop: 'url' },
],
color: '#1DA1F2',
},
{
network: 'LinkedIn',
tags: [
{ key: 'og:title', prop: 'title' },
{ key: 'og:description', prop: 'description' },
{ key: 'og:image', prop: 'image' },
{ key: 'og:url', prop: 'url' },
],
color: '#0077B5',
},
{
network: 'Discord',
tags: [
{ key: 'og:title', prop: 'title' },
{ key: 'og:description', prop: 'description' },
{ key: 'og:image', prop: 'image' },
{ key: 'og:url', prop: 'url' },
],
color: '#5865F2',
},
{
network: 'Slack',
tags: [
{ key: 'og:title', prop: 'title' },
{ key: 'og:description', prop: 'description' },
{ key: 'og:image', prop: 'image' },
{ key: 'og:url', prop: 'url' },
],
color: '#4A154B',
},
{
network: 'Mastodon',
tags: [
{ key: 'og:title', prop: 'title' },
{ key: 'og:description', prop: 'description' },
{ key: 'og:image', prop: 'image' },
{ key: 'og:url', prop: 'url' },
],
color: '#6364FF',
},
{
network: 'Bluesky',
tags: [
{ key: 'og:title', prop: 'title' },
{ key: 'og:description', prop: 'description' },
{ key: 'og:image', prop: 'image' },
{ key: 'og:url', prop: 'url' },
],
color: '#1185FE',
},
// Add more networks as needed
]
function SocialPreview(props: {
meta: SocialMeta
color: string
network: string
}) {
const styles = useStyles()
return (
<div
class={styles().seoPreviewCard}
style={{ 'border-color': props.color }}
>
<div class={styles().seoPreviewHeader} style={{ color: props.color }}>
{props.network} Preview
</div>
{props.meta.image ? (
<img
src={props.meta.image}
alt="Preview"
class={styles().seoPreviewImage}
/>
) : (
<div
class={styles().seoPreviewImage}
style={{
background: '#222',
color: '#888',
display: 'flex',
'align-items': 'center',
'justify-content': 'center',
'min-height': '80px',
width: '100%',
}}
>
No Image
</div>
)}
<div class={styles().seoPreviewTitle}>
{props.meta.title || 'No Title'}
</div>
<div class={styles().seoPreviewDesc}>
{props.meta.description || 'No Description'}
</div>
<div class={styles().seoPreviewUrl}>
{props.meta.url || window.location.href}
</div>
</div>
)
}
export const SeoTab = () => {
const [reports, setReports] = createSignal<Array<SocialReport>>(analyzeHead())
const styles = useStyles()
function analyzeHead(): Array<SocialReport> {
const metaTags = Array.from(document.head.querySelectorAll('meta'))
const reports: Array<SocialReport> = []
for (const social of SOCIALS) {
const found: Partial<SocialMeta> = {}
const missing: Array<string> = []
for (const tag of social.tags) {
const meta = metaTags.find(
(m) =>
(tag.key.includes('twitter:')
? false
: m.getAttribute('property') === tag.key) ||
m.getAttribute('name') === tag.key,
)
if (meta && meta.getAttribute('content')) {
found[tag.prop as keyof SocialMeta] =
meta.getAttribute('content') || undefined
} else {
missing.push(tag.key)
}
}
reports.push({ network: social.network, found, missing })
}
return reports
}
useHeadChanges(() => {
setReports(analyzeHead())
})
return (
<MainPanel withPadding>
<Section>
<SectionTitle>
<SectionIcon>
<SocialBubble />
</SectionIcon>
Social previews
</SectionTitle>
<SectionDescription>
See how your current page will look when shared on popular social
networks. The tool checks for essential meta tags and highlights any
that are missing.
</SectionDescription>
<div class={styles().seoPreviewSection}>
<For each={reports()}>
{(report, i) => {
const social = SOCIALS[i()]
return (
<div>
<SocialPreview
meta={report.found}
color={social!.color}
network={social!.network}
/>
{report.missing.length > 0 ? (
<>
<div class={styles().seoMissingTagsSection}>
<strong>Missing tags for {social?.network}:</strong>
<ul class={styles().seoMissingTagsList}>
<For each={report.missing}>
{(tag) => (
<li class={styles().seoMissingTag}>{tag}</li>
)}
</For>
</ul>
</div>
</>
) : null}
</div>
)
}}
</For>
</div>
</Section>
</MainPanel>
)
}