Creating the Basic Job Listing

ExamPro Markdown Lab Part 1

This is part of the ExamPro Next.js course. Preview of complete lab deployed on Vercel

In this lab, we will be creating the Job Listings portion of ExamPro using the following stack:

Technology Stack

  • Node.js (12.22.0 or later)
  • Next.js (12.0.4)
  • React (17.0.2)
  • TailwindCSS (3.0.0)
  • gray-matter (4.0.3)
  • marked (4.0.3)

Application Screenshots

localhost:3000/jobs/ display a list of all jobs

localhost:3000/jobs/[slug] displays individual jobs

Getting Started

You may choose to start a new repository or continue with the current exampro-nextjs project

If you're starting from scratch, proceed to Step 1.

Setting Up Next.js

  1. Create a new Next.js app called exampro-markdown
npx create-next-app@latest exampro-markdown
  1. Change to the exampro-markdown directory
cd exampro-markdown

Setting Up TailwindCSS

  1. Install TailwindCSS, its peer-dependencies, plugins, and other Tailwind Labs tools
npm install -D tailwindcss@latest postcss@latest autoprefixer@latest
npm install @headlessui/react @heroicons/react
  1. Initialize your Tailwind configuration files
npx tailwindcss init -p
  1. Include Tailwind in your CSS by replacing the original content with the following lines in your ./styles/globals.css file
@tailwind base;
@tailwind components;
@tailwind utilities;
  1. In tailwind.config.js, add orange to your colors by adding the following line at the top of the file
const colors = require('tailwindcss/colors');

and extending the color palette to including orange

module.exports = {
  content: ['./components/**/*.js', './pages/**/*.js'],
  theme: {
    extend: {
      colors: {
        orange: colors.orange,
      },
    },
  },
  variants: {
    extend: {},
  },
  plugins: [],
};

Setting Up Prettier and Husky Hooks (optional)

  1. Install Prettier, Husky, and lint-staged
npm install --save-dev --save-exact prettier
npm install --save-dev husky lint-staged
npx husky install
npm set-script prepare "husky install"
npx husky add .husky/pre-commit "npx lint-staged"
  1. Create .prettierrc.json in the root directory
{
  "arrowParens": "always",
  "bracketSpacing": true,
  "embeddedLanguageFormatting": "auto",
  "endOfLine": "lf",
  "htmlWhitespaceSensitivity": "css",
  "insertPragma": false,
  "bracketSameLine": false,
  "jsxSingleQuote": false,
  "proseWrap": "preserve",
  "quoteProps": "as-needed",
  "requirePragma": false,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5",
  "useTabs": false,
  "vueIndentScriptAndStyle": false,
  "printWidth": 100
}
  1. Create .prettierignore in the root directory
package.json
package-lock.json
node_modules/
.cache
.next
  1. In the package.json, add the following scripts and lint-staged:
"scripts": {
    ...
    "prettier": "prettier --write \"./**/*.{md,json,html,css,js,yml}\"",
    "prettier-check": "prettier --check \"./**/*.{md,json,html,css,js,yml}\"",
    ...
  },
  ...
    "lint-staged": {
    "**/*": "prettier --write --ignore-unknown"
  }

Install gray-matter and marked

npm install --save gray-matter
npm install marked

Removing unnecessary file and code

  1. Delete the styles/Home.module.css file
  2. Remove everything inside the parent <div> element in ./pages/index.js and the import lines
import Head from 'next/head'
import Image from 'next/image'
import styles from '../styles/Home.module.css'

Setting up jsconfig.json

This specifies path mapping to be computed relative to baseUrl option.

  1. Create jsconfig.json file
{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es6",
    "baseUrl": ".",
    "paths": {
      "@/components/*": ["components/*"],
      "@/config/*": ["config/*"],
      "@/styles/*": ["styles/*"]
    }
  }
}

Using provided components and stylesheets

  1. Copy the following components and stylesheet into your project. These are React components that have been styled using TailwindCSS. Markdown.module.css is used to style the Markdown content
  • Footer from ./components/Footer.js
  • Header from ./components/Header.js
  • Layout from ./components/Layout.js
  • Main from ./components/Main.js
  • Job from ./components/jobs/Job.js
  • JobsHeader from ./components/jobs/JobsHeader.js
  • TypeLabel from ./components/jobs/TypeLabel.js
  • TypeList from ./components/jobs/TypeList.js
  • ./styles/Markdown.module.css
  1. Update the ./pages/index.js file to include the Layout and Main components
import Main from '@/components/Main';
import Layout from '@/components/Layout';

export default function Home() {
  return (
    <Layout>
      <Main />
    </Layout>
  );
}
  1. Run npm run dev to start the server, you should see

Markdown Implementation

Job Postings

  1. Create /jobs directory and fill it with job postings in markdown (.md files).
  2. You can copy the .md files in the /jobs of the repository or create your own using Lorem Markdownum. Make sure to include frontmatter above your markdown. Frontmatter looks like:
---
title: 'Cloud Support Engineer'
type: 'Part-Time'
location: 'Remote'
category: 'Operations, IT and Support Engineering'
---

JobPostings Component (Page component that shows list of all jobs)

  1. Create pages/jobs/index.js file
  • Import the fs and path modules
  • Import matter from gray-matter
  • Import the Job Component
  • Import the Layout component
import { promises as fs } from 'fs';
import path from 'path';
import matter from 'gray-matter';

import Job from '@/components/jobs/Job';
import Layout from '@/components/Layout';
  1. Create the getStaticProps() function
export async function getStaticProps() {
  // Read from /jobs directory
  const files = await fs.readdir(path.join('jobs'));

  // Map through jobs directory
  const jobs = files.map(async (filename) => {
    // Set 'slug' to name of md file
    const slug = filename.replace('.md', '');
    // Read all markdown from file
    const markdown = await fs.readFile(path.join('jobs', filename), 'utf-8');
    // Extract data from markdown
    const { data } = matter(markdown);

    // return slug and data in an array
    return {
      slug,
      data,
    };
  });

  return {
    props: {
      jobs: await Promise.all(jobs),
    },
  };
}
  1. Your JobPostings() function will take the jobs prop from the getStaticProps() function and map through each of the job markdown files in /jobs
// Takes the `jobs` prop from the getStaticProps() function
export default function JobPostings({ jobs }) {
  return (
    <Layout title="Jobs | ExamPro">
      <div className="px-4 py-4 sm:px-6 md:flex md:items-center md:justify-between">
        <div className="flex-1 min-w-0">
          <h2 className="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
            Job Postings
          </h2>
        </div>
      </div>
      <div className="bg-white my-4 shadow overflow-hidden divide-y divide-gray-200 sm:rounded-md">
        <ul role="list" className="divide-y divide-gray-200">
          {/* Maps through each job */}
          {jobs.map((job, index) => (
            <Job key={index} job={job} />
          ))}
        </ul>
      </div>
    </Layout>
  );
}

Markdown Component (For parsing markdown)

This component handles the parsing of the markdown content to html so we can style it using Markdown.module.css

  1. Create ./components/Markdown.js file
import { marked } from 'marked';

import styles from '@/styles/Markdown.module.css';

// Takes content (for example from ./pages/jobs/[slug].js)
export default function Markdown({ content }) {
  return (
    // Uses marked to parse markdown to html
    <div className={styles.markdown} dangerouslySetInnerHTML={{ __html: marked(content) }}></div>
  );
}

JobPage Component (Individual job posting)

  1. Create ./pages/jobs/[slug].js file
  2. Import the following
import { promises as fs } from 'fs';
import path from 'path';
import Link from 'next/link';
import matter from 'gray-matter';
import { BriefcaseIcon, LocationMarkerIcon, UsersIcon } from '@heroicons/react/solid';

import Markdown from '@/components/Markdown';
import Layout from '@/components/Layout';
  1. Create a getStaticPaths() function
export async function getStaticPaths() {
  // Read from the /jobs directory
  const files = await fs.readdir(path.join('jobs'));
  // Map through the files
  const paths = await Promise.all(
    files.map(async (filename) => ({
      params: {
        // Create a slug using the name of the file without the .md extension at the end
        slug: filename.replace('.md', ''),
      },
    }))
  );

  return {
    paths,
    fallback: false,
  };
}
  1. Create a getStaticProps() function
// This function takes the slug from getStaticPaths()
export async function getStaticProps({ params: { slug } }) {
  // Read file with name of slug + .md extension in the /jobs directory
  const markdown = await fs.readFile(path.join('jobs', slug + '.md'), 'utf-8');
  // Use `matter` to extract the content and data from each file
  // content is the body of the markdown file
  // data is the frontmatter of the markdown file
  const { content, data } = matter(markdown);

  // Return content, data, and slug as props
  return {
    props: {
      content,
      data,
      slug,
    },
  };
}
  1. Your JobPage() function will take content and data as props from getStaticProps() and will display them as React Components
export default function JobPage({ content, data }) {
  return (
    <Layout title={`${data.title} | ExamPro`}>
      <div className="px-4 py-4 sm:px-6 md:flex md:items-center md:justify-between lg:flex lg:items-center lg:justify-between">
        <div className="flex-1 min-w-0">
          <h2 className="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
            {data.title}
          </h2>
          <div className="mt-1 flex flex-col sm:flex-row sm:flex-wrap sm:mt-0 sm:space-x-6">
            <div className="mt-2 flex items-center text-sm text-gray-500">
              <UsersIcon
                className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-400"
                aria-hidden="true"
              />
              {data.category}
            </div>
            <div className="mt-2 flex items-center text-sm text-gray-500">
              <LocationMarkerIcon
                className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-400"
                aria-hidden="true"
              />
              {data.location}
            </div>
            <div className="mt-2 flex items-center text-sm text-gray-500">
              <BriefcaseIcon
                className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-400"
                aria-hidden="true"
              />
              {data.type}
            </div>
          </div>
        </div>
        <div className="mt-5 flex lg:mt-0 lg:ml-4">
          <span className="sm:ml-3">
            <Link href="/jobs" passHref>
              <button
                type="button"
                className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-orange-500 hover:bg-orange-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-400"
              >
                Back to Jobs
              </button>
            </Link>
          </span>
        </div>
      </div>
      <div>
        <Markdown content={content} />
      </div>
    </Layout>
  );
}

28