Skip to content

Commit 0954347

Browse files
nextlevelshitMichael Czechowski
authored andcommitted
feat(seo): per-lesson static HTML pages (#96)
Co-authored-by: Michael Czechowski <mail@dailysh.it> Co-committed-by: Michael Czechowski <mail@dailysh.it>
1 parent 182144f commit 0954347

2 files changed

Lines changed: 256 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"scripts": {
77
"start": "npm run dev",
88
"dev": "vite --host",
9-
"build": "vite build && node scripts/generate-blog.mjs && node scripts/generate-sitemap.mjs",
9+
"build": "vite build && node scripts/generate-blog.mjs && node scripts/generate-lesson-pages.mjs && node scripts/generate-sitemap.mjs",
1010
"preview": "vite preview --debug",
1111
"test": "vitest run",
1212
"test.watch": "vitest watch",

scripts/generate-lesson-pages.mjs

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Generates dist/<moduleId>/<index>/index.html for every lesson, plus
4+
* dist/<moduleId>/index.html for module landings, and dist/<section>/
5+
* index.html for section pages.
6+
*
7+
* Each generated page is the SPA shell (dist/index.html) with the
8+
* <head> rewritten so that:
9+
* - <title>, <meta description>, canonical, og:*, twitter:* are
10+
* specific to that lesson/module/section
11+
* - schema.org LearningResource (per lesson) or CollectionPage
12+
* (per module/section) is embedded
13+
*
14+
* The <body> is untouched — the SPA bootstraps as usual and hydrates
15+
* over it. Search engines see real per-page metadata; users see the
16+
* normal interactive app.
17+
*
18+
* Run after `vite build` so dist/index.html exists.
19+
*/
20+
import { readdirSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
21+
import { join, dirname } from "node:path";
22+
import { fileURLToPath } from "node:url";
23+
24+
const __dirname = dirname(fileURLToPath(import.meta.url));
25+
const ROOT = join(__dirname, "..");
26+
const LESSONS_DIR = join(ROOT, "lessons");
27+
const DIST = join(ROOT, "dist");
28+
const ORIGIN = "https://codecrispi.es";
29+
30+
const SECTIONS = {
31+
css: { title: "CSS", description: "Master CSS through hands-on lessons covering selectors, box model, flexbox, grid, custom properties, transitions, gradients, filters, and more." },
32+
html: { title: "HTML", description: "Learn semantic HTML through interactive exercises: forms, validation, native elements, dialog, popover, tables, SVG." },
33+
tailwind: { title: "Tailwind CSS", description: "Practice Tailwind utility classes with live preview and instant validation." },
34+
javascript: { title: "JavaScript", description: "Hands-on JavaScript fundamentals: variables, DOM manipulation, events." },
35+
markdown: { title: "Markdown", description: "Learn Markdown syntax through interactive exercises with live rendering." }
36+
};
37+
38+
function escapeHtml(s) {
39+
return String(s)
40+
.replace(/&/g, "&amp;")
41+
.replace(/</g, "&lt;")
42+
.replace(/>/g, "&gt;")
43+
.replace(/"/g, "&quot;");
44+
}
45+
46+
function stripHtml(s) {
47+
return String(s).replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
48+
}
49+
50+
function loadModules() {
51+
const out = [];
52+
for (const f of readdirSync(LESSONS_DIR)) {
53+
if (!f.endsWith(".json")) continue;
54+
try {
55+
const m = JSON.parse(readFileSync(join(LESSONS_DIR, f), "utf8"));
56+
if (!m.id || !Array.isArray(m.lessons)) continue;
57+
out.push(m);
58+
} catch (e) {
59+
console.warn(`skip ${f}: ${e.message}`);
60+
}
61+
}
62+
return out;
63+
}
64+
65+
/**
66+
* Replace the SPA shell <head> tags with page-specific ones.
67+
* Uses regex on a small set of known tags so we don't reparse HTML.
68+
* If a tag is missing, append it.
69+
*/
70+
function rewriteHead(shellHtml, { title, description, canonical, jsonLd }) {
71+
let html = shellHtml;
72+
73+
// Title
74+
html = html.replace(/<title>[^<]*<\/title>/, `<title>${escapeHtml(title)}</title>`);
75+
76+
// Description meta
77+
const descRe = /<meta\s+name="description"\s+content="[^"]*"\s*\/?>/i;
78+
const newDesc = `<meta name="description" content="${escapeHtml(description)}" />`;
79+
if (descRe.test(html)) html = html.replace(descRe, newDesc);
80+
else html = html.replace("</head>", `\t\t${newDesc}\n\t</head>`);
81+
82+
// Canonical — strip ALL existing, then add one. Source HTML may
83+
// have a duplicate canonical from earlier edits.
84+
const canonReAll = /<link\s+rel="canonical"\s+href="[^"]*"\s*\/?>\s*/gi;
85+
html = html.replace(canonReAll, "");
86+
const newCanon = `<link rel="canonical" href="${canonical}" />`;
87+
html = html.replace("</head>", `\t\t${newCanon}\n\t</head>`);
88+
89+
// og:url
90+
html = html.replace(/<meta\s+property="og:url"\s+content="[^"]*"\s*\/?>/i,
91+
`<meta property="og:url" content="${canonical}" />`);
92+
93+
// og:title + twitter:title
94+
html = html.replace(/<meta\s+property="og:title"\s+content="[^"]*"\s*\/?>/i,
95+
`<meta property="og:title" content="${escapeHtml(title)}" />`);
96+
html = html.replace(/<meta\s+name="twitter:title"\s+content="[^"]*"\s*\/?>/i,
97+
`<meta name="twitter:title" content="${escapeHtml(title)}" />`);
98+
99+
// og:description + twitter:description
100+
html = html.replace(/<meta\s+property="og:description"\s+content="[^"]*"\s*\/?>/i,
101+
`<meta property="og:description" content="${escapeHtml(description)}" />`);
102+
html = html.replace(/<meta\s+name="twitter:description"\s+content="[^"]*"\s*\/?>/i,
103+
`<meta name="twitter:description" content="${escapeHtml(description)}" />`);
104+
105+
// Replace existing JSON-LD block (the shell has the home one; we
106+
// substitute a per-page one).
107+
html = html.replace(
108+
/<script\s+type="application\/ld\+json"[^>]*>[\s\S]*?<\/script>/,
109+
`<script type="application/ld+json">\n${JSON.stringify(jsonLd)}\n</script>`
110+
);
111+
112+
return html;
113+
}
114+
115+
function lessonJsonLd({ moduleObj, lesson, lessonIndex, canonical }) {
116+
return {
117+
"@context": "https://schema.org",
118+
"@type": "LearningResource",
119+
"name": lesson.title,
120+
"description": stripHtml(lesson.task || lesson.description || ""),
121+
"url": canonical,
122+
"learningResourceType": "Activity",
123+
"educationalLevel": moduleObj.difficulty || "Beginner",
124+
"isAccessibleForFree": true,
125+
"inLanguage": "en",
126+
"teaches": moduleObj.title || moduleObj.id,
127+
"isPartOf": {
128+
"@type": "Course",
129+
"@id": `${ORIGIN}/${moduleObj.id}/`,
130+
"name": moduleObj.title || moduleObj.id
131+
},
132+
"position": lessonIndex + 1,
133+
"author": {
134+
"@type": "Organization",
135+
"name": "LibreTECH",
136+
"url": "https://librete.ch"
137+
}
138+
};
139+
}
140+
141+
function moduleJsonLd({ moduleObj, canonical }) {
142+
return {
143+
"@context": "https://schema.org",
144+
"@type": "Course",
145+
"name": moduleObj.title || moduleObj.id,
146+
"description": stripHtml(moduleObj.description || ""),
147+
"url": canonical,
148+
"provider": {
149+
"@type": "Organization",
150+
"name": "LibreTECH",
151+
"url": "https://librete.ch"
152+
},
153+
"educationalLevel": moduleObj.difficulty || "Beginner",
154+
"isAccessibleForFree": true,
155+
"inLanguage": "en",
156+
"hasPart": moduleObj.lessons.map((l, i) => ({
157+
"@type": "LearningResource",
158+
"name": l.title,
159+
"position": i + 1,
160+
"url": `${ORIGIN}/${moduleObj.id}/${i}`
161+
}))
162+
};
163+
}
164+
165+
function sectionJsonLd({ sectionId, canonical, modules }) {
166+
const meta = SECTIONS[sectionId];
167+
return {
168+
"@context": "https://schema.org",
169+
"@type": "CollectionPage",
170+
"name": `${meta.title} — Code Crispies`,
171+
"description": meta.description,
172+
"url": canonical,
173+
"inLanguage": "en",
174+
"hasPart": modules.map((m) => ({
175+
"@type": "Course",
176+
"name": m.title || m.id,
177+
"url": `${ORIGIN}/${m.id}/`
178+
}))
179+
};
180+
}
181+
182+
const shellHtml = readFileSync(join(DIST, "index.html"), "utf8");
183+
const modules = loadModules();
184+
185+
let lessonPages = 0;
186+
let modulePages = 0;
187+
let sectionPages = 0;
188+
189+
// Per-section landings
190+
for (const sectionId of Object.keys(SECTIONS)) {
191+
const meta = SECTIONS[sectionId];
192+
const canonical = `${ORIGIN}/${sectionId}`;
193+
// Filter modules that match this section by mode (or accept all if mode missing)
194+
const sectionModules = modules.filter((m) => {
195+
const mode = (m.mode || "").toLowerCase();
196+
if (sectionId === "html") return m.id?.startsWith("html-");
197+
if (sectionId === "markdown") return m.id?.startsWith("markdown-") || m.id?.includes("md-");
198+
if (sectionId === "javascript") return m.id?.startsWith("js-");
199+
if (sectionId === "tailwind") return m.id?.includes("tailwind") || mode === "tailwind";
200+
if (sectionId === "css") {
201+
const id = m.id || "";
202+
return !id.startsWith("html-") && !id.startsWith("md-") && !id.startsWith("markdown-") && !id.startsWith("js-") && !id.includes("tailwind");
203+
}
204+
return false;
205+
});
206+
const html = rewriteHead(shellHtml, {
207+
title: `${meta.title} — Code Crispies`,
208+
description: meta.description,
209+
canonical,
210+
jsonLd: sectionJsonLd({ sectionId, canonical, modules: sectionModules })
211+
});
212+
const dir = join(DIST, sectionId);
213+
mkdirSync(dir, { recursive: true });
214+
writeFileSync(join(dir, "index.html"), html);
215+
sectionPages++;
216+
}
217+
218+
// Per-module + per-lesson pages
219+
for (const m of modules) {
220+
const moduleCanonical = `${ORIGIN}/${m.id}/`;
221+
const moduleTitle = `${m.title || m.id} — Code Crispies`;
222+
const moduleDesc = stripHtml(m.description || `Interactive lessons covering ${m.title || m.id}.`).slice(0, 200);
223+
224+
const moduleHtml = rewriteHead(shellHtml, {
225+
title: moduleTitle,
226+
description: moduleDesc,
227+
canonical: moduleCanonical,
228+
jsonLd: moduleJsonLd({ moduleObj: m, canonical: moduleCanonical })
229+
});
230+
const moduleDir = join(DIST, m.id);
231+
mkdirSync(moduleDir, { recursive: true });
232+
writeFileSync(join(moduleDir, "index.html"), moduleHtml);
233+
modulePages++;
234+
235+
for (let i = 0; i < m.lessons.length; i++) {
236+
const lesson = m.lessons[i];
237+
if (!lesson) continue;
238+
const canonical = `${ORIGIN}/${m.id}/${i}`;
239+
const title = `${lesson.title}${m.title || m.id} | Code Crispies`;
240+
const description = stripHtml(lesson.task || lesson.description || moduleDesc).slice(0, 200) || moduleDesc;
241+
242+
const html = rewriteHead(shellHtml, {
243+
title,
244+
description,
245+
canonical,
246+
jsonLd: lessonJsonLd({ moduleObj: m, lesson, lessonIndex: i, canonical })
247+
});
248+
const lessonDir = join(DIST, m.id, String(i));
249+
mkdirSync(lessonDir, { recursive: true });
250+
writeFileSync(join(lessonDir, "index.html"), html);
251+
lessonPages++;
252+
}
253+
}
254+
255+
console.log(`✓ wrote ${sectionPages} section + ${modulePages} module + ${lessonPages} lesson pages`);

0 commit comments

Comments
 (0)