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 * ---
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 */
1819import { readdirSync , readFileSync , writeFileSync , mkdirSync } from "node:fs" ;
1920import { join , dirname } from "node:path" ;
2021import { fileURLToPath } from "node:url" ;
2122import { marked } from "marked" ;
23+ import hljs from "highlight.js" ;
2224
2325const __dirname = dirname ( fileURLToPath ( import . meta. url ) ) ;
2426const ROOT = join ( __dirname , ".." ) ;
2527const BLOG_DIR = join ( ROOT , "blog" ) ;
2628const DIST = join ( ROOT , "dist" , "blog" ) ;
2729const 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 - z 0 - 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, "&" )
48+ . replace ( / < / g, "<" )
49+ . replace ( / > / g, ">" ) ;
50+ }
51+ const cls = langClass ? ` language-${ langClass } ` : "" ;
52+ return `<pre class="hljs"><code class="hljs${ cls } ">${ html } </code></pre>\n` ;
53+ }
54+ }
55+ } ) ;
56+
2957function 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, """ ) ;
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+
64123function 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+
189289const files = readdirSync ( BLOG_DIR ) . filter ( ( f ) => f . endsWith ( ".md" ) ) ;
190290const posts = files
191291 . map ( ( f ) => parseFrontmatter ( readFileSync ( join ( BLOG_DIR , f ) , "utf8" ) ) )
@@ -204,5 +304,6 @@ for (const post of posts) {
204304}
205305
206306writeFileSync ( 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