Skip to content

Commit

Permalink
Convert blog to App Router [#134]
Browse files Browse the repository at this point in the history
Deletes old Pages Router blog files, as well as displayMarkdown
component.
  • Loading branch information
genehack committed Oct 31, 2024
1 parent 3b4759a commit 22ebaba
Show file tree
Hide file tree
Showing 8 changed files with 325 additions and 268 deletions.
76 changes: 76 additions & 0 deletions static-site/app/blog/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { redirect } from "next/navigation";
import React from "react";

import FlexCenter from "../../../components/flex-center";
import { BigSpacer } from "../../../components/spacers";

import { getBlogPosts, markdownToHtml } from "../utils";

import styles from "./styles.module.css";

// just to avoid having to repeat this in a couple method sigs...
interface BlogPostParams {
id: string;
}

// return a list of params that will get handed to this page at build
// time, to statically build out all the blog posts
export function generateStaticParams(): BlogPostParams[] {
return getBlogPosts().map((post) => {
return { id: post.blogUrlName };
});
}

export default async function BlogPost({
params,
}: {
params: BlogPostParams;
}): Promise<React.ReactElement> {
const { id } = params;

// we need this list to build the archive list in the sidebar
const allBlogPosts = getBlogPosts();

// and then this is the specific post we're rendering
const blogPost = allBlogPosts.find((post) => post.blogUrlName === id);

// if for some reason we didn't find the post, 404 on out
if (!blogPost) {
redirect("/404");
}

const html = await markdownToHtml(blogPost.mdstring);

return (
<>
<BigSpacer count={4} />

<FlexCenter>
<div className="row">
<div className="col-lg-8">
<h3 className={styles.blogPostDate}>{blogPost.date}</h3>
<h1 className={styles.blogPostTitle}>{blogPost.title}</h1>
<h2 className={styles.blogPostAuthor}>{blogPost.author}</h2>
<div
className={styles.blogPostBody}
dangerouslySetInnerHTML={{
__html: html,
}}
/>
</div>
<div className="col-lg-1" />
<div className={`${styles.blogSidebar} col-lg-3`}>
<h2>Blog Archives</h2>
<ul>
{allBlogPosts.map((p) => (
<li key={p.blogUrlName}>
<a href={p.blogUrlName}>{p.sidebarTitle}</a> ({p.date})
</li>
))}
</ul>
</div>
</div>
</FlexCenter>
</>
);
}
71 changes: 71 additions & 0 deletions static-site/app/blog/[id]/styles.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
.blogPostTitle {
clear: both; /* this is to let the title fall under the floated date, not under it */
color: black;
font-size: 3.5rem;
font-weight: 500;
line-height: 40px;
text-align: left;
width: 100%;
}

.blogPostAuthor {
color: black;
font-size: 2rem;
font-weight: 300;
margin: 1rem 0 2rem;
}

.blogPostDate {
color: black;
float: right;
font-size: 1.4rem;
font-weight: 300;
min-height: 2rem;
}

.blogPostBody {
color: black;
font-size: 1.6rem;
font-weight: 300;
line-height: var(--niceLineHeight);
margin-top: 0px;
padding-bottom: 25px;
width: 100%;
}

.blogPostBody img {
max-width: 100%;
}

.blogPostBody h1 {
color: black;
font-size: 3rem;
margin-top: 20px;
text-align: left;
}
.blogPostBody h2 {
font-size: 2.4rem;
font-weight: 300;
margin-top: 10px;
}
.blogPostBody h3 {
font-size: 1.8rem;
font-weight: 300;
margin-top: 10px;
}
.blogPostBody p {
margin-top: 10px;
}
.blogPostBody li {
margin-left: 3rem;
}

.blogSidebar {
font-size: 14px;
}
.blogSidebar ul {
list-style: none;
}
.blogSidebar ul li {
margin: 1.2rem 0;
}
17 changes: 17 additions & 0 deletions static-site/app/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { redirect } from "next/navigation";

import { getBlogPosts } from "./utils";

export default function Index(): void {
const mostRecentPost = getBlogPosts()[0];

// _technically_ getBlogPosts() could return an empty array and then
// mostRecentPost would be undefined -- to make the type checker
// happy, if for some reason mostRecentPost is undefined, we will
// detect that and redirect to the 404 page
const redirectTo = mostRecentPost
? `/blog/${mostRecentPost.blogUrlName}`
: `/404`;

redirect(redirectTo);
}
89 changes: 89 additions & 0 deletions static-site/app/blog/parseMarkdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { marked } from "marked";
import sanitizeHtml, { Attributes, IOptions, Tag } from "sanitize-html";

import { siteUrl } from "../../data/BaseConfig";

export default async function parseMarkdown(mdString: string): Promise<string> {
const rawDescription = await marked.parse(mdString);

const sanitizerConfig: IOptions = {
allowedTags, // see below
allowedAttributes: { "*": allowedAttributes }, // see below
nonTextTags: ["style", "script", "textarea", "option"],
transformTags: {
a: transformA, // see below
},
};

const cleanDescription: string = sanitizeHtml(
rawDescription,
sanitizerConfig,
);

return cleanDescription;
}

function transformA(tagName: string, attribs: Attributes): Tag {
// small helper to keep things dry
const _setAttribs: (attribs: Attributes) => void = (attribs) => {
attribs.target = "_blank";
attribs.rel = "noreferrer nofollow";
};

const href = attribs.href;
if (href) {
const baseUrl = new URL(siteUrl);
try { // sometimes the `href` isn't a valid URL…
const linkUrl = new URL(href);
if (linkUrl.hostname !== baseUrl.hostname) {
_setAttribs(attribs);
}
} catch {
_setAttribs(attribs);
}
}

return { tagName, attribs };
}

// All of these tags may not be necessary, this list was adopted from https://github.com/nextstrain/auspice/blob/master/src/util/parseMarkdown.js
const allowedTags = ['div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'em', 'strong', 'del', 'ol', 'ul', 'li', 'a', 'img'];
allowedTags.push('#text', 'code', 'pre', 'hr', 'table', 'thead', 'tbody', 'th', 'tr', 'td', 'sub', 'sup');
// We want to support SVG elements, requiring the following tags (we exclude "foreignObject", "style" and "script")
allowedTags.push("svg", "altGlyph", "altGlyphDef", "altGlyphItem", "animate", "animateColor", "animateMotion", "animateTransform");
allowedTags.push("circle", "clipPath", "color-profile", "cursor", "defs", "desc", "ellipse", "feBlend", "feColorMatrix", "feComponentTransfer");
allowedTags.push("feComposite", "feConvolveMatrix", "feDiffuseLighting", "feDisplacementMap", "feDistantLight", "feFlood", "feFuncA");
allowedTags.push("feFuncB", "feFuncG", "feFuncR", "feGaussianBlur", "feImage", "feMerge", "feMergeNode", "feMorphology", "feOffset");
allowedTags.push("fePointLight", "feSpecularLighting", "feSpotLight", "feTile", "feTurbulence", "filter", "font", "font-face");
allowedTags.push("font-face-format", "font-face-name", "font-face-src", "font-face-uri", "g", "glyph", "glyphRef");
allowedTags.push("hkern", "image", "line", "linearGradient", "marker", "mask", "metadata", "missing-glyph", "mpath", "path");
allowedTags.push("pattern", "polygon", "polyline", "radialGradient", "rect", "set", "stop", "switch", "symbol");
allowedTags.push("text", "textPath", "title", "tref", "tspan", "use", "view", "vkern");

const allowedAttributes = ['href', 'src', 'width', 'height', 'alt'];
// We add the following Attributes for SVG via https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute
// Certain values have been excluded here, e.g. "style"
allowedAttributes.push("accent-height", "accumulate", "additive", "alignment-baseline", "allowReorder", "alphabetic", "amplitude", "arabic-form", "ascent", "attributeName", "attributeType", "autoReverse", "azimuth");
allowedAttributes.push("baseFrequency", "baseline-shift", "baseProfile", "bbox", "begin", "bias", "by");
allowedAttributes.push("calcMode", "cap-height", "class", "clip", "clipPathUnits", "clip-path", "clip-rule", "color", "color-interpolation", "color-interpolation-filters", "color-profile", "color-rendering", "cursor", "cx", "cy");
allowedAttributes.push("d", "decelerate", "descent", "diffuseConstant", "direction", "display", "divisor", "dominant-baseline", "dur", "dx", "dy");
allowedAttributes.push("edgeMode", "elevation", "enable-background", "end", "exponent", "externalResourcesRequired");
allowedAttributes.push("fill", "fill-opacity", "fill-rule", "filter", "filterRes", "filterUnits", "flood-color", "flood-opacity", "font-family", "font-size", "font-size-adjust", "font-stretch", "font-style", "font-variant", "font-weight", "format", "from", "fr", "fx", "fy");
allowedAttributes.push("g1", "g2", "glyph-name", "glyph-orientation-horizontal", "glyph-orientation-vertical", "glyphRef", "gradientTransform", "gradientUnits");
allowedAttributes.push("hanging", "height", "href", "hreflang", "horiz-adv-x", "horiz-origin-x");
allowedAttributes.push("id", "ideographic", "image-rendering", "in", "in2", "intercept");
allowedAttributes.push("k", "k1", "k2", "k3", "k4", "kernelMatrix", "kernelUnitLength", "kerning", "keyPoints", "keySplines", "keyTimes");
allowedAttributes.push("lang", "lengthAdjust", "letter-spacing", "lighting-color", "limitingConeAngle", "local");
allowedAttributes.push("marker-end", "marker-mid", "marker-start", "markerHeight", "markerUnits", "markerWidth", "mask", "maskContentUnits", "maskUnits", "mathematical", "max", "media", "method", "min", "mode");
allowedAttributes.push("name", "numOctaves");
allowedAttributes.push("offset", "opacity", "operator", "order", "orient", "orientation", "origin", "overflow", "overline-position", "overline-thickness");
allowedAttributes.push("panose-1", "paint-order", "path", "pathLength", "patternContentUnits", "patternTransform", "patternUnits", "ping", "pointer-events", "points", "pointsAtX", "pointsAtY", "pointsAtZ", "preserveAlpha", "preserveAspectRatio", "primitiveUnits");
allowedAttributes.push("r", "radius", "referrerPolicy", "refX", "refY", "rel", "rendering-intent", "repeatCount", "repeatDur", "requiredExtensions", "requiredFeatures", "restart", "result", "rotate", "rx", "ry");
allowedAttributes.push("scale", "seed", "shape-rendering", "slope", "spacing", "specularConstant", "specularExponent", "speed", "spreadMethod", "startOffset", "stdDeviation", "stemh", "stemv", "stitchTiles", "stop-color", "stop-opacity", "strikethrough-position", "strikethrough-thickness", "string", "stroke", "stroke-dasharray", "stroke-dashoffset", "stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "stroke-opacity", "stroke-width", "surfaceScale", "systemLanguage");
allowedAttributes.push("tabindex", "tableValues", "target", "targetX", "targetY", "text-anchor", "text-decoration", "text-rendering", "textLength", "to", "transform", "type");
allowedAttributes.push("u1", "u2", "underline-position", "underline-thickness", "unicode", "unicode-bidi", "unicode-range", "units-per-em");
allowedAttributes.push("v-alphabetic", "v-hanging", "v-ideographic", "v-mathematical", "values", "vector-effect", "version", "vert-adv-y", "vert-origin-x", "vert-origin-y", "viewBox", "viewTarget", "visibility");
allowedAttributes.push("width", "widths", "word-spacing", "writing-mode");
allowedAttributes.push("x", "x-height", "x1", "x2", "xChannelSelector");
allowedAttributes.push("y", "y1", "y2", "yChannelSelector");
allowedAttributes.push("z", "zoomAndPan");
72 changes: 72 additions & 0 deletions static-site/app/blog/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import fs from "fs";
import matter from "gray-matter";
import path from "path";
import { fileURLToPath } from "url";

import parseMarkdown from "./parseMarkdown";

export interface BlogPost {
author: string;
blogUrlName: string;
date: string;
mdstring: string;
sidebarTitle: string;
title: string;
}

// Scans the ./static-site/content/blog directory for .md files and
// returns a chronologically-sorted array of posts, each with some
// basic metadata and the raw (unsanitized) markdown contents.
export function getBlogPosts(): BlogPost[] {
const __dirname = path.dirname(fileURLToPath(import.meta.url));

const postsDirectory = path.join(__dirname, "..", "..", "content", "blog");

const markdownFiles = fs
.readdirSync(postsDirectory)
.filter((fileName) => fileName.endsWith(".md"));

const blogPosts: BlogPost[] = markdownFiles
.map((fileName): BlogPost | false => {
const { data: frontmatter, content: mdstring } = matter(
fs.readFileSync(path.join(postsDirectory, fileName), "utf8"),
);

// Our blog posts have frontmatter which includes author, date
// (YYYY-MM-DD format), post title, and an optional sidebar title.
// If the sidebar title isn't provided, the post title will be
// used for the sidebar title.
const { author, date, title } = frontmatter;

if (!author || !date || !title) {
// console warning printed server-side
console.warn(
`Blog post ${fileName} skipped due to empty/incomplete frontmatter`,
);
// we will filter these `false` values out momentarily
return false;
} else {
const blogUrlName = fileName.replace(/\.md$/, "");
const sidebarTitle: string = frontmatter.sidebarTitle || title;
return { author, date, title, blogUrlName, sidebarTitle, mdstring };
}
})
// type guard to filter out false entries generated because of bad frontmatter
.filter((post: false | BlogPost): post is BlogPost => {
return !!post;
})
// YYYY-MM-DD strings sort alphabetically
.sort((a, b) => (a.date > b.date ? -1 : 1));

return blogPosts;
}

export async function markdownToHtml(mdString:string): Promise<string> {
try {
return await parseMarkdown(mdString)
}
catch(error) {
console.error(`Error parsing markdown: ${error}`);
return '<p>There was an error parsing markdown content.</p>';
}
}
32 changes: 0 additions & 32 deletions static-site/pages/blog.jsx

This file was deleted.

Loading

0 comments on commit 22ebaba

Please sign in to comment.