diff --git a/.githooks/post-receive b/.githooks/post-receive index 398fb80..2426e63 100644 --- a/.githooks/post-receive +++ b/.githooks/post-receive @@ -238,7 +238,7 @@ combine_css || return 1 echo "Starting application for tests..." - nohup yarn start >>"$logfile" 2>&1 & + nohup node src/app.js >>"$logfile" 2>&1 & echo $! >"$pidfile" # set +x diff --git a/.githooks/pre-push b/.githooks/pre-push index 8d44aab..86e9f52 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -7,29 +7,43 @@ #export GIT_SSH_COMMAND="ssh -vvv" set -euo pipefail -# set -x +set -x export TEST_SCHEMA="http" +export TEST_DOMAIN="127.0.0.1" +export TEST_PORT=4123 -# Path to store last tested commit hash -TEST_CACHE_FILE=".last_tested_commit" +node src/app.js >/dev/null 2>&1 & + APP_PID=$! -# Get current commit hash -CURRENT_COMMIT=$(git rev-parse HEAD) +sleep 2 -if [ -f "$TEST_CACHE_FILE" ] && grep -q "$CURRENT_COMMIT" "$TEST_CACHE_FILE"; then - echo "Tests already passed for commit $CURRENT_COMMIT. Skipping tests." -else - npm run test:prepush +npm run test:prepush +TEST_RESULT=$? - if [ $? -ne 0 ]; then - echo "Tests failed. Push aborted." - exit 1 + # Clean up the app process + if kill -0 $APP_PID 2>/dev/null; then + echo "Stopping app (PID: $APP_PID)..." + kill $APP_PID + # Give it time to shut down gracefully + sleep 1 + # Force kill if still running + if kill -0 $APP_PID 2>/dev/null; then + kill -9 $APP_PID 2>/dev/null || true + fi fi + + # Wait for process to fully terminate + wait $APP_PID 2>/dev/null || true + + +if [ $TEST_RESULT -ne 0 ]; then + echo "Tests failed. Push aborted." + exit 1 fi cd content git push "$remote" main cd .. -# set +x +set +x diff --git a/content b/content index a69e6ad..0428693 160000 --- a/content +++ b/content @@ -1 +1 @@ -Subproject commit a69e6ad4983006f1ff002a337382d137149921ca +Subproject commit 04286935c52a3ac07361d7582816471d7d97c52a diff --git a/src/routes/tags.js b/src/routes/tags.js index 2ae8867..54035fd 100644 --- a/src/routes/tags.js +++ b/src/routes/tags.js @@ -1,5 +1,6 @@ const express = require("express"); -const { getAllTags, getPostsByTag } = require("../services/tagsService"); +const { getPostsByTag } = require("../services/tagsService"); +const { getAllTags } = require("../services/sitemapService"); const HttpError = require("../utils/HttpError"); const router = express.Router(); @@ -13,20 +14,26 @@ next(err); } }); +function normalizeTag(tag) { + return tag + .trim() + .toLowerCase() + .replace(/[\s-]+/g, " "); +} router.get("/tags/:tag", async (req, res, next) => { const tag = req.params.tag; + const normalizedTag = normalizeTag(tag); // Replace with your data source logic to fetch posts by tag const posts = await getPostsByTag(tag); - console.log(posts); if (!posts || posts.length === 0) { return next(new HttpError("No posts found for this tag.", 404)); } const context = { - tag, + tag: normalizedTag, posts, }; diff --git a/src/services/sitemapService.js b/src/services/sitemapService.js index f4d7610..7be050e 100644 --- a/src/services/sitemapService.js +++ b/src/services/sitemapService.js @@ -1,7 +1,20 @@ // src/services/sitemapService.js const path = require("path"); +const matter = require("gray-matter"); const fs = require("fs").promises; const { getAllPosts } = require("../utils/postFileUtils"); +const hash = require("../utils/hash"); + +const glob = require("fast-glob"); +const { qualifySitemapLinks } = require("../utils/qualifyLinks"); + +const CONTENT_ROOT = path.resolve(__dirname, "../../content"); +const pattern = `${CONTENT_ROOT}/**/*.md`; + +function slugifyTag(tag) { + return tag.toLowerCase().replace(/\s+/g, "-"); +} + const { STATIC_SITEMAP_PATH, PAGES_PATH, @@ -12,8 +25,6 @@ BLOG_POST_PRIORITY, } = require("../constants/sitemapConstants"); -const matter = require("gray-matter"); - class SitemapService { constructor() { this.staticSitemapPath = path.resolve(__dirname, STATIC_SITEMAP_PATH); @@ -47,6 +58,7 @@ if (!frontmatter.published) continue; pages.push({ + id: hash(frontmatter), loc: `/${frontmatter.slug || file.replace(/\.(md|mdx|handlebars)$/, "")}`, title: frontmatter.title || "", lastmod: frontmatter.updated || frontmatter.date || null, @@ -62,16 +74,51 @@ } } - async getBlogPostUrls() { + async getAllTags() { + const tagMap = new Map(); + const files = await glob(pattern); + + for (const file of files) { + try { + const raw = await fs.readFile(file, "utf8"); + const { data } = matter(raw); + + if (!data.published || !Array.isArray(data.tags)) continue; + + for (const rawTag of data.tags) { + const tag = rawTag.trim(); + const slug = slugifyTag(tag); + const current = tagMap.get(slug) || { + name: tag, + loc: `/tags/${slug}`, + slug, + count: 0, + }; + current.count += 1; + tagMap.set(slug, current); + } + } catch (_) { + continue; + } + } + + return Array.from(tagMap.values()).sort((a, b) => + a.name.localeCompare(b.name) + ); + } + + async getBlogPosts() { const allPosts = await getAllPosts(this.postsPath); return allPosts.map((post) => ({ + id: hash(post.frontmatter), loc: `/blog/${post.year}/${post.month}/${post.slug}`, lastmod: post.date ? new Date(post.date).toISOString().split("T")[0] : null, changefreq: BLOG_POST_CHANGEFREQ, priority: BLOG_POST_PRIORITY, + tags: post.tags, })); } @@ -106,31 +153,44 @@ } async getCompleteSitemap() { - const [staticPagesJsonTree, staticPages, blogUrls] = await Promise.all([ - this._loadStaticLayout(), - this.getStaticPages(), - this.getBlogPostUrls(), - ]); + const [staticPagesJsonTree, staticPages, blogPosts, tags] = + await Promise.all([ + this._loadStaticLayout(), + this.getStaticPages(), + this.getBlogPosts(), + this.getAllTags(), + ]); const pageItems = staticPages.map((page) => ({ + id: page.id, loc: page.loc, title: page.title, lastmod: page.lastmod, changefreq: page.changefreq, priority: page.priority, + tags: page.tags, })); - const blogPosts = blogUrls.map((url) => ({ - loc: url.loc, - title: url.loc.split("/").pop().replace(/-/g, " "), - lastmod: url.lastmod, - changefreq: url.changefreq, - priority: url.priority, + const postItems = blogPosts.map((post) => ({ + id: post.id, + loc: post.loc, + title: post.loc.split("/").pop().replace(/-/g, " "), + lastmod: post.lastmod, + changefreq: post.changefreq, + priority: post.priority, + tags: post.tags, + })); + const tagItems = tags.map((tag) => ({ + title: tag.name, + loc: tag.loc, + slug: tag.slug, + count: tag.count, })); this.injectPlaceholder(staticPagesJsonTree, "pages", pageItems); - this.injectPlaceholder(staticPagesJsonTree, "blog-posts", blogPosts); + this.injectPlaceholder(staticPagesJsonTree, "blog-posts", postItems); + this.injectPlaceholder(staticPagesJsonTree, "tags", tagItems); - return staticPagesJsonTree; + return qualifySitemapLinks(staticPagesJsonTree); } async getAllUrls() { @@ -142,6 +202,7 @@ for (const entry of entries) { if (entry.loc) { out.push({ + id: entry.id, loc: entry.loc, lastmod: entry.lastmod, changefreq: entry.changefreq || DEFAULT_CHANGEFREQ, @@ -155,5 +216,4 @@ return out; } } - module.exports = new SitemapService(); diff --git a/src/services/tagsService.js b/src/services/tagsService.js index 84f061e..13c7454 100644 --- a/src/services/tagsService.js +++ b/src/services/tagsService.js @@ -7,54 +7,32 @@ const CONTENT_ROOT = path.resolve(__dirname, "../../content"); const pattern = `${CONTENT_ROOT}/**/*.md`; -function slugifyTag(tag) { - return tag.toLowerCase().replace(/\s+/g, "-"); -} +const buildTagRegex = (tag) => + new RegExp(`^${tag.replace(/[-\s]/g, "[-\\s]")}$`, "i"); -async function getAllTags() { - const tagMap = new Map(); - const files = await glob(pattern); +const hash = require("../utils/hash"); +const sitemapService = require("../services/sitemapService"); - for (const file of files) { - try { - const raw = await fs.readFile(file, "utf8"); - const { data } = matter(raw); - - if (!data.published || !Array.isArray(data.tags)) continue; - - for (const rawTag of data.tags) { - const tag = rawTag.trim(); - const slug = slugifyTag(tag); - const current = tagMap.get(slug) || { name: tag, slug, count: 0 }; - current.count += 1; - tagMap.set(slug, current); - } - } catch (_) { - continue; - } - } - - return Array.from(tagMap.values()).sort((a, b) => - a.name.localeCompare(b.name) - ); -} async function getPostsByTag(tag) { + const allUrls = await sitemapService.getAllUrls(); const files = await glob(pattern); - const tagRegex = new RegExp(`^${tag.replace(/[-\s]/g, "[-\\s]")}$`, "i"); + const tagRegex = buildTagRegex(tag); const matchedPosts = []; for (const filePath of files) { const raw = await fs.readFile(filePath, "utf-8"); const { data: frontmatter, content } = matter(raw); + const fileHash = hash(frontmatter); if (frontmatter.published !== true) continue; if (!Array.isArray(frontmatter.tags)) continue; if (!frontmatter.tags.some((t) => tagRegex.test(t))) continue; + const urlMatches = allUrls.find((url) => url.id == fileHash); matchedPosts.push({ title: frontmatter.title || "Untitled", - slug: frontmatter.slug || path.basename(filePath, ".md"), + loc: urlMatches.loc, date: frontmatter.date || null, excerpt: createExcerpt(content, 200), }); @@ -63,6 +41,6 @@ return matchedPosts.sort((a, b) => new Date(b.date) - new Date(a.date)); } module.exports = { - getAllTags, getPostsByTag, + buildTagRegex, }; diff --git a/src/utils/baseUrl.js b/src/utils/baseUrl.js index dea4d02..535f5bc 100644 --- a/src/utils/baseUrl.js +++ b/src/utils/baseUrl.js @@ -1,14 +1,16 @@ // src/utils/baseUrl.js -function getBaseUrl({ schema = null, host = null } = {}) { +function getBaseUrl({ schema = null, host = null, port = null } = {}) { const envSchema = process.env.TEST_SCHEMA || process.env.SERVER_SCHEMA; const envDomain = process.env.TEST_DOMAIN || process.env.SERVER_DOMAIN; + const envPort = process.env.TEST_PORT || process.env.SERVER_PORT; + const finalPort = envPort || port || 3000; const finalProtocol = envSchema || schema || "https"; const finalDomain = (envDomain || host || "localhost") .replace(/^https?:\/\//, "") .replace(/\/$/, ""); - return `${finalProtocol}://${finalDomain}`; + return `${finalProtocol}://${finalDomain}${finalPort != 80 ? `:${finalPort}` : ""}`; } const baseUrl = getBaseUrl(); diff --git a/src/utils/hash.js b/src/utils/hash.js new file mode 100644 index 0000000..6833b38 --- /dev/null +++ b/src/utils/hash.js @@ -0,0 +1,9 @@ +const crypto = require("crypto"); +function hash(input) { + return crypto + .createHash("sha256") + .update(JSON.stringify(input)) + .digest("hex"); +} + +module.exports = hash; diff --git a/src/utils/postFileUtils.js b/src/utils/postFileUtils.js index ffa7295..d6cd372 100644 --- a/src/utils/postFileUtils.js +++ b/src/utils/postFileUtils.js @@ -4,6 +4,7 @@ const fs = require("fs").promises; const createExcerpt = require("./createExcerpt"); +const hash = require("./hash"); async function getAllPosts(baseDir, options = {}) { const { includeUnpublished = false } = options; @@ -45,6 +46,7 @@ const url = `/blog/${yearDir.name}/${monthDir.name}/${slug}`; return { + id: hash(data), url, slug, title: data.title || slug.replace(/-/g, " "), diff --git a/src/utils/qualifyLinks.js b/src/utils/qualifyLinks.js index 07658a6..846b23b 100644 --- a/src/utils/qualifyLinks.js +++ b/src/utils/qualifyLinks.js @@ -21,4 +21,20 @@ }); } -module.exports = { qualifyNavLinks, qualifyLink }; +function qualifySitemapLinks(links) { + return links.map((item) => { + const qualified = { ...item }; + + if (typeof qualified.loc === "string") { + qualified.loc = qualifyLink(qualified.loc); + } + + if (Array.isArray(qualified.children)) { + qualified.children = qualifySitemapLinks(qualified.children); + } + + return qualified; + }); +} + +module.exports = { qualifyNavLinks, qualifySitemapLinks, qualifyLink }; diff --git a/src/views/pages/sitemap.handlebars b/src/views/pages/sitemap.handlebars index 472683d..8c41f02 100644 --- a/src/views/pages/sitemap.handlebars +++ b/src/views/pages/sitemap.handlebars @@ -9,7 +9,11 @@
  • {{#if children}} + {{#if loc}} + {{#if title}}{{title}}{{else}}{{label}}{{/if}} + {{else}} {{#if title}}{{title}}{{else}}{{label}}{{/if}} + {{/if}}