Skip to content

Instantly share code, notes, and snippets.

@Blankeos
Last active November 28, 2024 10:56
Show Gist options
  • Select an option

  • Save Blankeos/9af01916ece55c4f8b90eac1934bff90 to your computer and use it in GitHub Desktop.

Select an option

Save Blankeos/9af01916ece55c4f8b90eac1934bff90 to your computer and use it in GitHub Desktop.
Vike `useMeta`

Note

I already published an npm package: vike-metadata for react, solid, and vue.

Feel free to check those out instead!

Hi, this is a WIP hook I'm making for Vike to manage meta tags with similar DX as Next.js's Metadata API e.g. export const meta = {}.

I might make an npm package out of this, but for now, feel free to copy this and save it as a plain .ts file in your project.

Goals:

  • Achieve typesafe, repeatable, and pleasant DX in managing meta tags.
  • Seamless SSR and Client-side <title> and behaviors.
  • Seamless SSR for all meta tags.
  • Seamless Client-side updates of meta tags (not sure if necessary since most meta tags are only for SEO, but right now it works for title, description, and keywords. I might add a way to activate it though and disable it by default)
  • Solid.js
  • React (I think it works in React, just replace useConfig import)
  • Vue
  • "Universal Hook" - In Vike, universal hooks basically work on the component jsx, on +data.ts and everywhere else. useConfig is built on top of useConfig hence it's a universal hook.
  • 1:1 feature parity with Next.js's metadata API but with improvements. (not there yet but covers 80% of it so far)

Get Started

Assuming you've copied the ts somewhere in like use-meta.tsx

  1. Configure defaults
// ===========================================================================
// Config
// ===========================================================================
/** Edit the default values here. Treat this as your ROOT meta config. */
const DEFAULT_CONFIG = {
  title: 'Home | Solid Launch',
  description: 'An awesome app template by Carlo Taleon.',
  keywords: ["solid.js", "carlo", "react"]
} as UseMetaParams;
  1. Use in your pages
export function Page() {
  useMeta({
    title: getTitle('About'),
    description: 'Learn about why this template is cool.'
  })
}

Copy the File

// useMeta hook
// Written by Carlo Taleon (github.com/Blankeos)

import type { JSX } from 'solid-js';
import { useConfig } from 'vike-solid/useConfig';

// ===========================================================================
// Config
// ===========================================================================
/** Edit the default values here. Treat this as your ROOT meta config. */
const DEFAULT_CONFIG = {
  title: 'Home | Solid Launch',
  description: 'An awesome app template by Carlo Taleon.',
} as UseMetaParams;

// ===========================================================================
// Hook
// ===========================================================================

type UseMetaParams = {
  /**
   * If you're looking to do templates like `%s | Solid Launch` like Next.JS,
   * I'd argue the better way is to just create a utility function like:
   *
   * ```
   * const TITLE_TEMPLATE = '%s | Solid Launch';
   *
   * export function getTitle(title: string) {
   *    return TITLE_TEMPLATE.replace('%s', title);
   * }
   * ```
   *
   * @example
   * 'Home'
   * <title>Home</title>
   *
   * getTitle('Home')
   * <title>Home | Solid Launch</title>
   */
  title?: string;
  /**
   * @example
   * <meta name="description" content="An awesome app template by Carlo Taleon." />
   */
  description?: string;

  // ===========================================================================
  // Other types I copied from Next.js
  // ===========================================================================

  // >>> BASIC FIELDS
  /**
   * @example
   * <meta name="generator" content="Next.js" />
   */
  generator?: string;
  /**
   * @example
   * <meta name="application-name" content="Solid Launch" />
   */
  applicationName?: string;
  /**
   * @example
   * <meta name="referrer" content="origin-when-cross-origin" />
   */
  referrer?:
    | 'no-referrer'
    | 'origin'
    | 'no-referrer-when-downgrade'
    | 'origin-when-cross-origin'
    | 'same-origin'
    | 'strict-origin'
    | 'strict-origin-when-cross-origin';
  /**
   * @example
   * <meta name="keywords" content="Next.js,React,JavaScript" />
   */
  keywords?: string | string[];
  /**
   * Has multiple behaviors.
   *
   * @example
   * "Josh"
   * <meta name="author" content="Josh" />
   *
   * { name: "Josh", url: "https://josh.com" }
   * <link rel="author" href="https://josh.com" />
   * <meta name="author" content="Josh" />
   */
  authors?: Author | Author[];
  /**
   * @example
   * <meta name="creator" content="Jiachi Liu" />
   */
  creator?: string;
  /**
   * @example
   * <meta name="publisher" content="Sebastian Markbåge" />
   */
  publisher?: string;
  /**
   * @example
   * <meta name="format-detection" content="telephone=no, address=no, email=no" />
   */
  formatDetection?: {
    address?: boolean;
    date?: boolean;
    email?: boolean;
    telephone?: boolean;
    url?: boolean;
  };

  // >>> OPEN GRAPH
  openGraph?: OpenGraph;

  /**
   * The robots setting for the document.
   *
   * @example
   * "index, follow"
   * <meta name="robots" content="index, follow" />
   *
   * { index: false, follow: false }
   * <meta name="robots" content="noindex, nofollow" />
   */
  robots?:
    | string
    | (RobotsInfo & {
        googleBot?: string | RobotsInfo;
      });

  // >>> Icons - has complicated internals, so we'll see if I implement this. I think you can use `manifest.json` anyway.
  // icons?: {
  //   /** <link rel="shortcut icon" href="/shortcut-icon.png" /> */
  //   icon?: string;
  //   /** <link rel="icon" href="/icon.png" /> */
  //   shortcut?: string;
  //   /** <link rel="apple-touch-icon" href="/apple-icon.png" /> */
  //   apple?: string;
  //   /**
  //    * <link rel="apple-touch-icon-precomposed" href="/apple-touch-icon-precomposed.png" />
  //    */
  //   other?: {
  //     rel?: string;
  //     url?: string;
  //   };
  // };

  // >>> Manifest
  /**
   * Manifest.json path
   * <link rel="manifest" href="https://nextjs.org/manifest.json" />
   */
  manifest?: string | URL;

  // >>> Twitter
  twitter?: Twitter;

  /** ⭐️ */
  viewport?: ViewPort;

  /**
   *
   * @example
   * { verification: { google: "1234567890", yandex: "1234567890", "me": "1234567890" } }
   * <meta name="google-site-verification" content="1234567890" />
   * <meta name="yandex-verification" content="1234567890" />
   * <meta name="me" content="@me" />
   */
  verification?: { [key: string]: string };

  /**
   * ⭐️
   *
   * All metadata options should be covered using the built-in support. However,
   * there may be custom metadata tags specific to your site, or brand new metadata
   * tags just released. You can use the other option to render any custom metadata tag.
   *
   * @example
   * // { "xxx": "yyy"}
   * // Generates <meta name="xxx" content="yyy" />
   *
   * // { "xxx": [123, 456] }
   * // Generates
   * <meta name="xxx" content="123" />
   * <meta name="xxx" content="456" />
   *
   */
  other?: { [key: string]: string | number | (string | number)[] };

  /**
   * ⭐️
   *
   * If you prefer not using the type-safe data format and just want to copy-paste
   * whatever meta you currently have. You can also just paste it here.
   *
   * otherJSX: () => (
   *    <>
   *       <meta ... />
   *       <meta ... />
   *       <meta ... />
   *    </>
   * );
   */
  otherJSX?: () => JSX.Element;
};

/**
 * 🎖️ This is currently, in my opinion, the BEST way to handle <head> in Vike.
 * Currently, Vike has multiple ways to handle the Head leading to mix-ups for beginners
 * eg. <Head />, useConfig(), +title, +description.
 * as well as some gotchas like <Head /> can't change title on clientside, +title can. +description can't.
 *
 * This hook gives a familiar API with more expected behaviors as a developer (in my opinion).
 * No gotchas, it just works. In SSR, In the browser, or if you need dynamic values.
 * Until Vike gets better, please just use this.
 *
 * What you have to do:
 * - Edit this component for default values.
 * - This hook must only be used once for every page.
 * - Has a very close API as NextJS's `export meta = {}`.
 */
export function useMeta(params: UseMetaParams) {
  const setConfig = useConfig();

  const values = {
    // Sensible Title & Description Defaults for HTML, OG, and Twitter.
    title: params.title ?? DEFAULT_CONFIG.title,
    description: params.description ?? DEFAULT_CONFIG.description,

    // Other values here:
    generator: params.generator ?? DEFAULT_CONFIG.generator,
    applicationName: params.applicationName ?? DEFAULT_CONFIG.applicationName,
    referrer: params.referrer ?? DEFAULT_CONFIG.referrer,
    keywords: params.keywords ?? DEFAULT_CONFIG.keywords,
    authors: params.authors ?? DEFAULT_CONFIG.authors,
    creator: params.creator ?? DEFAULT_CONFIG.creator,
    publisher: params.publisher ?? DEFAULT_CONFIG.publisher,
    formatDetection: params.formatDetection ?? DEFAULT_CONFIG.formatDetection,

    // Open Graph
    openGraph: {
      title: params.openGraph?.title ?? params.title ?? DEFAULT_CONFIG.title,
      description:
        params.openGraph?.description ?? params.description ?? DEFAULT_CONFIG.description,
      url: params.openGraph?.url ?? DEFAULT_CONFIG?.openGraph?.url,
      siteName: params.openGraph?.siteName ?? DEFAULT_CONFIG?.openGraph?.siteName,
      images: params.openGraph?.images ?? DEFAULT_CONFIG?.openGraph?.images,
      videos: params.openGraph?.videos ?? DEFAULT_CONFIG?.openGraph?.videos,
      audio: params.openGraph?.audio ?? DEFAULT_CONFIG?.openGraph?.audio,
      locale: params.openGraph?.locale ?? DEFAULT_CONFIG?.openGraph?.locale,
      type: ((params.openGraph as any)?.type ?? (DEFAULT_CONFIG?.openGraph as any)?.type) as
        | string
        | undefined,
    },

    // Robots
    robots: params.robots ?? DEFAULT_CONFIG.robots,

    // Manifest
    manifest: params.manifest ?? DEFAULT_CONFIG.manifest,

    // Twitter
    twitter: {
      // Metadata
      creator: params.twitter?.creator ?? DEFAULT_CONFIG?.twitter?.creator,
      creatorId: params.twitter?.creatorId ?? DEFAULT_CONFIG?.twitter?.creatorId,
      description: params.twitter?.description ?? params.description ?? DEFAULT_CONFIG.description,
      images: params.twitter?.images ?? DEFAULT_CONFIG?.twitter?.images,
      site: params.twitter?.site ?? DEFAULT_CONFIG?.twitter?.site,
      siteId: params.twitter?.siteId ?? DEFAULT_CONFIG?.twitter?.siteId,
      title: params.twitter?.title ?? params.title ?? DEFAULT_CONFIG.title,

      // Card
      card: ((params.twitter as any)?.card ?? (DEFAULT_CONFIG?.twitter as any)?.card) as
        | string
        | undefined,

      // Player
      players:
        (params.twitter as Extract<Twitter, { card: 'player' }>)?.players ??
        (DEFAULT_CONFIG?.twitter as any)?.players,

      // App
      app:
        (params?.twitter as Extract<Twitter, { card: 'app' }>)?.app ??
        (DEFAULT_CONFIG?.twitter as any)?.app,
    },

    // ViewPort
    viewport: params.viewport ?? DEFAULT_CONFIG.viewport,

    // Verification
    verification: params.verification ?? DEFAULT_CONFIG.verification,

    // Other
    other: params.other ?? DEFAULT_CONFIG.other,

    // Other JSX
    otherJSX: params.otherJSX ?? DEFAULT_CONFIG.otherJSX,
  };

  function Head_() {
    return (
      <>
        {renderMetaName('generator', values.generator)}
        {renderMetaName('application-name', values.applicationName)}
        {renderMetaName('referrer', values.referrer)}
        {renderMetaName(
          'keywords',
          values?.keywords?.length ? returnOrJoin(values.keywords) : null
        )}
        {renderArrayable(values?.authors, (item) => (
          <>
            {renderMetaName('author', item.name)}
            {item.url ? <link rel="author" href={item.url?.toString()} /> : null}
          </>
        ))}
        {renderMetaName('creator', values?.creator)}
        {renderMetaName('publisher', values?.publisher)}
        {renderMetaName(
          'format-detection',
          values?.formatDetection ? parseFormatDetection(values.formatDetection) : null
        )}

        {renderOpenGraphMetadata(values?.openGraph)}

        {renderMetaName('robots', values?.robots ? parseRobotsInfo(values.robots) : null)}

        {renderMetaName(
          'googlebot',
          (values?.robots as any)?.googleBot
            ? parseRobotsInfo((values?.robots as any)?.googleBot)
            : null
        )}

        {values?.manifest ? <link rel="manifest" href={values.manifest?.toString()} /> : null}

        {renderTwitterMetadata(values.twitter)}

        {renderViewPortMetadata(values.viewport)}

        {renderMetaNameMap(values?.verification)}

        {renderMetaNameMap(values?.other)}

        {values.otherJSX}
      </>
    );
  }

  setConfig({
    title: values.title,
    description: values.description,
    /** @ts-ignore */
    Head: Head_,
  });

  // Special Workarounds

  // > Server-side
  if (typeof window === 'undefined') {
  }
  // > Client-side
  else {
    if (values.openGraph.title) {
      createIfNotExistsMetaProperty('og:title', values.openGraph.title);
    }
    if (values.description) {
      createIfNotExistsMetaName('description', values.description);
    }

    if (values.openGraph.description) {
      createIfNotExistsMetaProperty('og:description', values.openGraph.description);
    }

    if (values.twitter.title) {
      createIfNotExistsMetaName('twitter:title', values.twitter.title);
    }

    if (values.twitter.description) {
      createIfNotExistsMetaName('twitter:description', values.twitter.description);
    }

    if (values.keywords?.length) {
      createIfNotExistsMetaName('keywords', returnOrJoin(values.keywords));
    }
  }
}

// ===========================================================================
// Utilities
// ===========================================================================

// >>> Utilities for Server-Side (JS-framework specific)
function renderMetaName(name: string, value: any) {
  return value ? <meta name={name} content={value} /> : null;
}

function renderMetaProperty(property: string, value: any) {
  return value ? <meta property={property} content={value} /> : null;
}

function renderArrayable<T>(
  value: T,
  render: (item: _RemoveArray<T>, index: number) => JSX.Element
) {
  if (!value) return null;

  if (Array.isArray(value)) {
    return value.map(render);
  }

  return render(value as any, 0);
}

function renderOpenGraphMetadata(value: UseMetaParams['openGraph']) {
  if (!value) return null;

  function _renderOGArticle(value: Extract<UseMetaParams['openGraph'], { type: 'article' }>) {
    if (value.type !== 'article') return null;
    return (
      <>
        {renderArrayable(value.authors, (item) => renderMetaProperty('article:author', item))}
        {renderMetaProperty('article:expiration_time', value.expirationTime)}
        {renderMetaProperty('article:modified_time', value.modifiedTime)}
        {renderMetaProperty('article:published_time', value.publishedTime)}
        {renderMetaProperty('article:section', value.section)}
        {renderArrayable(value.tags, (item) => renderMetaProperty('article:tag', item))}
      </>
    );
  }

  function _renderOGBook(value: Extract<UseMetaParams['openGraph'], { type: 'book' }>) {
    if (value.type !== 'book') return null;
    return (
      <>
        {renderArrayable(value.authors, (item) => renderMetaProperty('article:author', item))}
        {renderMetaProperty('book:isbn', value.isbn)}
        {renderMetaProperty('book:release_date', value.releaseDate)}
        {renderArrayable(value.tags, (item) => renderMetaProperty('article:tag', item))}
      </>
    );
  }

  function _renderOGProfile(value: Extract<UseMetaParams['openGraph'], { type: 'profile' }>) {
    if (value.type !== 'profile') return null;
    return (
      <>
        {renderMetaProperty('profile:first_name', value.firstName)}
        {renderMetaProperty('profile:last_name', value.lastName)}
        {renderMetaProperty('profile:username', value.username)}
        {renderMetaProperty('profile:gender', value.gender)}
      </>
    );
  }

  function _renderOGMusicSong(value: Extract<UseMetaParams['openGraph'], { type: 'music.song' }>) {
    if (value.type !== 'music.song') return null;
    return (
      <>
        {renderArrayable(value.albums, (item) => {
          if (typeof item === 'string' || item instanceof URL) {
            return renderMetaProperty('music:album', item);
          }

          return (
            <>
              {renderMetaProperty('music:album:disc', item?.disc)}
              {renderMetaProperty('music:album:track', item?.track)}
              {renderMetaProperty('music:album', item?.url)}
            </>
          );
        })}
        {renderMetaProperty('music:duration', value.duration)}
        {renderArrayable(value.musicians, (item) => renderMetaProperty('music:musician', item))}
      </>
    );
  }

  function _renderOGMusicAlbum(
    value: Extract<UseMetaParams['openGraph'], { type: 'music.album' }>
  ) {
    if (value.type !== 'music.album') return null;
    return (
      <>
        {renderArrayable(value.musicians, (item) => renderMetaProperty('music:musician', item))}
        {renderMetaProperty('music:release_date', value.releaseDate)}
        {renderArrayable(value.songs, (item) => {
          if (typeof item === 'string' || item instanceof URL) {
            return renderMetaProperty('music:song', item);
          }

          return (
            <>
              {renderMetaProperty('music:song:disc', item?.disc)}
              {renderMetaProperty('music:song:track', item?.track)}
              {renderMetaProperty('music:song', item?.url)}
            </>
          );
        })}
      </>
    );
  }

  function _renderOGMusicPlaylist(
    value: Extract<UseMetaParams['openGraph'], { type: 'music.playlist' }>
  ) {
    if (value.type !== 'music.playlist') return null;
    return (
      <>
        {renderArrayable(value.creators, (item) => renderMetaProperty('music:creator', item))}
        {renderArrayable(value.songs, (item) => {
          if (typeof item === 'string' || item instanceof URL) {
            return renderMetaProperty('music:song', item);
          }

          return (
            <>
              {renderMetaProperty('music:song:disc', item?.disc)}
              {renderMetaProperty('music:song:track', item?.track)}
              {renderMetaProperty('music:song', item?.url)}
            </>
          );
        })}
      </>
    );
  }

  function _renderOGMusicRadioStation(
    value: Extract<UseMetaParams['openGraph'], { type: 'music.radio_station' }>
  ) {
    if (value.type !== 'music.radio_station') return null;
    return (
      <>{renderArrayable(value.creators, (item) => renderMetaProperty('music:creator', item))}</>
    );
  }

  function _renderOGVideoMovie(
    value: Extract<UseMetaParams['openGraph'], { type: 'video.movie' }>
  ) {
    if (value.type !== 'video.movie') return null;
    return (
      <>
        {renderArrayable(value.actors, (item) => {
          if (typeof item === 'string' || item instanceof URL) {
            return renderMetaProperty('video:actor', item);
          }

          return (
            <>
              {renderMetaProperty('video:actor:role', item?.role)}
              {renderMetaProperty('video:actor', item?.url)}
            </>
          );
        })}

        {renderArrayable(value.directors, (item) => renderMetaProperty('video:director', item))}

        {renderMetaProperty('video:duration', value.duration)}
        {renderMetaProperty('video:release_date', value.releaseDate)}

        {renderArrayable(value.tags, (item) => renderMetaProperty('video.tag', item))}
        {renderArrayable(value.writers, (item) => renderMetaProperty('video:writer', item))}
      </>
    );
  }

  function _renderOGVideoEpisode(
    value: Extract<UseMetaParams['openGraph'], { type: 'video.episode' }>
  ) {
    if (value.type !== 'video.episode') return null;
    return (
      <>
        {renderArrayable(value.actors, (item) => {
          if (typeof item === 'string' || item instanceof URL) {
            return renderMetaProperty('video:actor', item);
          }

          return (
            <>
              {renderMetaProperty('video:actor:role', item?.role)}
              {renderMetaProperty('video:actor', item?.url)}
            </>
          );
        })}

        {renderArrayable(value.directors, (item) => renderMetaProperty('video:director', item))}

        {renderMetaProperty('video:duration', value.duration)}
        {renderMetaProperty('video:release_date', value.releaseDate)}
        {renderMetaProperty('video:series', value.series)}

        {renderArrayable(value.tags, (item) => renderMetaProperty('video.tag', item))}
        {renderArrayable(value.writers, (item) => renderMetaProperty('video:writer', item))}
      </>
    );
  }

  return (
    <>
      {renderArrayable(value?.alternateLocale, (item) =>
        renderMetaProperty('og:locale:alternate', item)
      )}
      {renderArrayable(value?.audio, (item) => {
        if (typeof item === 'string' || item instanceof URL) {
          return <meta property="og:audio" content={item?.toString()} />;
        }

        return (
          <>
            {renderMetaProperty('og:audio', item?.url)}
            {renderMetaProperty('og:audio:type', item?.type)}
          </>
        );
      })}
      {renderMetaProperty('og:country-name', value?.countryName)}
      {renderMetaProperty('og:determiner', value?.determiner)}
      {renderArrayable(value?.emails, (item) => renderMetaProperty('og:email', item))}
      {renderArrayable(value?.faxNumbers, (item) => renderMetaProperty('og:fax_number', item))}

      {renderArrayable(value?.images, (item) => {
        if (typeof item === 'string' || item instanceof URL) {
          return <meta property="og:image" content={item?.toString()} />;
        }

        return (
          <>
            {renderMetaProperty('og:image', item?.url)}
            {renderMetaProperty('og:image:secure_url', item?.secureUrl)}
            {renderMetaProperty('og:image:width', item?.width)}
            {renderMetaProperty('og:image:height', item?.height)}
            {renderMetaProperty('og:image:alt', item?.alt)}
          </>
        );
      })}
      {renderMetaProperty('og:locale', value?.locale)}
      {renderArrayable(value?.phoneNumbers, (item) => renderMetaProperty('og:phone_number', item))}
      {renderMetaProperty('og:site_name', value?.siteName)}
      {renderMetaProperty('og:ttl', value?.ttl)}
      {renderMetaProperty('og:url', value?.url)}

      {renderArrayable(value?.videos, (item) => {
        if (typeof item === 'string' || item instanceof URL) {
          return <meta property="og:video" content={item?.toString()} />;
        }

        return (
          <>
            {renderMetaProperty('og:video', item?.url)}
            {renderMetaProperty('og:video:secure_url', item?.secureUrl)}
            {renderMetaProperty('og:video:type', item?.type)}
            {renderMetaProperty('og:video:width', item?.width)}
            {renderMetaProperty('og:video:height', item?.height)}
          </>
        );
      })}

      {renderMetaProperty('og:type', (value as any)?.type)}

      {_renderOGArticle(value as any)}
      {_renderOGBook(value as any)}
      {_renderOGProfile(value as any)}
      {_renderOGMusicSong(value as any)}
      {_renderOGMusicAlbum(value as any)}
      {_renderOGMusicPlaylist(value as any)}
      {_renderOGMusicRadioStation(value as any)}
      {_renderOGVideoMovie(value as any)}
      {_renderOGVideoEpisode(value as any)}
    </>
  );
}

function renderTwitterMetadata(value: UseMetaParams['twitter']) {
  if (!value) return null;

  function renderTwitterAppMetadata(value: Extract<Twitter, { card: 'app' }>['app']) {
    if (!value) return null;

    return (
      <>
        {renderMetaName('twitter:app:id:googleplay', value?.id?.googleplay)}
        {renderMetaName('twitter:app:url:googleplay', value?.url?.googleplay)}

        {renderMetaName('twitter:app:id:iphone', value?.id?.iphone)}
        {renderMetaName('twitter:app:url:iphone', value?.url?.iphone)}

        {renderMetaName('twitter:app:id:ipad', value?.id?.ipad)}
        {renderMetaName('twitter:app:url:ipad', value?.url?.ipad)}

        {renderMetaName(
          'twitter:app:name:googleplay',
          value?.id?.googleplay ? value?.name : undefined
        )}
        {renderMetaName('twitter:app:name:iphone', value?.id?.iphone ? value?.name : undefined)}
        {renderMetaName('twitter:app:name:ipad', value?.id?.ipad ? value?.name : undefined)}
      </>
    );
  }

  return (
    <>
      {renderMetaName('twitter:creator', value.creator)}
      {renderMetaName('twitter:creator:id', value.creatorId)}
      {renderMetaName('twitter:description', value.description)}
      {renderMetaName('twitter:site', value.site)}
      {renderMetaName('twitter:site:id', value.siteId)}
      {renderMetaName('twitter:title', value.title)}

      {renderMetaName('twitter:card', (value as any).card)}

      {renderArrayable(value?.images, (item) => {
        if (typeof item === 'string' || item instanceof URL) {
          return <meta name="twitter:image" content={item?.toString()} />;
        }

        return (
          <>
            <meta name="twitter:image" content={item.url?.toString()} />;
            {renderMetaName('twitter:image:alt', item.alt)}
          </>
        );
      })}

      {renderArrayable((value as Extract<Twitter, { card: 'player' }>)?.players, (item) => (
        <>
          {renderMetaName('twitter:player', item.playerUrl)}
          {renderMetaName('twitter:player:width', item.width)}
          {renderMetaName('twitter:player:height', item.height)}
          {renderMetaName('twitter:player:stream', item.streamUrl)}
        </>
      ))}

      {renderTwitterAppMetadata((value as any)?.app)}
    </>
  );
}

function renderViewPortMetadata(value: UseMetaParams['viewport']) {
  if (!value) return null;

  return (
    <>
      {renderMetaName('color-scheme', value.colorScheme)}
      {renderArrayable(value.themeColor, (item) => {
        if (typeof item === 'string') {
          return <meta name="theme-color" content={item} />;
        }

        if (item.media) {
          return <meta name="theme-color" media={item.media} content={item.color} />;
        }

        return <meta name="theme-color" content={item.color} />;
      })}
      {renderMetaName('viewport', parseViewport(value))}
    </>
  );
}

function renderMetaNameMap(records?: { [key: string]: string | number | (string | number)[] }) {
  if (!records) return null;

  return Object.entries(records).map(([_key, _value]) => {
    if (Array.isArray(_value)) {
      return renderArrayable(_value, (item) => renderMetaName(_key, item));
    }

    return renderMetaName(_key, _value);
  });
}

// >>> Utilities for Client-Side (Vanilla friendly)

function createIfNotExistsMetaName(name: string, value: any) {
  let metaElement = document.querySelector(`meta[name="${name}"]`);
  if (!metaElement) {
    metaElement = document.createElement('meta');
    metaElement.setAttribute('name', name);
    document.head.appendChild(metaElement);
  }
  metaElement.setAttribute('content', value);
}

function createIfNotExistsMetaProperty(property: string, value: any) {
  let metaElement = document.querySelector(`meta[property="${property}"]`);
  if (!metaElement) {
    metaElement = document.createElement('meta');
    metaElement.setAttribute('property', property);
    document.head.appendChild(metaElement);
  }
  metaElement.setAttribute('content', value);
}

/** Used by keywords. */
function returnOrJoin(value: string | string[]) {
  if (typeof value === 'string') {
    return value;
  }
  return value.join(',');
}

function parseFormatDetection(value: UseMetaParams['formatDetection']) {
  if (!value) return undefined;

  let content = '';

  if (typeof value?.email !== 'undefined') {
    if (value.email) content += 'email=yes,';
    else content += 'email=no,';
  }

  if (typeof value?.telephone !== 'undefined') {
    if (value.telephone) content += 'telephone=yes,';
    else content += 'telephone=no,';
  }

  if (typeof value?.address !== 'undefined') {
    if (value.address) content += 'address=yes,';
    else content += 'address=no,';
  }

  if (typeof value?.url !== 'undefined') {
    if (value.url) content += 'url=yes,';
    else content += 'url=no,';
  }

  if (typeof value?.date !== 'undefined') {
    if (value.date) content += 'date=yes';
    else content += 'date=no';
  }

  return content;
}

function parseRobotsInfo(value: UseMetaParams['robots'] | string) {
  if (!value) return null;
  if (typeof value === 'string') return value;

  let content = [];

  if (value?.follow !== undefined || value?.nofollow !== undefined) {
    if (value.follow === true || value.nofollow === false) content.push('follow');
    else content.push('nofollow');
  }

  if (value?.index !== undefined || value?.noindex !== undefined) {
    if (value.index === true || value.noindex === false) content.push('index');
    else content.push('noindex');
  }

  if (value?.indexifembedded === true) {
    content.push('indexifembedded');
  }

  if (value?.['max-image-preview'] !== undefined) {
    content.push(`max-image-preview:${value['max-image-preview']}`);
  }

  if (value?.['max-snippet'] !== undefined) {
    content.push(`max-snippet:${value['max-snippet']}`);
  }

  if (value?.['max-video-preview'] !== undefined) {
    content.push(`max-video-preview:${value['max-video-preview']}`);
  }

  if (value?.noarchive === true) {
    content.push('noarchive');
  }

  if (value?.nocache === true) {
    content.push('nocache');
  }

  if (value?.noimageindex === true) {
    content.push('noimageindex');
  }

  if (value?.nositelinkssearchbox === true) {
    content.push('nositelinkssearchbox');
  }

  if (value?.nosnippet === true) {
    content.push('nosnippet');
  }

  if (value?.notranslate === true) {
    content.push('notranslate');
  }

  if (value?.unavailable_after !== undefined) {
    content.push(`unavailable_after: ${value.unavailable_after}`);
  }

  return content.join(', ');
}

function parseViewport(value: UseMetaParams['viewport']) {
  if (!value) return null;

  let content = [];

  if (value.width !== undefined) {
    content.push(`width=${value.width}`);
  }

  if (value.height !== undefined) {
    content.push(`height=${value.height}`);
  }

  if (value.initialScale !== undefined) {
    content.push(`initial-scale=${value.initialScale}`);
  }

  if (value.interactiveWidget !== undefined) {
    content.push(`interactive-widget=${value.interactiveWidget}`);
  }

  if (value.maximumScale !== undefined) {
    content.push(`maximum-scale=${value.maximumScale}`);
  }

  if (value.minimumScale !== undefined) {
    content.push(`minimum-scale=${value.minimumScale}`);
  }

  if (value.userScalable !== undefined) {
    if (value.userScalable === true) content.push('user-scalable=yes');
    content.push('user-scalable=no');
  }

  if (value.viewportFit !== undefined) {
    content.push(`viewport-fit=${value.viewportFit}`);
  }

  return content.join(', ');
}

// ===========================================================================
// Internals
// ===========================================================================

type _RemoveArray<T> = T extends any[] | undefined ? never : T;

type TwitterImageDescriptor = {
  alt?: string;
  /** Twitter deprecated this in 2015. */
  height?: string | number;
  /** Unused, not sure why NextJS has this. */
  secureUrl?: string | URL;
  /** Unused, not sure why NextJS has this. */
  type?: string;
  url: string | URL;
  /** Twitter deprecated this in 2015. */
  width?: string | number;
};

type TwitterImage = string | TwitterImageDescriptor | URL;

type TwitterMetadata = {
  /**  <meta name="twitter:creator" content="@nextjs" /> */
  creator?: string;
  /** <meta name="twitter:creator:id" content="1467726470533754880" /> */
  creatorId?: string;
  /**
   * When not specified, uses `description` by default. (Vike doesn't do this)
   * <meta name="twitter:description" content="The React Framework for the Web" />
   */
  description?: string;
  /**
   * Must be absolute URLs.
   *
   * <meta name="twitter:image" content="https://nextjs.org/og.png" />
   */
  images?: TwitterImage | TwitterImage[];
  /** <meta name="twitter:site" content="" /> */
  site?: string;
  /** <meta name="twitter:site:id" content="1467726470533754880" /> */
  siteId?: string;
  /**
   * When not specified, uses `title` by default (Vike doesn't do this).
   * <meta name="twitter:title" content="Next.js" />
   */
  title?: string;
};

type TwitterPlayerDescriptor = {
  height: number;
  playerUrl: string | URL;
  streamUrl: string | URL;
  width: number;
};

type TwitterAppDescriptor = {
  id: {
    googleplay?: string;
    ipad?: string | number;
    iphone?: string | number;
  };
  name?: string;
  url?: {
    googleplay?: string | URL;
    ipad?: string | URL;
    iphone?: string | URL;
  };
};

type Twitter =
  | (TwitterMetadata & {
      /** <meta name="twitter:card" content="summary" />  */
      card: 'summary';
    })
  | (TwitterMetadata & {
      /** <meta name="twitter:card" content="summary_large_image" />  */
      card: 'summary_large_image';
    })
  | (TwitterMetadata & {
      /** <meta name="twitter:card" content="player" />  */
      card: 'player';
      players: TwitterPlayerDescriptor | TwitterPlayerDescriptor[];
    })
  | (TwitterMetadata & {
      app: TwitterAppDescriptor;
      /** <meta name="twitter:card" content="app" />  */
      card: 'app';
    })
  | TwitterMetadata;

type Author = {
  name?: string;
  url?: string | URL;
};

type OGAudioDescriptor = {
  secureUrl?: string | URL;
  type?: string;
  url: string | URL;
};

type OGAudio = string | OGAudioDescriptor | URL;

type OGImageDescriptor = {
  alt?: string;
  height?: string | number;
  secureUrl?: string | URL;
  type?: string;
  url: string | URL;
  width?: string | number;
};

type OGImage = string | OGImageDescriptor | URL;

type OGVideoDescriptor = {
  height?: string | number;
  secureUrl?: string | URL;
  type?: string;
  url: string | URL;
  width?: string | number;
};

type OGVideo = string | OGVideoDescriptor | URL;

type OpenGraphMetadata = {
  alternateLocale?: string | string[];
  audio?: OGAudio | OGAudio[];
  countryName?: string;
  /**
   * When not specified, uses `description` by default. (Vike does this by default)
   * <meta property="og:description" content="The React Framework for the Web" />
   */
  description?: string;
  determiner?: 'a' | 'an' | 'the' | 'auto' | '';
  emails?: string | string[];
  faxNumbers?: string | string[];
  /**
   * <meta property="og:image" content="https://nextjs.org/og.png" />
   * <meta property="og:image:width" content="800" />
   * <meta property="og:image:height" content="600" />
   */
  images?: OGImage | OGImage[];
  /**
   * <meta property="og:locale" content="en_US" />
   */
  locale?: string;
  phoneNumbers?: string | string[];
  siteName?: string;
  /**
   * When not specified, uses `title` by default. (Vike does this by default).
   * <meta property="og:title" content="Next.js" />
   */
  title?: string;
  ttl?: number;
  /**
   * <meta property="og:url" content="https://nextjs.org/" />
   */
  url?: string | URL;
  /**
   * <meta property="og:video" content="https://nextjs.org/video.mp4" />
   * <meta property="og:video:width" content="800" />
   * <meta property="og:video:height" content="600" />
   */
  videos?: OGVideo | OGVideo[];
};

type OGAlbumOrSong = {
  disc?: number;
  track?: number;
  url: string | URL;
};

type OGActor = {
  role?: string;
  url: string | URL;
};

type OpenGraph =
  | (OpenGraphMetadata & {
      /**
       * <meta property="og:type" content="website" />
       */
      type: 'website';
    })
  | (OpenGraphMetadata & {
      authors?: null | string | URL | (string | URL)[];
      expirationTime?: string;
      modifiedTime?: string;
      publishedTime?: string;
      section?: null | string;
      tags?: null | string | string[];
      type: 'article';
    })
  | (OpenGraphMetadata & {
      authors?: null | string | URL | (string | URL)[];
      isbn?: null | string;
      releaseDate?: null | string;
      tags?: null | string | string[];
      type: 'book';
    })
  | (OpenGraphMetadata & {
      firstName?: null | string;
      gender?: null | string;
      lastName?: null | string;
      type: 'profile';
      username?: null | string;
    })
  | (OpenGraphMetadata & {
      albums?: null | string | URL | OGAlbumOrSong | (string | URL | OGAlbumOrSong)[];
      duration?: null | number;
      musicians?: null | string | URL | (string | URL)[];
      type: 'music.song';
    })
  | (OpenGraphMetadata & {
      musicians?: null | string | URL | (string | URL)[];
      releaseDate?: null | string;
      songs?: null | string | URL | OGAlbumOrSong | (string | URL | OGAlbumOrSong)[];
      type: 'music.album';
    })
  | (OpenGraphMetadata & {
      creators?: null | string | URL | (string | URL)[];
      songs?: null | string | URL | OGAlbumOrSong | (string | URL | OGAlbumOrSong)[];
      type: 'music.playlist';
    })
  | (OpenGraphMetadata & {
      creators?: null | string | URL | (string | URL)[];
      type: 'music.radio_station';
    })
  | (OpenGraphMetadata & {
      actors?: null | string | URL | OGActor | (string | URL | OGActor)[];
      directors?: null | string | URL | (string | URL)[];
      duration?: null | number;
      releaseDate?: null | string;
      tags?: null | string | string[];
      type: 'video.movie';
      writers?: null | string | URL | (string | URL)[];
    })
  | (OpenGraphMetadata & {
      actors?: null | string | URL | OGActor | (string | URL | OGActor)[];
      directors?: null | string | URL | (string | URL)[];
      duration?: null | number;
      releaseDate?: null | string;
      series?: null | string | URL;
      tags?: null | string | string[];
      type: 'video.episode';
      writers?: null | string | URL | (string | URL)[];
    })
  | (OpenGraphMetadata & {
      type: 'video.tv_show';
    })
  | (OpenGraphMetadata & {
      type: 'video.other';
    })
  | OpenGraphMetadata;

type ThemeColorDescriptor = {
  color: string;
  media?: string;
};

type RobotsInfo = {
  follow?: boolean;
  index?: boolean;
  indexifembedded?: boolean;
  'max-image-preview'?: 'none' | 'standard' | 'large';
  'max-snippet'?: number;
  'max-video-preview'?: number | string;
  noarchive?: boolean;
  nocache?: boolean;
  nofollow?: never;
  noimageindex?: boolean;
  noindex?: never;
  nositelinkssearchbox?: boolean;
  nosnippet?: boolean;
  notranslate?: boolean;
  unavailable_after?: string;
};

/**
 * ⭐️
 */
type ViewPort = {
  /**
   * The color scheme for the document.
   *
   * @example
   * "dark"
   * <meta name="color-scheme" content="dark" />
   */
  colorScheme?: 'normal' | 'light' | 'dark' | 'light dark' | 'dark light' | 'only light';
  height?: string | number;
  initialScale?: number;
  interactiveWidget?: 'resizes-visual' | 'resizes-content' | 'overlays-content';
  maximumScale?: number;
  minimumScale?: number;
  /**
   * The theme color for the document.
   * @example
   * "#000000"
   * <meta name="theme-color" content="#000000" />
   * { media: "(prefers-color-scheme: dark)", color: "#000000" }
   * <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000" />
   *
   * [
   *  { media: "(prefers-color-scheme: dark)", color: "#000000" },
   *  { media: "(prefers-color-scheme: light)", color: "#ffffff" }
   * ]
   * <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000" />
   * <meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff" />
   */
  themeColor?: string | ThemeColorDescriptor | ThemeColorDescriptor[];
  userScalable?: boolean;
  viewportFit?: 'auto' | 'cover' | 'contain';
  width?: string | number;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment