Bayan Bennett

Code Snippets in NextJS Generated Pages | DevLog 006

Next.js
TypeScript
Video version of this post

In the last post, through some code metamorphosis I converted our humble little caterpillar of a text editor into a beautiful butterfly of a code editor.

There is one problem with the project: it only has one page. Obviously, there's a need to have hundreds or thousands of pages.

I could just copy and paste the same page repeatedly, but that will become difficult to scale and maintain. I need to take the common aspects of the page and then abstract it into a template.

Dynamic Routing in NextJS

NextJS has a fancy schmancy way of dynamic routing via a file naming convention. To take advantage of this, I'll move my ~/pages/JavaScript/String/index.tsx file to ~/pages/JavaScript/[...path].tsx.

Notice that the new file uses square brackets and an ellipsis. This is the naming convention that I mentioned. I'll get into how this file name becomes relevant a little later.

The file name is just the first requirement. There are two methods that we need to export for dynamic routing to work:

  • getStaticPaths: determines which paths will need to be created.
  • getStaticProps: determines which props will be passed to the page template.

Note that each of these have their corresponding type: GetStaticPaths and GetStaticProps.

getStaticPaths

I need to feed some source of path information to getStaticPaths so that NextJS knows which paths to generate. In my case, I would like to have a folder full of code snippets that I will then use on the reference site to populate the code editor. Each of those code snippets corresponds to a path on the client side. For example: ~/data/JavaScript/String.js, which will contain a code snippet to illustrate several ways to create a String in JavaScript.

// ~/data/JavaScript/data/String.js

const doubleQuoteString = "this is a double quote string";
const singleQuoteString = "this is a single quote string";
const backtickString = `this is a backtick string`;

const areAllStrings = [
  doubleQuoteString,
  singleQuoteString,
  backtickString,
].every(({ constructor }) => constructor === String);

console.assert(areAllStrings);

console.log(doubleQuoteString, singleQuoteString, backtickString);

I could also have other files, such as ~/data/JavaScript/String/length.js for demonstrating the length of an instance of a String.

The keen among you will notice that instead of ~/data/JavaScript/String/index.js, I created ~/data/JavaScript/String.js. This is because these files will be read as text and not imported using the NodeJS module system.

To read the file paths in the ~/data/ directory, I am using globby, which wraps fast-glob and uses glob patterns. globby returns a promise that resolves to an array of file paths. I need to remove the file extension from each one of these paths and return an object that contains the paths and a flag that tells NextJS whether enable fallback mode.

export const getStaticPaths: GetStaticPaths = async () => {
  const filePaths = await globby("data/JavaScript/**/*", {
    onlyFiles: true,
  });

  const paths = filePaths.map((filePath) => {
    const filePathWithoutExtension = filePath.replace(/\.js$/, "");
    // The next line is redundant, but I'll be making changes to it later
    return filePathWithoutExtension;
  });

  return { paths, fallback: false };
};

getStaticProps

Now that the paths are known, I can pass some props to the template when it is rendered. In my case, it is a code snippet in the ~/data/JavaScript folder. In the beginning of this post, I named my template [...path].tsx. getStaticProps's first argument is an object with the name of the file: { path: string[] }. Note that if I had named the file [path].tsx, instead of the path key being a string array, it would be a regular string. The last item in the path array will always be the name of the file that needs to be read and the rest of the path array will be the folder that the file is located in.

import { promises as fs } from "fs";

/* ... */

type JavaScriptPageTemplateProps = { initialCode: string; path: string[] };

const cwd = resolve("data", "JavaScript");

export const getStaticProps: GetStaticProps<
  JavaScriptPageTemplateProps,
  { path: string[] }
> = async ({ params }) => {
  if (typeof params?.path === "undefined") return { notFound: true };

  const { path } = params;

  const fileName = path[path.length - 1];
  const folder = path.slice(0, path.length - 1);

  const initialCode = await fs.readFile(
    resolve(cwd, ...folder, `${fileName}.js`),
    "utf-8"
  );

  return { props: { initialCode, path } };
};

After reading the file, I can return the path and the initialCode so that it can be used by the page template.

Since I declared cwd, I can deduplicate some code in getStaticPaths. By default, globby returns paths relative to the cwd, so I'll have to prepend /JavaScript/ to all the returned paths.

export const getStaticPaths: GetStaticPaths = async () => {
  const filePaths = await globby("data/JavaScript/**/*", {
    onlyFiles: true,
+   cwd
});

  const paths = filePaths.map((filePath) => {
    const filePathWithoutExtension = filePath.replace(/\.js$/, "");
-   return filePathWithoutExtension;
+   return `/JavaScript/${filePathWithoutExtension}`;
  });

  return { paths, fallback: false };
};

Note that it would also be reasonable to use a capture group instead of a template string for the addition of /JavaScript/.

Passing the Snippet to the Editor

In the [...path].tsx template I can forward the initialCode to the Editor.

const JavaScriptPageTemplate: FunctionComponent<JavaScriptPageTemplateProps> = ({
  initialCode,
  path,
}) => {

  /* ... */

  return (
    <>
      {/* .. */}
      <Editor setView={setView} initialCode={initialCode} />
    </>
  );
};

The Editor just needs to create a state with that initial code when creating the view. I renamed initialCode to doc just to make it a bit cleaner. doc was also added to the React.useEffect dependency array.

export const Editor: React.FunctionComponent<EditorProps> = ({
  setView,
  initialCode: doc,
}) => {
  const editorRef = React.useRef<HTMLElement>(null);

  React.useEffect(() => {
    if (editorRef.current === null) return;

    const state = EditorState.create({
      doc,
      extensions,
    });

    const view = new EditorView({
      state,
      parent: editorRef.current,
    });

    setView(view);

    return () => {
      view.destroy();
      setView(null);
    };
  }, [editorRef.current, doc]);

  return <section ref={editorRef} />;
};

This is the result for /Javascript/String:

string page progress

TL; DR

Used NextJS' getStaticPaths to get all the paths for the code snippets and getStaticProps to pass on the content of the code snippet to the template, which in turn passes it to the code editor.

© 2021 Bayan Bennett