summaryrefslogtreecommitdiffstats
path: root/_writing/lib/template.mjs
blob: 4431d16e3939d84af1260915fa86167be1d5298e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
/* Generates a standalone post page wearing the same shell as the rest of the
   site. Chrome comes from /style.css, prose from /blog.css, behaviour from
   /blog.js — the same external, gzipped, progressively-enhanced shape the home
   page uses. */

import { renderMarkdown, escapeHtml } from "./markdown.mjs";
import { longDate, shortDate, coverUrl } from "./posts.mjs";

const SITE = "https://ericzou.dev";

const TABS = [
  ["home", "/"],
  ["skills", "/#skills"],
  ["products", "/#products"],
  ["resume", "/#resume"],
  ["blog", "/#blog"],
  ["contact", "/#contact"],
];

function navHtml() {
  const tabs = TABS.map(([name, href], i) => {
    // aria-current, not aria-selected: these are links to another document,
    // not tabs inside a tablist.
    const current = name === "blog" ? ' aria-current="page"' : "";
    return `\t\t\t<a class="tab" href="${href}"${current}><span class="tab__key">${i + 1}</span>${name}</a>`;
  }).join("\n");
  return `\t\t<nav class="nav" aria-label="Sections">\n\t\t\t<span class="nav__brand">eric zou</span>\n${tabs}\n\t\t</nav>`;
}

function postnavHtml(prev, next) {
  // "prev" is the older post, matching the reading order of the index.
  const side = (post, dir, label) =>
    post
      ? `<a class="postnav__link postnav__link--${dir}" href="/blog/${post.slug}/">` +
        `<span class="postnav__dir">${label}</span>` +
        `<span class="postnav__title">${escapeHtml(post.title)}</span></a>`
      : `<div class="postnav__empty"></div>`;

  if (!prev && !next) return "";
  return (
    `\t\t\t\t<nav class="postnav" aria-label="More posts">` +
    side(prev, "prev", "&larr; older") +
    side(next, "next", "newer &rarr;") +
    `</nav>`
  );
}

export function renderPostPage({ post, prev, next, index, isDraft = false }) {
  const body = renderMarkdown(post.body);
  const url = `${SITE}/blog/${post.slug}/`;
  const desc = escapeHtml(post.description || "");
  const title = escapeHtml(post.title);
  const cover = coverUrl(post.slug, post.cover);

  const tags = post.tags.length
    ? `<span class="sep">&middot;</span><span class="post__tags">` +
      post.tags.map((t) => `<span class="post__tag">#${escapeHtml(t)}</span>`).join("") +
      `</span>`
    : "";

  const ld = JSON.stringify({
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: post.title,
    datePublished: post.date,
    description: post.description,
    author: { "@type": "Person", name: "Eric Zou", url: SITE },
    mainEntityOfPage: url,
  });

  return `<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8">
		<meta name="viewport" content="width=device-width, initial-scale=1.0">
		<title>${title} — Eric Zou</title>
		<meta name="description" content="${desc}">
		${isDraft ? '<meta name="robots" content="noindex, nofollow">' : `<link rel="canonical" href="${url}">`}
		<link rel="stylesheet" type="text/css" href="/style.css">
		<link rel="stylesheet" type="text/css" href="/blog.css">
		<script src="/blog.js" defer></script>
		<link rel="icon" href="/favicon.ico">
		<link rel="alternate" type="application/rss+xml" title="Eric Zou" href="/feed.xml">
		<meta property="og:type" content="article">
		<meta property="og:title" content="${title}">
		<meta property="og:description" content="${desc}">
		<meta property="og:url" content="${url}">
		<meta property="article:published_time" content="${post.date}">
${cover ? `\t\t<meta property="og:image" content="${SITE}${cover}">\n` : ""}		<meta name="twitter:card" content="${cover ? "summary_large_image" : "summary"}">
		<meta name="twitter:creator" content="@sleppyeric">
		<script type="application/ld+json">${ld}</script>
	</head>
	<body data-slug="${escapeHtml(post.slug)}">

${navHtml()}

		<div class="main" id="main">
			<div class="progress" id="progress"></div>
			<article class="post">
				<a class="crumb" href="/#blog">&larr; ~/blog</a>
${isDraft ? '\t\t\t\t<p class="post__meta post__draftbanner">DRAFT PREVIEW &middot; not published</p>\n' : ""}${cover ? `\t\t\t\t<img class="post__cover" src="${cover}" alt="" width="1000" height="400" fetchpriority="high">\n` : ""}				<h1 class="post__title">${title}</h1>
				<p class="post__meta">
					<span>${longDate(post.date)}</span>
					<span class="sep">&middot;</span>
					<span>${post.minutes} min read</span>
					${tags}
				</p>
				<hr class="post__rule">
				<div class="prose">
${body}
				</div>
${postnavHtml(prev, next)}
			</article>
		</div>

		<form class="term" id="termForm" autocomplete="off">
			<span class="term__prompt">[eric@ericzou.dev:<span class="loc loc--full">~/blog/${escapeHtml(post.slug)}</span><span class="loc loc--short">~/blog</span>]$</span>
			<label for="term" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);">Command</label>
			<input id="term" name="term" type="text" placeholder="back — or ls to see every post" spellcheck="false">
		</form>

		<div class="status">
			<span><kbd>&larr;</kbd><kbd>&rarr;</kbd> post</span>
			<span><kbd>&uarr;</kbd><kbd>&darr;</kbd> scroll</span>
			<span><kbd>b</kbd> back</span>
			<span><kbd>/</kbd> command</span>
			<span class="status__pct" id="pct">0%</span>
			<span class="status__msg" id="msg"></span>
		</div>

		<script id="blogIndex" type="application/json">${JSON.stringify(index)}</script>
	</body>
</html>
`;
}

/* The post list injected into the blog tab on the home page. */
export function renderBlogIndex(posts) {
  if (!posts.length) {
    return `\t\t\t\t<p class="blog__empty">No posts yet. The first one is being written.</p>`;
  }

  const rows = [];
  let year = null;
  posts.forEach((post, i) => {
    const y = post.date.slice(0, 4);
    if (y !== year) {
      year = y;
      rows.push(`\t\t\t\t\t<h2 class="postlist__year">${y}</h2>`);
    }
    const draft = post.status === "draft" ? `<span class="post__draft">draft</span>` : "";
    rows.push(
      `\t\t\t\t\t<a class="postrow" href="/blog/${post.slug}/">` +
        `<span class="postrow__n">${String(i + 1).padStart(2, "0")}</span>` +
        `<span class="postrow__date">${shortDate(post.date)}</span>` +
        `<span class="postrow__title">${escapeHtml(post.title)}${draft}</span>` +
        `<span class="postrow__time">${post.minutes} min</span>` +
        `</a>`
    );
  });

  // The hint has to demonstrate the shortcut, so it picks the most distinctive
  // word in the newest slug rather than the first, which is usually "the".
  const STOP = new Set(["the", "a", "an", "and", "is", "of", "for", "to", "in", "on", "my", "it"]);
  const words = posts[0].slug.split("-").filter((w) => w.length > 2 && !STOP.has(w));
  const hint = words.sort((a, b) => b.length - a.length)[0] || posts[0].slug;

  return (
    `\t\t\t\t<p class="blog__hint">open with <code>read ${escapeHtml(hint)}</code> ` +
    `&mdash; any word from a title works, or <code>read 1</code></p>\n` +
    `\t\t\t\t<div class="postlist">\n${rows.join("\n")}\n\t\t\t\t</div>`
  );
}