diff --git a/.githooks/post-receive b/.githooks/post-receive index 19644d5..398fb80 100644 --- a/.githooks/post-receive +++ b/.githooks/post-receive @@ -65,6 +65,7 @@ echo "Moving tested deployment from '$test_dir' to '$deploy_path'..." [[ -d "$deploy_path" ]] && rm -rf "$deploy_path" mv "$test_dir" "$deploy_path" + git config -f /srv/jasonpoage.com/expressjs-blog.git/modules/content/config core.worktree "/srv/jasonpoage.com/expressjs-blog-testing/content" ln -f "$envfile" "$deploy_path/.env" systemctl --user restart express-blog@"$branch".service @@ -257,7 +258,7 @@ initialize_submodules() { local worktree="$1" echo "Initializing and updating submodules for test environment..." - git --work-tree="$worktree" submodule update --init --recursive || { + git --git-dir="$worktree/.git" --work-tree="$worktree" submodule update --init --recursive || { ls "$worktree" -a echo "Error: Failed to initialize/update submodules for test environment." return 1 diff --git a/content b/content index ab062f8..a69e6ad 160000 --- a/content +++ b/content @@ -1 +1 @@ -Subproject commit ab062f8e24672c3b225a14bafbd806aff679e1d5 +Subproject commit a69e6ad4983006f1ff002a337382d137149921ca diff --git a/package-lock.json b/package-lock.json index dee6d7b..ac7266d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "express": "^5.1.0", "express-handlebars": "^8.0.2", "express-rate-limit": "^7.5.0", + "fast-glob": "^3.3.3", "gray-matter": "^4.0.3", "hbs": "^4.2.0", "helmet": "^8.1.0", @@ -571,6 +572,41 @@ "node": ">=12" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@npmcli/fs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", @@ -3009,6 +3045,22 @@ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, "node_modules/fast-json-patch": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", @@ -3022,6 +3074,15 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fclone": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/fclone/-/fclone-1.0.11.tgz", @@ -4708,6 +4769,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -6483,6 +6566,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/random-bytes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", @@ -6755,6 +6858,16 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -6871,6 +6984,29 @@ "node": ">= 0.6" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/run-series": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/run-series/-/run-series-1.1.9.tgz", diff --git a/package.json b/package.json index b4682d0..2e7583d 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "express": "^5.1.0", "express-handlebars": "^8.0.2", "express-rate-limit": "^7.5.0", + "fast-glob": "^3.3.3", "gray-matter": "^4.0.3", "hbs": "^4.2.0", "helmet": "^8.1.0", diff --git a/public/css/tag_posts.css b/public/css/tag_posts.css new file mode 100644 index 0000000..655fbbf --- /dev/null +++ b/public/css/tag_posts.css @@ -0,0 +1,28 @@ +.tag-post-list { + list-style: none; + padding: 0; + margin: 0; +} + +.tag-post-item { + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid #ddd; +} + +.tag-post-link { + font-weight: 600; + font-size: 1.2rem; + color: #1a73e8; + text-decoration: none; +} + +.tag-post-link:hover { + text-decoration: underline; +} + +.tag-post-excerpt { + margin-top: 0.5rem; + color: #555; + font-size: 0.95rem; +} diff --git a/public/css/tags.css b/public/css/tags.css new file mode 100644 index 0000000..31d2c3a --- /dev/null +++ b/public/css/tags.css @@ -0,0 +1,39 @@ +main.container { + max-width: 80ch; + margin: 0 auto; + padding: 2rem; + font-family: sans-serif; + background-color: #fff; + color: #222; +} + +main.container h1 { + font-size: 2rem; + margin-bottom: 1.5rem; +} + +main.container ul.tag-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +main.container li.tag-item { + background-color: #f0f0f0; + padding: 0.5rem 0.75rem; + border-radius: 4px; +} + +main.container li.tag-item a { + text-decoration: none; + color: #333; + font-weight: 500; +} + +main.container li.tag-item a:hover { + text-decoration: underline; + color: #007acc; +} diff --git a/src/constants/errorConstants.js b/src/constants/errorConstants.js index 21e37bf..870b211 100644 --- a/src/constants/errorConstants.js +++ b/src/constants/errorConstants.js @@ -2,13 +2,11 @@ const DEFAULT_STACK_TRACE = "No stack trace available"; const DEFAULT_STATUS_CODE = 500; const DEFAULT_LOG_LEVEL = "error"; -const ERROR_VIEW = "pages/error"; const ERROR_REDIRECT_PATH = "/error"; module.exports = { DEFAULT_ERROR_MESSAGE, DEFAULT_STACK_TRACE, DEFAULT_STATUS_CODE, DEFAULT_LOG_LEVEL, - ERROR_VIEW, ERROR_REDIRECT_PATH, }; diff --git a/src/constants/sitemapConstants.js b/src/constants/sitemapConstants.js index 84ffdb1..86377db 100644 --- a/src/constants/sitemapConstants.js +++ b/src/constants/sitemapConstants.js @@ -1,6 +1,7 @@ // constants/sitemapConstants.js const STATIC_SITEMAP_PATH = "../../content/sitemap.json"; const POSTS_PATH = "../../content/posts"; +const PAGES_PATH = "../../content/pages"; const DEFAULT_CHANGEFREQ = "monthly"; const DEFAULT_PRIORITY = "0.5"; @@ -9,6 +10,7 @@ module.exports = { STATIC_SITEMAP_PATH, + PAGES_PATH, POSTS_PATH, DEFAULT_CHANGEFREQ, DEFAULT_PRIORITY, diff --git a/src/middleware/analytics.js b/src/middleware/analytics.js index 57bcb9b..7548b3d 100644 --- a/src/middleware/analytics.js +++ b/src/middleware/analytics.js @@ -11,7 +11,7 @@ db.run( `INSERT INTO analytics (timestamp, url, referrer, user_agent, js_enabled, forwardedIp, directIp) - VALUES (?, ?, ?, ?, ?, ?, ?)`, // fixme + VALUES (?, ?, ?, ?, ?, ?, ?)`, // fixme, join together in main table? i dont know what i was suppose to fix. it works fine [timestamp, url, referrer, userAgent, 0, forwardedIp, directIp] ); } diff --git a/src/middleware/baseContext.js b/src/middleware/baseContext.js index a0d17a8..f64ca1d 100644 --- a/src/middleware/baseContext.js +++ b/src/middleware/baseContext.js @@ -15,5 +15,12 @@ res.render(template, Object.assign({}, baseContext, overrides)); }; + res.renderGenericMessage = (overrides = {}) => { + res.render( + "pages/generic-message", + Object.assign({}, baseContext, overrides) + ); + }; + next(); }; diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js index 1dc9098..45881f7 100644 --- a/src/middleware/errorHandler.js +++ b/src/middleware/errorHandler.js @@ -9,7 +9,6 @@ DEFAULT_STACK_TRACE, DEFAULT_STATUS_CODE, DEFAULT_LOG_LEVEL, - ERROR_VIEW, ERROR_REDIRECT_PATH, } = require("../constants/errorConstants"); @@ -38,14 +37,15 @@ }; if (req?.log?.error) { - req.log.error(logEntry); + req.log.error(logEntry); // fixme, logs arent logging? + console.log(logEntry); } else { console.error(logEntry); } const errorContext = getErrorContext(code || statusCode); - if (!isDev) { + if (!isDev && !req?.isAuthenticated) { res.customRedirect( `${ERROR_REDIRECT_PATH}?code=${errorContext.statusCode}` ); @@ -64,5 +64,6 @@ }); const errorPageContext = await getBaseContext(req?.isAuthenticated, context); - res.status(errorContext.statusCode).render(ERROR_VIEW, errorPageContext); + res.status(errorContext.statusCode); + res.renderGenericMessage(errorPageContext); }; diff --git a/src/middleware/redirect.js b/src/middleware/redirect.js index a82d6d0..b93f25a 100644 --- a/src/middleware/redirect.js +++ b/src/middleware/redirect.js @@ -1,6 +1,7 @@ // src/middleware/redirect.js const { baseUrl } = require("../utils/baseUrl"); +const { qualifyLink } = require("../utils/qualifyLinks"); // Configuration - adjust these as needed const redirectConfig = { @@ -27,9 +28,6 @@ function handleRedirect(req, res, targetPath, status = 302) { const redirectUrl = buildRedirectUrl(req, targetPath); - // Log the redirect for debugging - console.log(`[REDIRECT] ${req.originalUrl} -> ${redirectUrl}`); - // Check if this is a request that expects JSON (API calls) if (req.accepts("json") && !req.accepts("html")) { return res.status(301).json({ @@ -47,11 +45,10 @@ originalUrl: req.originalUrl, }); } - // Middleware function to check for redirects function redirectMiddleware(req, res, next) { res.customRedirect = (targetPath, status) => - handleRedirect(req, res, targetPath, status); + handleRedirect(req, res, qualifyLink(targetPath), status); const targetPath = redirectConfig[req.path]; if (targetPath) { diff --git a/src/routes/contact.js b/src/routes/contact.js index d60d786..b8a68e6 100644 --- a/src/routes/contact.js +++ b/src/routes/contact.js @@ -61,12 +61,6 @@ ); } -// Middleware to capture request start time -router.use((req, res, next) => { - req._startTime = Date.now(); - next(); -}); - router.post("/contact", formLimiter, async (req, res, next) => { try { const { name, email, message, subject, hcaptchaToken, clientData } = @@ -220,8 +214,10 @@ await logSecurityEvent(securityData, "thankyou_access"); - res.renderWithBaseContext("pages/thankyou.handlebars", { + res.renderGenericMessage({ title: "Thank You", + message: + "Your message has been sent successfully. We will get back to you shortly.", }); }); diff --git a/src/routes/errorPage.js b/src/routes/errorPage.js index 1140b49..19f49c0 100644 --- a/src/routes/errorPage.js +++ b/src/routes/errorPage.js @@ -13,5 +13,5 @@ }; res.status(errorContext.statusCode); - res.renderWithBaseContext("pages/error", context); + res.renderGenericMessage(context); }; diff --git a/src/routes/index.js b/src/routes/index.js index b873373..5197f9d 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -9,6 +9,7 @@ const csrfToken = require("../middleware/csrfToken"); const errorPage = require("./errorPage"); const admin = require("./admin"); +const tags = require("./tags"); const contact = require("./contact"); const sitemap = require("./sitemap"); @@ -20,7 +21,6 @@ const securedMiddleware = require("../middleware/secured"); const securedRoutes = require("./secured"); -const testingRoutes = require("./testing"); const favicon = require("serve-favicon"); const faviconsPath = path.join(__dirname, "..", "..", "public", "favicons"); @@ -31,16 +31,6 @@ }); router.use("/admin", securedMiddleware, securedRoutes); -router.use( - "/test", - (req, res, next) => { - if (process.env.NODE_ENV !== "production") { - return next(); - } - next(new HttpError(403, "Attempt to access test data")); - }, - testingRoutes -); router.get("/error", errorPage); // Landing page after error is logged @@ -76,55 +66,16 @@ router.use(sitemap); router.use(pages); router.use(rssFeed); +router.use(tags); router.get("/blog/:year/:month/:name", post); -function flattenRouterLayers(stack, acc = []) { - for (const layer of stack) { - acc.push(layer); - const h = layer.handle; - if (typeof h === "function") { - if (h.stack && Array.isArray(h.stack)) { - flattenRouterLayers(h.stack, acc); - } else if (h.handle && h.handle.stack && Array.isArray(h.handle.stack)) { - flattenRouterLayers(h.handle.stack, acc); - } - } - } - return acc; -} - -// router.use((req, res) => { -// const rootStack = req.app._router?.stack || req.app.router?.stack; -// if (!rootStack) return res.sendStatus(500); -// const flat = flattenRouterLayers(rootStack); -// const routes = []; -// flat.forEach((l) => { -// if (l.route) { -// routes.push(l.route.path); -// } -// }); -// res.json(routes).send(200); -// }); - -// router.use((req, res) => { -// const appStack = req.app._router?.stack || req.app.router?.stack; -// if (!appStack) return res.sendStatus(500); -// const flatStack = flattenRouterStack(appStack); -// flatStack.forEach((layer) => { -// console.log(layer); -// }); -// res.sendStatus(200); -// }); - router.get("/", (req, res) => { - console.log(qualifyLink("/blog")); - // res.customRedirect(qualifyLink("/blog"), 301); res.customRedirect("/blog", 301); }); router.use((req, res, next) => { - console.log(req.url); + req.log(req.url); next(new HttpError("Page not found", 404)); }); diff --git a/src/routes/newsletter.js b/src/routes/newsletter.js index 58ff4f9..48c6aa6 100644 --- a/src/routes/newsletter.js +++ b/src/routes/newsletter.js @@ -3,11 +3,15 @@ const sendNewsletterSubscriptionMail = require("../utils/sendNewsletterSubscriptionMail"); const { saveEmail } = require("../services/newsletterService"); const formLimiter = require("../utils/formLimiter"); +const { unsubscribeEmail } = require("../services/newsletterService"); +const { ERRORS } = require("../constants/newsletterConstants"); -const { validateAndSanitizeEmail } = require("../utils/emailValidator"); +const { + validateAndSanitizeEmail, + MESSAGES, +} = require("../utils/emailValidator"); const { qualifyLink } = require("../utils/qualifyLinks"); -const HttpError = require("../utils/HttpError"); router.get("/newsletter", async (req, res) => { const context = { @@ -21,7 +25,9 @@ router.get("/newsletter/success", async (req, res) => { const context = { - title: "Thank You", + title: "Unsubscribed", + message: + "You’ve successfully subscribed to my newsletter. Stay tuned for updates.", }; res.renderWithBaseContext("pages/newsletter-success.handlebars", context); }); @@ -39,14 +45,34 @@ try { await saveEmail(sanitizedEmail); await sendNewsletterSubscriptionMail({ email: sanitizedEmail }); - res.customRedirect("/newsletter/success"); // fixme qualifyLink() + res.customRedirect("/newsletter/success"); } catch (err) { - console.error("Newsletter subscription error:", err); + req.log.error("Newsletter subscription error:", err); if (err.code === "DUPLICATE_EMAIL") { - return res.customRedirect("/newsletter/success"); // fixme qualifyLink() + return res.customRedirect("/newsletter/success"); } next(err); } }); +router.get("/unsubscribe", async (req, res) => { + const { valid, email, message } = validateAndSanitizeEmail(req.query.email); + + if (!valid) { + return next(new HttpError(message || ERRORS.INVALID_EMAIL, 400)); + } + + try { + await unsubscribeEmail(email); + const context = { + title: "Thank You", + message: + "You’ve been successfully removed from the newsletter mailing list.", + }; + res.renderGenericMessage(context); + } catch (err) { + next(new HttpError({ error: "Failed to unsubscribe" }, 500)); + } +}); + module.exports = router; diff --git a/src/routes/pages.js b/src/routes/pages.js index c6ac3a1..07499bf 100644 --- a/src/routes/pages.js +++ b/src/routes/pages.js @@ -24,7 +24,7 @@ construction.register("/changelog", "Changelog"); construction.register("/archive", "Archive"); -construction.register("/tags", "Tags"); +// construction.register("/tags", "Tags"); // construction.register("/contact", "Contact Me"); markdown.register("/tools", "tools", "tools"); diff --git a/src/routes/secured/logs.js b/src/routes/secured/logs.js index f762e5b..8144200 100644 --- a/src/routes/secured/logs.js +++ b/src/routes/secured/logs.js @@ -196,7 +196,6 @@ const totalPages = Math.ceil(total / limit); - console.log(logs); res.json({ logs, pagination: { diff --git a/src/routes/tags.js b/src/routes/tags.js new file mode 100644 index 0000000..2ae8867 --- /dev/null +++ b/src/routes/tags.js @@ -0,0 +1,36 @@ +const express = require("express"); +const { getAllTags, getPostsByTag } = require("../services/tagsService"); +const HttpError = require("../utils/HttpError"); + +const router = express.Router(); + +router.get("/tags", async (req, res, next) => { + try { + const tags = await getAllTags(); + const context = { tags }; + res.renderWithBaseContext("pages/tags", context); + } catch (err) { + next(err); + } +}); + +router.get("/tags/:tag", async (req, res, next) => { + const tag = req.params.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, + posts, + }; + + res.renderWithBaseContext("pages/tag-posts", context); +}); + +module.exports = router; diff --git a/src/services/newsletterService.js b/src/services/newsletterService.js index be3a651..8eeae24 100644 --- a/src/services/newsletterService.js +++ b/src/services/newsletterService.js @@ -1,55 +1,73 @@ // src/services/newsletterService.js const fs = require("fs").promises; const path = require("path"); -const { - FILE_PATH, - EMAIL_REGEX, - ERRORS, -} = require("../constants/newsletterConstants"); +const { FILE_PATH, ERRORS } = require("../constants/newsletterConstants"); +const { validateAndSanitizeEmail } = require("../utils/emailValidator"); let writeLock = Promise.resolve(); -function isValidEmail(email) { - return EMAIL_REGEX.test(email); -} +async function saveEmail(rawEmail) { + const { valid, email, message } = validateAndSanitizeEmail(rawEmail); + if (!valid) throw new Error(message || ERRORS.INVALID_EMAIL); -async function saveEmail(email) { - try { - if (!isValidEmail(email)) { - throw new Error(ERRORS.INVALID_EMAIL); + await fs.mkdir(path.dirname(FILE_PATH), { recursive: true }); + + writeLock = writeLock.then(async () => { + let data = []; + try { + const file = await fs.readFile(FILE_PATH, "utf8"); + data = JSON.parse(file); + } catch (e) { + if (e.code !== "ENOENT" && !(e instanceof SyntaxError)) { + console.error(ERRORS.PARSE_FAILURE, e); + throw e; + } } - const sanitizedEmail = email.trim().toLowerCase(); - - await fs.mkdir(path.dirname(FILE_PATH), { recursive: true }); - - writeLock = writeLock.then(async () => { - let data = []; + if (!data.includes(email)) { + data.push(email); try { - const file = await fs.readFile(FILE_PATH, "utf8"); - data = JSON.parse(file); - } catch (e) { - if (e.code !== "ENOENT" && !(e instanceof SyntaxError)) { - console.error(ERRORS.PARSE_FAILURE, e); - throw e; - } + await fs.writeFile(FILE_PATH, JSON.stringify(data, null, 2)); + } catch (err) { + console.error(ERRORS.WRITE_FAILURE, err); + throw err; } + } + }); - if (!data.includes(sanitizedEmail)) { - data.push(sanitizedEmail); - try { - await fs.writeFile(FILE_PATH, JSON.stringify(data, null, 2)); - } catch (err) { - console.error(ERRORS.WRITE_FAILURE, err); - throw err; - } - } - }); - } catch (err) { - console.error(ERRORS.SAVE_EMAIL_FAILURE, err); - throw err; - } return await writeLock; } -module.exports = { saveEmail }; +async function unsubscribeEmail(rawEmail) { + const { valid, email, message } = validateAndSanitizeEmail(rawEmail); + if (!valid) throw new Error(message || ERRORS.INVALID_EMAIL); + + writeLock = writeLock.then(async () => { + let data = []; + try { + const file = await fs.readFile(FILE_PATH, "utf8"); + data = JSON.parse(file); + } catch (e) { + if (e.code === "ENOENT") return; + if (!(e instanceof SyntaxError)) { + console.error(ERRORS.PARSE_FAILURE, e); + throw e; + } + } + + const index = data.indexOf(email); + if (index !== -1) { + data.splice(index, 1); + try { + await fs.writeFile(FILE_PATH, JSON.stringify(data, null, 2)); + } catch (err) { + console.error(ERRORS.WRITE_FAILURE, err); + throw err; + } + } + }); + + return await writeLock; +} + +module.exports = { saveEmail, unsubscribeEmail }; diff --git a/src/services/sitemapService.js b/src/services/sitemapService.js index c380972..f4d7610 100644 --- a/src/services/sitemapService.js +++ b/src/services/sitemapService.js @@ -4,6 +4,7 @@ const { getAllPosts } = require("../utils/postFileUtils"); const { STATIC_SITEMAP_PATH, + PAGES_PATH, POSTS_PATH, DEFAULT_CHANGEFREQ, DEFAULT_PRIORITY, @@ -11,9 +12,12 @@ BLOG_POST_PRIORITY, } = require("../constants/sitemapConstants"); +const matter = require("gray-matter"); + class SitemapService { constructor() { this.staticSitemapPath = path.resolve(__dirname, STATIC_SITEMAP_PATH); + this.pagesPath = path.join(__dirname, PAGES_PATH); this.postsPath = path.join(__dirname, POSTS_PATH); } @@ -27,6 +31,37 @@ } } + async getStaticPages() { + try { + const filenames = await fs.readdir(this.pagesPath); + const pages = []; + + for (const file of filenames) { + const fullPath = path.join(this.pagesPath, file); + const stat = await fs.stat(fullPath); + if (stat.isDirectory()) continue; + + const raw = await fs.readFile(fullPath, "utf8"); + const { data: frontmatter } = matter(raw); + + if (!frontmatter.published) continue; + + pages.push({ + loc: `/${frontmatter.slug || file.replace(/\.(md|mdx|handlebars)$/, "")}`, + title: frontmatter.title || "", + lastmod: frontmatter.updated || frontmatter.date || null, + changefreq: "monthly", + priority: 0.7, + }); + } + + return pages; + } catch (err) { + console.warn("Failed to load static pages:", err); + return []; + } + } + async getBlogPostUrls() { const allPosts = await getAllPosts(this.postsPath); @@ -60,13 +95,30 @@ } return false; } + async _loadStaticLayout() { + try { + const data = await fs.readFile(this.staticSitemapPath, "utf-8"); + return JSON.parse(data); + } catch { + console.warn("Could not load static sitemap.json, using empty array"); + return []; + } + } async getCompleteSitemap() { - const [staticPages, blogUrls] = await Promise.all([ + const [staticPagesJsonTree, staticPages, blogUrls] = await Promise.all([ + this._loadStaticLayout(), this.getStaticPages(), this.getBlogPostUrls(), ]); + const pageItems = staticPages.map((page) => ({ + loc: page.loc, + title: page.title, + lastmod: page.lastmod, + changefreq: page.changefreq, + priority: page.priority, + })); const blogPosts = blogUrls.map((url) => ({ loc: url.loc, title: url.loc.split("/").pop().replace(/-/g, " "), @@ -74,9 +126,11 @@ changefreq: url.changefreq, priority: url.priority, })); - this.injectPlaceholder(staticPages, "blog-posts", blogPosts); - // return [...staticPages, blogSection]; - return staticPages; + + this.injectPlaceholder(staticPagesJsonTree, "pages", pageItems); + this.injectPlaceholder(staticPagesJsonTree, "blog-posts", blogPosts); + + return staticPagesJsonTree; } async getAllUrls() { diff --git a/src/services/tagsService.js b/src/services/tagsService.js new file mode 100644 index 0000000..84f061e --- /dev/null +++ b/src/services/tagsService.js @@ -0,0 +1,68 @@ +const fs = require("fs").promises; +const path = require("path"); +const matter = require("gray-matter"); +const glob = require("fast-glob"); +const createExcerpt = require("../utils/createExcerpt"); + +const CONTENT_ROOT = path.resolve(__dirname, "../../content"); +const pattern = `${CONTENT_ROOT}/**/*.md`; + +function slugifyTag(tag) { + return tag.toLowerCase().replace(/\s+/g, "-"); +} + +async function 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, 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 files = await glob(pattern); + const tagRegex = new RegExp(`^${tag.replace(/[-\s]/g, "[-\\s]")}$`, "i"); + + const matchedPosts = []; + + for (const filePath of files) { + const raw = await fs.readFile(filePath, "utf-8"); + const { data: frontmatter, content } = matter(raw); + + if (frontmatter.published !== true) continue; + if (!Array.isArray(frontmatter.tags)) continue; + if (!frontmatter.tags.some((t) => tagRegex.test(t))) continue; + + matchedPosts.push({ + title: frontmatter.title || "Untitled", + slug: frontmatter.slug || path.basename(filePath, ".md"), + date: frontmatter.date || null, + excerpt: createExcerpt(content, 200), + }); + } + + return matchedPosts.sort((a, b) => new Date(b.date) - new Date(a.date)); +} +module.exports = { + getAllTags, + getPostsByTag, +}; diff --git a/src/utils/createExcerpt.js b/src/utils/createExcerpt.js new file mode 100644 index 0000000..154c712 --- /dev/null +++ b/src/utils/createExcerpt.js @@ -0,0 +1,17 @@ +// src/utils/createExcerpt.js + +function createExcerpt(content, limit = 200) { + const plain = content + .replace(/[*_`~#>\[\]()]/g, "") // strip basic markdown syntax + .replace(/\n+/g, " ") // flatten newlines + .replace(/\s+/g, " ") // normalize spaces + .trim(); + + if (plain.length <= limit) return plain; + + const truncated = plain.slice(0, limit); + const lastSpace = truncated.lastIndexOf(" "); + return truncated.slice(0, lastSpace) + "…"; +} + +module.exports = createExcerpt; diff --git a/src/utils/postFileUtils.js b/src/utils/postFileUtils.js index 2a40792..ffa7295 100644 --- a/src/utils/postFileUtils.js +++ b/src/utils/postFileUtils.js @@ -3,6 +3,8 @@ const path = require("path"); const fs = require("fs").promises; +const createExcerpt = require("./createExcerpt"); + async function getAllPosts(baseDir, options = {}) { const { includeUnpublished = false } = options; @@ -29,7 +31,7 @@ const fileContent = await fs.readFile(filePath, "utf8"); const { data, content } = matter(fileContent); - const excerpt = content.replace(/\n+/g, " ").slice(0, 200) + "..."; + const excerpt = createExcerpt(content, 200); // Filter unpublished posts in production unless explicitly included if ( diff --git a/src/utils/sendContactMail.js b/src/utils/sendContactMail.js index 12ebf7c..a213255 100644 --- a/src/utils/sendContactMail.js +++ b/src/utils/sendContactMail.js @@ -1,11 +1,9 @@ const transporter = require("./transporter"); +const { validateAndSanitizeEmail } = require("../utils/emailValidator"); const MAIL_DOMAIN = process.env.MAIL_DOMAIN; const MAIL_USER = process.env.MAIL_USER; const DEFAULT_SUBJECT = "New Contact Form Submission"; -const MAX_MESSAGE_LENGTH = 2000; - -const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; function sanitizeInput(input) { return String(input) @@ -13,28 +11,25 @@ .trim(); } -function isValidEmail(email) { - return EMAIL_REGEX.test(email); -} +const HttpError = require("./HttpError"); function sendContactMail({ name, email, subject, message }) { const cleanName = sanitizeInput(name); - const cleanEmail = sanitizeInput(email); const cleanSubject = sanitizeInput(subject || DEFAULT_SUBJECT); const cleanMessage = sanitizeInput(message); - if (!isValidEmail(cleanEmail)) { - throw new Error("Invalid email format"); - } + const { + valid, + email: sanitizedEmail, + message: errorMessage, + } = validateAndSanitizeEmail(email); - if (cleanMessage.length > MAX_MESSAGE_LENGTH) { - throw new Error("Message too long"); - } + if (!valid) throw new HttpError(errorMessage || ERRORS.INVALID_EMAIL, 400); const mailData = { from: `"Contact Form" `, to: MAIL_USER, - replyTo: `"${cleanName}" <${cleanEmail}>`, + replyTo: `"${cleanName}" <${sanitizedEmail}>`, subject: cleanSubject, text: cleanMessage, }; diff --git a/src/utils/sendNewsletterSubscriptionMail.js b/src/utils/sendNewsletterSubscriptionMail.js index 9265a01..966f683 100644 --- a/src/utils/sendNewsletterSubscriptionMail.js +++ b/src/utils/sendNewsletterSubscriptionMail.js @@ -6,7 +6,7 @@ const MAIL_SUBJECT = "New Newsletter Subscription"; const MAIL_FROM = `Newsletter `; const MAIL_TEXT_TEMPLATE = (email) => - `Please add this email to the newsletter list: ${MAIL_NEWSLETTER}`; + `Please add this email to the newsletter list: ${MAIL_NEWSLETTER}`; // fixme async function sendNewsletterSubscriptionMail({ email }) { const mailData = { diff --git a/src/views/pages/about.handlebars b/src/views/pages/about.handlebars deleted file mode 100644 index bacc213..0000000 --- a/src/views/pages/about.handlebars +++ /dev/null @@ -1,9 +0,0 @@ -
-

About

-
-

This blog is maintained by Jason Poage. It covers programming, technology, and related topics.

-
-
-{{#section "scripts"}} - -{{/section}} diff --git a/src/views/pages/authRedirect.handlebars b/src/views/pages/authRedirect.handlebars deleted file mode 100644 index cd0f318..0000000 --- a/src/views/pages/authRedirect.handlebars +++ /dev/null @@ -1,5 +0,0 @@ -
-

Redirecting...

-

Please wait while we redirect you to the authentication service.

-

If you are not redirected automatically, click here.

-
diff --git a/src/views/pages/blog_index.handlebars b/src/views/pages/blog_index.handlebars index 8eb8b78..99c3fe3 100644 --- a/src/views/pages/blog_index.handlebars +++ b/src/views/pages/blog_index.handlebars @@ -1,3 +1,5 @@ +{{!-- pages/blog_index.handlebars --}} + {{#section "styles"}} {{/section}} diff --git a/src/views/pages/construction.handlebars b/src/views/pages/construction.handlebars index 5bd37d7..f41dfe5 100644 --- a/src/views/pages/construction.handlebars +++ b/src/views/pages/construction.handlebars @@ -1,6 +1,9 @@ +{{!-- pages/construction.handlebars --}} + {{#section "styles"}} {{/section}} +

{{title}}

Page Under Construction

diff --git a/src/views/pages/contact.handlebars b/src/views/pages/contact.handlebars index d33de8a..b103032 100644 --- a/src/views/pages/contact.handlebars +++ b/src/views/pages/contact.handlebars @@ -1,10 +1,14 @@ +{{!-- pages/contact.handlebars --}} + {{#section "styles"}} {{/section}} + {{#section "scripts"}} {{/section}} +

{{title}}

diff --git a/src/views/pages/error.handlebars b/src/views/pages/error.handlebars deleted file mode 100644 index 0562fa9..0000000 --- a/src/views/pages/error.handlebars +++ /dev/null @@ -1,3 +0,0 @@ -

{{title}}

-

{{message}}

-
{{{content}}}
diff --git a/src/views/pages/generic-message.handlebars b/src/views/pages/generic-message.handlebars new file mode 100644 index 0000000..5979c99 --- /dev/null +++ b/src/views/pages/generic-message.handlebars @@ -0,0 +1,13 @@ +{{!-- pages/generic-message.handlebars --}} + +{{#if title}} +

{{title}}

+{{/if}} + +{{#if message}} +

{{message}}

+{{/if}} + +{{#if content}} +
{{{content}}}
+{{/if}} diff --git a/src/views/pages/home.handlebars b/src/views/pages/home.handlebars index c9bf1ec..29a0cdb 100644 --- a/src/views/pages/home.handlebars +++ b/src/views/pages/home.handlebars @@ -1,4 +1,5 @@ {{!-- pages/home.handlebars --}} +

{{title}}

diff --git a/src/views/pages/loading.handlebars b/src/views/pages/loading.handlebars deleted file mode 100644 index a8d7676..0000000 --- a/src/views/pages/loading.handlebars +++ /dev/null @@ -1,16 +0,0 @@ -{{#section "headers"}} - -{{/section}} -{{#section "styles"}} - -{{/section}} -{{#section " scripts"}} - -{{/section}} -
-
-
-

Loading...

-
-
diff --git a/src/views/pages/newsletter-success.handlebars b/src/views/pages/newsletter-success.handlebars deleted file mode 100644 index 02ee8a8..0000000 --- a/src/views/pages/newsletter-success.handlebars +++ /dev/null @@ -1,6 +0,0 @@ -{{!-- pages/thankyou.handlebars --}} -
-

Thank You

-
-

You’ve successfully subscribed to my newsletter. Stay tuned for updates.

-
diff --git a/src/views/pages/newsletter.handlebars b/src/views/pages/newsletter.handlebars index 418c743..d6e1a37 100644 --- a/src/views/pages/newsletter.handlebars +++ b/src/views/pages/newsletter.handlebars @@ -1,9 +1,13 @@ +{{!-- pages/newsletter.handlebars --}} + {{#section "styles"}} {{/section}} + {{#section "scripts"}} {{/section}} +

{{title}}

diff --git a/src/views/pages/page.handlebars b/src/views/pages/page.handlebars index db87044..2a0681c 100644 --- a/src/views/pages/page.handlebars +++ b/src/views/pages/page.handlebars @@ -1,9 +1,14 @@ +{{!-- pages/page.handlebars --}} + {{#section "styles"}} {{/section}} -
- {{{content}}} -
+ + {{#section "scripts"}} {{/section}} + +
+ {{{content}}} +
diff --git a/src/views/pages/post.handlebars b/src/views/pages/post.handlebars index 40615e8..1356b56 100644 --- a/src/views/pages/post.handlebars +++ b/src/views/pages/post.handlebars @@ -1,11 +1,15 @@ +{{!-- pages/post.handlebars --}} + {{#section "styles"}} {{/section}} -
- {{{content}}} -
+ {{#section "scripts"}} {{/section}} + +
+ {{{content}}} +
diff --git a/src/views/pages/redirect.handlebars b/src/views/pages/redirect.handlebars index 1a9563d..c6213c4 100644 --- a/src/views/pages/redirect.handlebars +++ b/src/views/pages/redirect.handlebars @@ -1,14 +1,19 @@ +{{!-- pages/redirect.handlebars --}} + {{#section "headers"}} {{/section}} + {{#section "styles"}} {{/section}} + {{#section " scripts"}} {{/section}} +
diff --git a/src/views/pages/sitemap-xml.handlebars b/src/views/pages/sitemap-xml.handlebars index 34e9266..cded92a 100644 --- a/src/views/pages/sitemap-xml.handlebars +++ b/src/views/pages/sitemap-xml.handlebars @@ -1,3 +1,4 @@ +{{!-- pages/sitemap-xml.handlebars --}} diff --git a/src/views/pages/tag-posts.handlebars b/src/views/pages/tag-posts.handlebars new file mode 100644 index 0000000..1374681 --- /dev/null +++ b/src/views/pages/tag-posts.handlebars @@ -0,0 +1,20 @@ +{{!-- pages/handlebars --}} + +{{#section "styles"}} + +{{/section}} + +

Posts tagged with "{{tag}}"

+ +{{#if posts.length}} +
    + {{#each posts}} +
  • + {{this.title}} +

    {{this.excerpt}}

    +
  • + {{/each}} +
+{{else}} +

No posts found for this tag.

+{{/if}} diff --git a/src/views/pages/tags.handlebars b/src/views/pages/tags.handlebars new file mode 100644 index 0000000..d696d73 --- /dev/null +++ b/src/views/pages/tags.handlebars @@ -0,0 +1,21 @@ +{{!-- pages/tags.handlebars --}} + +{{#section "styles"}} + +{{/section}} + +
+

Tags

+ + {{#if tags.length}} + + {{else}} +

No tags found.

+ {{/if}} +
diff --git a/src/views/pages/thankyou.handlebars b/src/views/pages/thankyou.handlebars deleted file mode 100644 index 6976f9f..0000000 --- a/src/views/pages/thankyou.handlebars +++ /dev/null @@ -1,7 +0,0 @@ -{{!-- pages/thankyou.handlebars --}} -
-

Thank You

-
-

Your message has been sent successfully. We will get back to you shortly.

-
-
diff --git a/src/views/pages/tools.handlebars b/src/views/pages/tools.handlebars index f23d4ac..346618c 100644 --- a/src/views/pages/tools.handlebars +++ b/src/views/pages/tools.handlebars @@ -1,6 +1,9 @@ +{{!-- pages/tools.handlebars --}} + {{#section "styles"}} {{/section}} +
{{{content}}}
diff --git a/yarn.lock b/yarn.lock index 4fc32a9..ad17ada 100644 --- a/yarn.lock +++ b/yarn.lock @@ -76,6 +76,27 @@ wrap-ansi "^8.1.0" wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5": + version "2.0.5" + resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3": + version "1.2.8" + resolved "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + "@npmcli/fs@^1.0.0": version "1.1.1" resolved "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz" @@ -547,7 +568,7 @@ dependencies: balanced-match "^1.0.0" -braces@~3.0.2: +braces@^3.0.3, braces@~3.0.2: version "3.0.3" resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz" integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== @@ -1390,6 +1411,17 @@ resolved "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz" integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== +fast-glob@^3.3.3: + version "3.3.3" + resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz" + integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.8" + fast-json-patch@^3.1.0: version "3.1.1" resolved "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz" @@ -1400,6 +1432,13 @@ resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +fastq@^1.6.0: + version "1.19.1" + resolved "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz" + integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ== + dependencies: + reusify "^1.0.4" + fclone@~1.0.11, fclone@1.0.11: version "1.0.11" resolved "https://registry.npmjs.org/fclone/-/fclone-1.0.11.tgz" @@ -1629,7 +1668,7 @@ resolved "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz" integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw== -glob-parent@~5.1.2: +glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== @@ -2355,6 +2394,19 @@ resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz" integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== +merge2@^1.3.0: + version "1.4.1" + resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + mime-db@^1.54.0, "mime-db@>= 1.43.0 < 2": version "1.54.0" resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz" @@ -3006,7 +3058,7 @@ resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -picomatch@^2.0.4, picomatch@^2.2.1: +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== @@ -3312,6 +3364,11 @@ resolved "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz" integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + random-bytes@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz" @@ -3460,6 +3517,11 @@ resolved "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz" integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== +reusify@^1.0.4: + version "1.1.0" + resolved "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz" + integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== + rimraf@^3.0.2: version "3.0.2" resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz" @@ -3491,6 +3553,13 @@ mime-types "2.1.13" xml "1.0.1" +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + run-series@^1.1.8: version "1.1.9" resolved "https://registry.npmjs.org/run-series/-/run-series-1.1.9.tgz"