Snippet

主に技術系の単発ネタを書き留めたメモ。同じところで悩む誰かの役に立てれば。

Next.jsサイトにsitemapを実装する

2021/01/18

/pages/sitemap.xml.tsx
import React from 'react';
import { GetServerSideProps } from 'next';

import { SITE_URL } from '../utils/env';
import { mergeUrlAndSlug, UrlTable } from '../utils/path/url';
import { getAvailableSlugs } from '../utils/article/fs.server';

const FIXED_URLS = [UrlTable.root, UrlTable.about, UrlTable.blog];
const siteUrl = SITE_URL.slice(0, -1);

const genUrl = (url: string) => `<url><loc>${siteUrl + url}</loc></url>`;
const genFromSlugs = async (urlBase: string, urlPost: string) => {
  const slugs = await getAvailableSlugs(urlBase);
  return slugs.map((slug) => genUrl(mergeUrlAndSlug(slug, urlPost)));
};

export const getServerSideProps: GetServerSideProps = async ({ res }) => {
  const smFixed = FIXED_URLS.map((url) => genUrl(url));
  const smBlogPosts = await genFromSlugs(UrlTable.blog, UrlTable.blogPosts);

  const sitemap = [...smFixed, ...smBlogPosts].join('');

  if (res) {
    res.setHeader('Content-Type', 'text/xml');
    res.write(`<?xml version="1.0" encoding="UTF-8"?>
    <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${sitemap}
    </urlset>`);
    res.end();
  }

  return {
    props: {},
  };
};

const Sitemap: React.FC = () => null;

export default Sitemap;

sitemap も pages でレンダリングする。コツはファイル名を /pages/sitemap.xml.tsx として XML を返すことを明示することと、SSR(getServerSideProps) の res.setHeadertext/xml として返すこと。

  • SSG のページは、固定の URL テーブルを作成して、それを返す。
  • slug などの dynamic route を使っているページは、getStaticPaths で利用している関数をそのまま sitemap.xml.tsx でも利用すればよい

references

markdownページでnext/imageを使う

2021/01/15

import React from 'react';
import Image from 'next/image';

type NextImageProps = {
  src: string;
  alt?: string;
};

export const NextImage: React.FC<NextImageProps> = ({ src, alt, ...props }) => (
  <div
    style={{
      display: 'flex',
      position: 'relative',
      justifyContent: 'center',
      alignItems: 'center',
      width: '100%',
      height: '16em',
      marginBottom: '1.75em',
      backgroundColor: '#f7fafc',
    }}
  >
    <Image {...props} src={src} alt={alt || src} layout="fill" objectFit="contain" />
  </div>
);

markdown で利用する画像サイズは予測しづらいので、next/image の layout="fill" を使い、動的にリクエストを変更してもらう。親コンテナ(のサイズ)が必要になるので、div で wrap しておき、画像自身は objectFit="contain" で枠に収まるように設定した。

import remarkUnwrapImages from 'remark-unwrap-images';

const getDefaultMdxOptions = () => ({
  // ...
  remarkPlugins: [
    // ...
    remarkUnwrapImages,
  ],
});

markdown のデフォルトだと、image をpで囲ってしまい、p -> divの HTML 違反となってしまうため、remark-unwrap-images で p を外す。next-mdx-remote の mdxOptions に remarkUnwrapImages を追加することで対応できる。

references

markdownにカスタムブロックを追加する

2021/01/12

yarn add remark-custom-blocks

remark-custom-blocks を使うと、markdown にカスタムブロックを追加できる。

import remarkCustomBlocks from 'remark-custom-blocks';

const getDefaultMdxOptions = () => ({
  // ...
  remarkPlugins: [
    [
      remarkCustomBlocks,
      {
        exercise: {
          classes: 'exercise',
          title: 'required',
        },
      },
    ],
  ],
});

next-mdx-remote の mdxOptions に remarkCustomBlocks を追加し、オプションを設定する。classes でブロックにあてる CSS クラス名を、title でタイトル部分が必須かどうかを設定できる。

/* 
 * Custom Blocks
 */
.exercise,
.practice {
  border: 1px solid #e2e8f0;
  border-radius: 0.5em;
  margin-bottom: 1.75em;

  .custom-block-heading {
    padding: 0.5em 1em;
    border-radius: 0.5em 0.5em 0 0;
    font-size: 1em;
    font-weight: bold;
  }

  .custom-block-body {
    border-radius: 0 0 0.5em 0.5em;
    padding: 1em;
    font-size: 1em;

    & ol,
    & ul,
    & p {
      margin-bottom: 0;
    }
  }
}

.exercise {
  border-color: #81e6d9;

  .custom-block-heading {
    background-color: #e6fffa;
  }
}

CSS クラス名に対応するスタイルを記述する。今回はブロック全体を角丸矩形で囲い、タイトル部分に背景色を当てた。

練習問題ブロックのレンダリング結果
[[exercise | 練習問題 : 三角形の面積を求める]]
| 1. 整数を 1 つ受け取り、その値を 2 倍した値を表示してください
| 2. 整数を 2 つ受け取り、その値の積を表示してください
| 3. 小数を 2 つ受け取り、三角形の面積を求めるプログラムを記述してください
| 4. 問題(3)で、小数点以下は 1 桁で表示するようにしてください

[[name | title]] が開始部分になり、以下 | が続く限りブロックとみなす。| の内部で別の markdown 記法を使っても良い

references

ボタンを押すと滑らかにスクロールしてコンテンツを表示

2021/01/08

export const SomePage = () => {
  const refContents = useRef<HTMLDivElement>();

  const scrollToContents = useCallback(() => {
    refContents.current.scrollIntoView({
      behavior: 'smooth',
      block: 'start',
    });
  }, [refContents]);

  return (
    <VStack w="100%" minH="calc(100vh - 64px)">
      <Center h={16} pb={16}>
        <IconButton
          aria-label="Scroll to Contents section"
          icon={<FaArrowDown />}
          fontSize="2em"
          onClick={scrollToContents}
        />
      </Center>

      {/* 他の内容省略 */}

      <Box backgroundColor="gray.50" minH="16em" px={[0, 8, 16, 24]} ref={refContents}>
        {/* コンテンツ */}
      </Box>
    </VStack>
  );
};

scrollIntoView を使うと、対象の DOM へスクロールできる。

  • behavior: 'smooth' とすると、スムーススクロールでジャンプしてくれる
  • block で要素の先頭・中央・末尾のいずれへジャンプするかを指定できる
  • DOM 要素の参照は ref で取得し useRef を通して保存する。取得した参照は ref.current からアクセスできる
  • IconButton を利用すると、アイコンをボタン要素として利用できる。SVG アイコンであれば fontSize で大きさを指定できる
  • アイコンを利用したリンク要素やボタン要素には、aria-label をつけること。アイコン単独だと可読な文字列がなく、スクリーンリーダーなどに対応できないため。これに限らず a11y は意識していきたい

references

カルーセルでカードを表示する

2021/01/07

import React from 'react';
import { Box } from '@chakra-ui/react';
import Slick, { Settings } from 'react-slick';

import 'slick-carousel/slick/slick.css';
import 'slick-carousel/slick/slick-theme.css';

const settings: Settings = {
  dots: true,
  infinite: true,
  centerMode: true,
  slidesToShow: 3,
  autoplay: true,
  speed: 500,
  cssEase: 'ease-out',
  responsive: [
    {
      breakpoint: 992,
      settings: {
        slidesToShow: 2,
      },
    },
    {
      breakpoint: 640,
      settings: {
        slidesToShow: 1,
      },
    },
  ],
};

export const SlickArticles: React.FC = ({ articles }) => (
  <Slick {...settings}>
    {articles.map((a) => (
      <Box key={a.slug} p={[2, 4]}>
        <ArticleCard article={a} />
      </Box>
    ))}
  </Slick>
);

react-slick ライブラリを使えば達成できる。

  • responsive オプションで、タブレットビューやスマホビューにも対応できる。max-width タイプの指定になるので、Chakra で慣れている場合は逆指定になることに注意
  • autoplayinfinite で自動スクロール
  • centerMode を指定すると、左右にカードの一部が表示されるため、無限にあるように見えやすくなる。カードの枚数は slidesToShow で指定
  • エレメントに padding はつかないため、自分でつけてから渡す(ここでは Box でラップしている)
  • エレメントの高さにあった height に自動的になるが、dots ぶんの高さは確保されないので、margin-bottom などで調整する

references

SNSシェアボタンを上スクロール時のみフェードインさせる

2021/01/06

import React, { useState } from 'react';
import { VStack, SlideFade, Box, Icon, Placement, Tooltip } from '@chakra-ui/react';
import { useScrollPosition } from '@n8tb1t/use-scroll-position';
import { TwitterShareButton } from 'react-share';
import { FaTwitter } from 'react-icons/fa';

import { SITE_URL, TWITTER_ID } from '../../../utils/env';

type ShareButtonsLeftFixedProps = {
  urlBlog: string;
  title: string;
};

export const ShareButtonsLeftFixed: React.FC<ShareButtonsLeftFixedProps> = ({ title, urlBlog }) => {
  const [showShareButtons, setShowShareButtons] = useState(true);

  useScrollPosition(({ prevPos, currPos }) => {
    const visible = currPos.y > prevPos.y;
    setShowShareButtons(visible);
  }, []);

  const url = new URL(urlBlog, SITE_URL).toString();

  return (
    <Box
      position="fixed"
      left="calc(50vw - 28em)"
      top="7em"
      visibility={['hidden', 'hidden', 'hidden', 'visible']}
    >
      <SlideFade in={showShareButtons} offsetX="-1em" offsetY={0}>
        <VStack spacing={4} p={4} backgroundColor="gray.50" borderRadius={8}>
          <TwitterShareButton url={url} title={title} via={TWITTER_ID}>
            <Tooltip
              label="Twitterでシェア"
              shouldWrapChildren
              hasArrow
              placement={tooltipPlacement}
            >
              <Icon as={FaTwitter} boxSize={6} fill="gray.400" _hover={{ fill: 'teal.500' }} />
            </Tooltip>
          </TwitterShareButton>

          {/* 以下のボタンは省略 */}
        </VStack>
      </SlideFade>
    </Box>
  );
};
  • 上スクロールを検知するには use-scroll-position を利用する
  • スライドアニメーションのために Chakra-UI の SlideFade を利用する
  • タップすると何が起こるのかを提示するために Chakra-UI の Tooltip を利用する

references

SNSシェアボタンの作成

2021/01/05

import React from 'react';
import { Icon, Placement, Tooltip } from '@chakra-ui/react';
import {
  FacebookShareButton,
  HatenaShareButton,
  LineShareButton,
  PocketShareButton,
  TwitterShareButton,
} from 'react-share';
import { FaFacebook, FaGetPocket, FaLine, FaTwitter } from 'react-icons/fa';
import { SiHatenabookmark } from 'react-icons/si';

import { SITE_URL, TWITTER_ID } from '../../../utils/env';

type ShareButtonsProps = {
  urlBlog: string;
  title: string;
  tooltipPlacement: Placement;
};

export const ShareButtons: React.FC<ShareButtonsProps> = ({ urlBlog, title, tooltipPlacement }) => {
  const url = new URL(urlBlog, SITE_URL).toString();

  return (
    <>
      <TwitterShareButton url={url} title={title} via={TWITTER_ID}>
        <Icon as={FaTwitter} boxSize={6} fill="gray.400" _hover={{ fill: 'teal.500' }} />
      </TwitterShareButton>
      <FacebookShareButton url={url}>
        <Icon as={FaFacebook} boxSize={6} fill="gray.400" _hover={{ fill: 'teal.500' }} />
      </FacebookShareButton>
      <LineShareButton title={title} url={url}>
        <Icon as={FaLine} boxSize={6} fill="gray.400" _hover={{ fill: 'teal.500' }} />
      </LineShareButton>
      <PocketShareButton title={title} url={url}>
        <Icon as={FaGetPocket} boxSize={6} fill="gray.400" _hover={{ fill: 'teal.500' }} />
      </PocketShareButton>
      <HatenaShareButton title={title} url={url}>
        <Icon as={SiHatenabookmark} boxSize={6} fill="gray.400" _hover={{ fill: 'teal.500' }} />
      </HatenaShareButton>
    </>
  );
};

react-share ライブラリを使用する。外部スクリプトの読み込みや、テンプレート文字列の流し込みなど、必要な操作はすべて対応してくれる。

各業者のアイコンについては、今回はトーンを揃えたかったので react-icons の FontAwesome や Simple を利用した。react-share 自身にも各種アイコンは入っているので、それを利用しても OK。

import { FacebookIcon } from 'react-share'; // react-share の組み込み
import { FaFacebook } from 'react-icons/fa'; // react-icons を使う場合

<Icon as={FaTwitter} boxSize={6} fill="gray.400" _hover={{ fill: 'teal.500' }} />;

references

上スクロールしたときだけ表示されるヘッダ

2021/01/04

import { useScrollPosition } from '@n8tb1t/use-scroll-position';

export const Header: React.FC = ({ children }) => {
  const [showMenu, setShowMenu] = useState(true);

  useScrollPosition(({ prevPos, currPos }) => {
    const visible = currPos.y > prevPos.y;
    setShowMenu(visible);
  }, []);

  return (
    <Center
      as="nav"
      visibility={showMenu ? 'visible' : 'hidden'}
      transition={`all 200ms ${showMenu ? 'ease-in' : 'ease-out'}`}
      transform={showMenu ? 'none' : 'translate(0, -100%)'}
    >
      {children}
    </Center>
  );
};

useScrollPosition Hooks を利用して、スクロールイベントをフックする。前回と今回の Y 座標を比較して、今回のほうが大きい(上にスクロールした)場合は、ヘッダを表示する。

hooks の引数無指定の場合は document.body の boundingClientRect が参照される。下にスクロールすると、body の top は上方向に移動する= body の Y 座標はどんどんマイナス値になっていく

表示トランジションには、Chakra UI の Collapse などを利用しても良い。ただページ遷移の際にもアニメーションしてしまって、少し違和感を覚えたので、上記の例ではトランジションを自筆している。

references

mdxファイルのリンクにtarget=_blankなどを入れる

2021/01/03

import hydrate from 'next-mdx-remote/hydrate';

export const LinkWithTargetBlank = (props) => {
  const { href, ...rest } = props;
  if (href.startsWith('http'))
    return <a href={href} target="_blank" rel="noopener noreferrer" {...rest} />;

  return <Link to={href} {...rest} />;
};

hydrate(content, {
  components: {
    // ...
    a: LinkWithTargetBlank,
  },
});
  • href が外部リンクの場合は、a タグで展開し、target="_blank"rel="noopener noreferrer" を付与する
  • href が内部リンクの場合は、next/link で展開する

Chakra UI の Link が使える場合は、target, ref のかわりに isExternal を付与するだけで OK。

references

Next.jsにGoogleAnalyticsを入れる

2021/01/02

.env.production.local
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=YOUR_TRACKING_ID
utils/analytics/gtag.ts
export const GA_TRACKING_ID = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID;

export const pageview = (url: string) => {
  window.gtag('config', GA_TRACKING_ID, {
    page_path: url,
  });
};

export const event = ({ action, category, label, value }) => {
  if (!GA_TRACKING_ID) return;

  window.gtag('event', action, {
    event_category: category,
    event_label: JSON.stringify(label),
    value: value,
  });
};
_document.tsx
import { GA_TRACKING_ID } from '../utils/analytics/gtag';

// Update <Head /> as below
<Head>
  {GA_TRACKING_ID && (
    <>
      <script
        async
        src={`https://www.googletagmanager.com/gtag/js?id=${GA_TRACKING_ID}`}
      ></script>
      <script
        dangerouslySetInnerHTML={{
          __html: `
      window.dataLayer = window.dataLayer || [];
      function gtag(){dataLayer.push(arguments);}
      gtag('js', new Date());

      gtag('config', '${GA_TRACKING_ID}');`,
        }}
      ></script>
    </>
  )}
</Head>
_app.tsx
import React, { useEffect } from 'react';
import { useRouter } from 'next/router';

import * as gtag from '../utils/analytics/gtag';

const App: React.FC<AppProps> = ({ Component, pageProps }) => {
  const router = useRouter();
  useEffect(() => {
    if (!gtag.GA_TRACKING_ID) return;

    const handleRouteChange = (url: string) => {
      gtag.pageview(url);
    };

    router.events.on('routeChangeComplete', handleRouteChange);
    return () => {
      router.events.off('routeChangeComplete', handleRouteChange);
    };
  }, [router.events]);

  // ...
}
next-env.d.ts
interface Window {
  gtag(type: 'config', googleAnalyticsId: string, { page_path: string });
  gtag(
    type: 'event',
    eventAction: string,
    fieldObject: {
      event_label: string;
      event_category: string;
      value?: string;
    },
  );
}

references

Array.map で async/await を使う

2020/12/30

子要素を親要素からはみ出して配置するCSS

2020/12/30

.full {
  margin: 0 calc(50% - 50vw);
  padding: 0 calc(50vw - 50%);
  width: 100vw;
}

margin で画面端に寄せて、padding で中央に戻す。

発生した現象

例えば記事ページにおいて横幅を 40em で固定してあるときに、記事内部の画像やコードなどを全幅で表示したいような場合。position を指定する方法だと、height がわかってないとダメなので使えない。

具体的な手法

<div class="content">
  <div class="inner">
    <div class="full">ここを画面幅いっぱいにしたい</div>
  </div>
</div>
.content {
  overflow: hidden;
}

.inner {
  width: 40em;
  max-width: 100%;
  margin: 0 auto;
}

.full {
  margin: 0 calc(50% - 50vw);
  padding: 0 calc(50vw - 50%);
  width: 100vw;
}

position: static のままで問題なく機能する。

references

useRefの使いどき

2020/12/29

下記の 2 パターンで利用する;

  1. DOM ノードの参照を持つときに使う(e.g. <input ref={ref} />
  2. re-render を走らせたくない変数に使う(e.g. タイマー ID)

DOM ノードの参照を持つときに使う

ja.reactjs.org/docs/hooks-reference.html#useref
function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // `current` points to the mounted text input element
    inputEl.current.focus();
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

re-render を走らせたくない変数に使う

ja.reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables
function Timer() {
  const intervalRef = useRef();

  useEffect(() => {
    const id = setInterval(() => {
      // ...
    });
    intervalRef.current = id;
    return () => {
      clearInterval(intervalRef.current);
    };
  });

  // ...
}

関数コンポーネントにおける、クラスのインスタンス変数として使える。

useRef はオブジェクトを作ってその参照を返すので、intervalRef.current の値を変更しても、intervalRef 自体が変更されないことから、re-render が走らない。この性質を利用して、クラスコンポーネントにおけるインスタンス変数のようなものとして扱うことができる。

references

Writings

blogsnippetcourse

Contact

Home©︎ suzukalight