Skip to content

Commit 514411e

Browse files
nextlevelshitMichael Czechowski
authored andcommitted
feat(blog): syntax highlight + RSS + post-CTA + cleaner template (#92)
Co-authored-by: Michael Czechowski <mail@dailysh.it> Co-committed-by: Michael Czechowski <mail@dailysh.it>
1 parent cdf731e commit 514411e

3 files changed

Lines changed: 160 additions & 48 deletions

File tree

package-lock.json

Lines changed: 11 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"@emmetio/codemirror6-plugin": "^0.4.0",
4646
"@supabase/supabase-js": "^2.90.1",
4747
"codemirror": "^6.0.2",
48+
"highlight.js": "^11.11.1",
4849
"marked": "^17.0.1",
4950
"whatwg-fetch": "^3.6.20"
5051
}

scripts/generate-blog.mjs

Lines changed: 148 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
#!/usr/bin/env node
22
/**
33
* Pre-renders blog/*.md to dist/blog/<slug>/index.html (one HTML file
4-
* per post) plus dist/blog/index.html (post list).
4+
* per post) plus dist/blog/index.html (post list) plus
5+
* dist/blog/rss.xml.
56
*
67
* Frontmatter format:
78
* ---
@@ -12,20 +13,47 @@
1213
* tags: [a, b]
1314
* ---
1415
*
15-
* Markdown rendered with `marked` (already a runtime dep). Output is
16-
* fully static — Google sees real content, no JS required.
16+
* Markdown rendered with `marked`; code blocks get syntax tokens via
17+
* highlight.js so the .hljs-* classes can be themed at the post level.
1718
*/
1819
import { readdirSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
1920
import { join, dirname } from "node:path";
2021
import { fileURLToPath } from "node:url";
2122
import { marked } from "marked";
23+
import hljs from "highlight.js";
2224

2325
const __dirname = dirname(fileURLToPath(import.meta.url));
2426
const ROOT = join(__dirname, "..");
2527
const BLOG_DIR = join(ROOT, "blog");
2628
const DIST = join(ROOT, "dist", "blog");
2729
const ORIGIN = "https://codecrispi.es";
2830

31+
// Wire highlight.js into marked: detect language from fence, fall
32+
// back to auto-detect, leave classes in place so the post stylesheet
33+
// can theme them.
34+
marked.use({
35+
renderer: {
36+
code({ text, lang }) {
37+
const langClass = lang ? lang.replace(/[^a-z0-9-]/gi, "") : "";
38+
let html;
39+
try {
40+
if (langClass && hljs.getLanguage(langClass)) {
41+
html = hljs.highlight(text, { language: langClass, ignoreIllegals: true }).value;
42+
} else {
43+
html = hljs.highlightAuto(text).value;
44+
}
45+
} catch {
46+
html = text
47+
.replace(/&/g, "&amp;")
48+
.replace(/</g, "&lt;")
49+
.replace(/>/g, "&gt;");
50+
}
51+
const cls = langClass ? ` language-${langClass}` : "";
52+
return `<pre class="hljs"><code class="hljs${cls}">${html}</code></pre>\n`;
53+
}
54+
}
55+
});
56+
2957
function parseFrontmatter(raw) {
3058
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
3159
if (!match) return { meta: {}, body: raw };
@@ -36,11 +64,9 @@ function parseFrontmatter(raw) {
3664
if (!m) continue;
3765
let [, key, value] = m;
3866
value = value.trim();
39-
// Strip wrapping quotes
4067
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
4168
value = value.slice(1, -1);
4269
}
43-
// Array syntax [a, b, c]
4470
if (value.startsWith("[") && value.endsWith("]")) {
4571
value = value
4672
.slice(1, -1)
@@ -61,11 +87,53 @@ function escapeHtml(s) {
6187
.replace(/"/g, "&quot;");
6288
}
6389

90+
function readingTime(text) {
91+
const words = text.split(/\s+/).filter(Boolean).length;
92+
return Math.max(1, Math.round(words / 200));
93+
}
94+
95+
const SHARED_STYLES = `
96+
:root { color-scheme: light; }
97+
* { box-sizing: border-box; }
98+
body { font: 16px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; color: #1f2937; max-width: 720px; margin: 0 auto; padding: 1.5rem 1.25rem 4rem; background: #fafafa; }
99+
a { color: #4f46e5; }
100+
a:hover { color: #4338ca; }
101+
header.site { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 2.5rem; padding-bottom: 1.25rem; border-bottom: 1px solid #e5e7eb; }
102+
header.site .brand { display: flex; align-items: center; gap: .6rem; color: inherit; text-decoration: none; font-weight: 800; letter-spacing: -.02em; font-size: 1.05rem; }
103+
header.site .brand img { width: 36px; height: 36px; }
104+
header.site .brand span { color: #4f46e5; }
105+
header.site nav { font-size: .9rem; }
106+
header.site nav a { margin-left: .9rem; text-decoration: none; color: #4b5563; font-weight: 500; }
107+
header.site nav a:hover { color: #4f46e5; }
108+
footer.site { margin-top: 3.5rem; padding-top: 1.5rem; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: .9rem; display: flex; justify-content: space-between; flex-wrap: wrap; gap: 1rem; }
109+
/* highlight.js base — github-light theme tokens, trimmed */
110+
pre.hljs { background: #0f172a; color: #e2e8f0; padding: 1rem 1.25rem; border-radius: 8px; overflow-x: auto; font-size: .9rem; line-height: 1.55; margin: 1.25rem 0; }
111+
pre.hljs code { background: transparent; padding: 0; color: inherit; font-family: "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
112+
.hljs-keyword, .hljs-selector-tag, .hljs-meta { color: #c4b5fd; }
113+
.hljs-string, .hljs-attr-value, .hljs-symbol, .hljs-bullet { color: #86efac; }
114+
.hljs-comment, .hljs-quote { color: #94a3b8; font-style: italic; }
115+
.hljs-number, .hljs-literal { color: #fda4af; }
116+
.hljs-attr, .hljs-attribute, .hljs-title, .hljs-name, .hljs-section { color: #fcd34d; }
117+
.hljs-tag, .hljs-property, .hljs-built_in, .hljs-selector-class, .hljs-selector-id, .hljs-selector-pseudo { color: #93c5fd; }
118+
.hljs-variable, .hljs-template-variable { color: #fbbf24; }
119+
.hljs-deletion { color: #fda4af; }
120+
.hljs-addition { color: #86efac; }
121+
`;
122+
64123
function postPagе(post) {
65124
const title = `${post.meta.title} — Code Crispies Blog`;
66125
const desc = post.meta.description || "";
67126
const url = `${ORIGIN}/blog/${post.meta.slug}/`;
68127
const tags = Array.isArray(post.meta.tags) ? post.meta.tags : [];
128+
const minutes = readingTime(post.body);
129+
130+
// Suggest a related practice link if a tag matches a known module
131+
// prefix (cheap heuristic; non-fatal if wrong, just a soft CTA).
132+
const tagToModule = { html: "html-elements", css: "css-fundamentals", tailwind: "tailwind-basics", typography: "typography", responsive: "responsive" };
133+
const practiceTag = tags.find((t) => tagToModule[t]);
134+
const practiceLink = practiceTag
135+
? `<p>Want to practice <strong>${escapeHtml(practiceTag)}</strong> hands-on? <a href="/${tagToModule[practiceTag]}/0">Open the ${escapeHtml(practiceTag)} module →</a></p>`
136+
: '<p>Practice in the browser at <a href="/">codecrispi.es</a> — 135 free interactive lessons.</p>';
69137

70138
return `<!doctype html>
71139
<html lang="en">
@@ -83,47 +151,43 @@ function postPagе(post) {
83151
<meta property="article:published_time" content="${post.meta.date}">
84152
${tags.map((t) => `<meta property="article:tag" content="${escapeHtml(t)}">`).join("\n")}
85153
<meta name="twitter:card" content="summary_large_image">
154+
<link rel="alternate" type="application/rss+xml" title="Code Crispies Blog" href="/blog/rss.xml">
86155
<script type="application/ld+json">
87156
{"@context":"https://schema.org","@type":"BlogPosting","headline":${JSON.stringify(post.meta.title)},"datePublished":"${post.meta.date}","description":${JSON.stringify(desc)},"url":"${url}","author":{"@type":"Organization","name":"LibreTECH","url":"https://librete.ch"},"publisher":{"@type":"Organization","name":"Code Crispies","url":"${ORIGIN}"},"mainEntityOfPage":"${url}","keywords":${JSON.stringify(tags.join(", "))}}
88157
</script>
89158
<link rel="icon" href="/favicon.ico">
90-
<style>
91-
body { font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; color: #1f2937; max-width: 720px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
92-
header { display: flex; align-items: center; gap: .75rem; margin-bottom: 2rem; }
93-
header img { width: 40px; height: 40px; }
94-
header a { color: inherit; text-decoration: none; font-weight: 700; letter-spacing: -.02em; }
95-
header a span { color: #4f46e5; }
96-
h1 { font-size: 2.25rem; line-height: 1.15; letter-spacing: -.03em; margin: 0 0 .5rem; }
97-
.meta { color: #6b7280; font-size: .9rem; margin-bottom: 2rem; }
98-
.meta time + .tags { margin-left: .75rem; }
99-
.tag { background: #eef2ff; color: #4338ca; padding: 2px 8px; border-radius: 4px; font-size: .8rem; margin-right: .25rem; }
100-
article { margin-top: 1rem; }
159+
<style>${SHARED_STYLES}
160+
h1 { font-size: 2.25rem; line-height: 1.15; letter-spacing: -.03em; margin: 0 0 .75rem; }
161+
.meta { color: #6b7280; font-size: .9rem; margin: 0 0 2.25rem; display: flex; gap: .9rem; flex-wrap: wrap; align-items: center; }
162+
.meta time { color: inherit; }
163+
.tag { background: #eef2ff; color: #4338ca; padding: 2px 8px; border-radius: 4px; font-size: .8rem; }
101164
article h2 { margin-top: 2.5rem; font-size: 1.5rem; letter-spacing: -.02em; }
102165
article h3 { margin-top: 2rem; font-size: 1.2rem; }
103166
article p, article li { font-size: 1.05rem; }
104-
article a { color: #4f46e5; }
105-
article code { background: #f3f4f6; padding: 1px 5px; border-radius: 3px; font-size: .92em; }
106-
article pre { background: #1f2937; color: #f9fafb; padding: 1rem 1.25rem; border-radius: 8px; overflow-x: auto; font-size: .9rem; }
107-
article pre code { background: transparent; padding: 0; color: inherit; }
167+
article :not(pre) > code { background: #eef2ff; color: #312e81; padding: 1px 6px; border-radius: 4px; font-size: .92em; font-family: "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
108168
article kbd { background: #f3f4f6; border: 1px solid #d1d5db; border-bottom-width: 2px; padding: 1px 6px; border-radius: 4px; font-size: .85em; font-family: inherit; }
109169
article hr { border: 0; border-top: 1px solid #e5e7eb; margin: 2.5rem 0; }
110-
article blockquote { border-left: 3px solid #4f46e5; padding-left: 1rem; color: #4b5563; }
111-
footer { margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: .9rem; }
112-
footer a { color: #4f46e5; }
170+
article blockquote { border-left: 3px solid #4f46e5; padding-left: 1rem; color: #4b5563; font-style: italic; }
171+
.practice-cta { margin-top: 3rem; padding: 1.25rem 1.5rem; background: linear-gradient(135deg, #eef2ff 0%, #fef3c7 100%); border-radius: 12px; border: 1px solid #c7d2fe; }
172+
.practice-cta p { margin: 0; }
113173
</style>
114174
</head>
115175
<body>
116-
<header>
117-
<a href="/"><img src="/bowl.png" alt=""> CODE <span>CRISPIES</span></a>
176+
<header class="site">
177+
<a class="brand" href="/"><img src="/bowl.png" alt=""><span>CODE CRISPIES</span></a>
178+
<nav><a href="/blog/">Blog</a> <a href="/">Try it</a></nav>
118179
</header>
119180
<h1>${escapeHtml(post.meta.title)}</h1>
120181
<p class="meta">
121182
<time datetime="${post.meta.date}">${post.meta.date}</time>
122-
<span class="tags">${tags.map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join("")}</span>
183+
<span>${minutes} min read</span>
184+
${tags.map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join("")}
123185
</p>
124186
<article>${marked.parse(post.body)}</article>
125-
<footer>
126-
<p>← <a href="/blog">All posts</a> · <a href="/">Try Code Crispies</a></p>
187+
<aside class="practice-cta">${practiceLink}</aside>
188+
<footer class="site">
189+
<span>← <a href="/blog/">All posts</a></span>
190+
<span><a href="/blog/rss.xml">RSS</a> · <a href="https://github.com/nextlevelshit/code-crispies">Source</a></span>
127191
</footer>
128192
</body>
129193
</html>
@@ -134,9 +198,11 @@ function indexPage(posts) {
134198
const items = posts
135199
.map(
136200
(p) => ` <li>
137-
<a href="/blog/${p.meta.slug}/"><h2>${escapeHtml(p.meta.title)}</h2></a>
138-
<p class="meta"><time datetime="${p.meta.date}">${p.meta.date}</time></p>
139-
<p>${escapeHtml(p.meta.description || "")}</p>
201+
<a href="/blog/${p.meta.slug}/">
202+
<h2>${escapeHtml(p.meta.title)}</h2>
203+
<p class="meta"><time datetime="${p.meta.date}">${p.meta.date}</time> · ${readingTime(p.body)} min read</p>
204+
<p class="excerpt">${escapeHtml(p.meta.description || "")}</p>
205+
</a>
140206
</li>`
141207
)
142208
.join("\n");
@@ -155,37 +221,71 @@ function indexPage(posts) {
155221
<meta property="og:url" content="${ORIGIN}/blog/">
156222
<meta property="og:image" content="${ORIGIN}/og-image.png">
157223
<link rel="icon" href="/favicon.ico">
158-
<style>
159-
body { font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; color: #1f2937; max-width: 720px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
160-
header { display: flex; align-items: center; gap: .75rem; margin-bottom: 2rem; }
161-
header img { width: 40px; height: 40px; }
162-
header a { color: inherit; text-decoration: none; font-weight: 700; letter-spacing: -.02em; }
163-
header a span { color: #4f46e5; }
224+
<link rel="alternate" type="application/rss+xml" title="Code Crispies Blog" href="/blog/rss.xml">
225+
<style>${SHARED_STYLES}
164226
h1 { font-size: 2rem; margin: 0 0 .5rem; letter-spacing: -.02em; }
165-
.lede { color: #6b7280; margin-bottom: 2rem; }
166-
ul { list-style: none; padding: 0; }
167-
li { padding: 1.25rem 0; border-bottom: 1px solid #e5e7eb; }
168-
li a { text-decoration: none; color: inherit; }
227+
.lede { color: #6b7280; margin: 0 0 2rem; font-size: 1.05rem; }
228+
ul { list-style: none; padding: 0; margin: 0; }
229+
li { margin-bottom: 1.25rem; }
230+
li a { display: block; padding: 1.25rem; background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; text-decoration: none; color: inherit; transition: border-color .15s, transform .15s; }
231+
li a:hover { border-color: #818cf8; transform: translateY(-1px); }
232+
li h2 { margin: 0 0 .25rem; font-size: 1.3rem; letter-spacing: -.01em; color: #1f2937; }
169233
li a:hover h2 { color: #4f46e5; }
170-
li h2 { margin: 0 0 .25rem; font-size: 1.35rem; letter-spacing: -.01em; }
171-
.meta { color: #9ca3af; font-size: .85rem; margin: 0 0 .5rem; }
172-
li p:last-child { color: #4b5563; margin: 0; }
234+
li .meta { color: #9ca3af; font-size: .85rem; margin: 0 0 .5rem; }
235+
li .excerpt { color: #4b5563; margin: 0; font-size: .98rem; }
173236
</style>
174237
</head>
175238
<body>
176-
<header>
177-
<a href="/"><img src="/bowl.png" alt=""> CODE <span>CRISPIES</span></a>
239+
<header class="site">
240+
<a class="brand" href="/"><img src="/bowl.png" alt=""><span>CODE CRISPIES</span></a>
241+
<nav><a href="/">Try it</a></nav>
178242
</header>
179243
<h1>Blog</h1>
180244
<p class="lede">Hands-on web-development tutorials. Each post is short, code-first, and links back to a Code Crispies module to practice.</p>
181245
<ul>
182246
${items}
183247
</ul>
248+
<footer class="site">
249+
<span>${posts.length} post${posts.length === 1 ? "" : "s"}</span>
250+
<span><a href="/blog/rss.xml">RSS</a> · <a href="https://github.com/nextlevelshit/code-crispies">Source</a></span>
251+
</footer>
184252
</body>
185253
</html>
186254
`;
187255
}
188256

257+
function rssFeed(posts) {
258+
const lastBuild = new Date().toUTCString();
259+
const items = posts
260+
.slice(0, 20)
261+
.map((p) => {
262+
const url = `${ORIGIN}/blog/${p.meta.slug}/`;
263+
const pubDate = new Date(`${p.meta.date}T12:00:00Z`).toUTCString();
264+
return ` <item>
265+
<title>${escapeHtml(p.meta.title)}</title>
266+
<link>${url}</link>
267+
<guid isPermaLink="true">${url}</guid>
268+
<pubDate>${pubDate}</pubDate>
269+
<description>${escapeHtml(p.meta.description || "")}</description>
270+
</item>`;
271+
})
272+
.join("\n");
273+
274+
return `<?xml version="1.0" encoding="UTF-8"?>
275+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
276+
<channel>
277+
<title>Code Crispies Blog</title>
278+
<link>${ORIGIN}/blog/</link>
279+
<description>Hands-on web-development tutorials from the Code Crispies team.</description>
280+
<language>en</language>
281+
<lastBuildDate>${lastBuild}</lastBuildDate>
282+
<atom:link href="${ORIGIN}/blog/rss.xml" rel="self" type="application/rss+xml"/>
283+
${items}
284+
</channel>
285+
</rss>
286+
`;
287+
}
288+
189289
const files = readdirSync(BLOG_DIR).filter((f) => f.endsWith(".md"));
190290
const posts = files
191291
.map((f) => parseFrontmatter(readFileSync(join(BLOG_DIR, f), "utf8")))
@@ -204,5 +304,6 @@ for (const post of posts) {
204304
}
205305

206306
writeFileSync(join(DIST, "index.html"), indexPage(posts));
307+
writeFileSync(join(DIST, "rss.xml"), rssFeed(posts));
207308

208-
console.log(`✓ wrote ${posts.length} blog post(s) + index to dist/blog/`);
309+
console.log(`✓ wrote ${posts.length} blog post(s) + index + RSS to dist/blog/`);

0 commit comments

Comments
 (0)