Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions documentation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
"@types/react-dom": "^17.0.11",
"@types/react-helmet": "^6.1.2",
"cross-env": "^7.0.3",
"fast-xml-parser": "^5.4.1",
"fs-extra": "^10.1.0",
"jest": "^29.3.1",
"jest-environment-jsdom": "^29.3.1",
Expand Down
185 changes: 171 additions & 14 deletions documentation/plugins/blog-plugin.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
const blogPluginExports = require("@docusaurus/plugin-content-blog");
const utils = require("@docusaurus/utils");
const path = require("path");
const fs = require("fs");
const { XMLParser, XMLBuilder } = require("fast-xml-parser");

const defaultBlogPlugin = blogPluginExports.default;

// Module-level map populated during contentLoaded, consumed by postBuild.
let blogLastmodMap = {};
const DEFAULT_BLOG_CATEGORY = "Engineering";
const DEFAULT_POSTS_PER_PAGE_MODE = "ALL";
const PAGINATION_PATH_SEGMENT = "page";
Expand Down Expand Up @@ -111,7 +116,9 @@ const pluginDataDirRoot = path.join(
CONTENT_BLOG_PLUGIN_DIR,
);
const aliasedSource = (source) =>
`${BLOG_ALIAS_PREFIX}/${utils.posixPath(path.relative(pluginDataDirRoot, source))}`;
`${BLOG_ALIAS_PREFIX}/${utils.posixPath(
path.relative(pluginDataDirRoot, source),
)}`;

function formatBlogDate(dateValue) {
if (!dateValue) {
Expand Down Expand Up @@ -151,7 +158,10 @@ function paginateBlogPosts({

function permalink(page) {
return page > 0
? utils.normalizeUrl([basePageUrl, `${PAGINATION_PATH_SEGMENT}/${page + 1}`])
? utils.normalizeUrl([
basePageUrl,
`${PAGINATION_PATH_SEGMENT}/${page + 1}`,
])
: basePageUrl;
}

Expand Down Expand Up @@ -188,10 +198,12 @@ function toPostInfo(post) {
title: post.metadata.title,
description: post.metadata.description,
permalink: post.metadata.permalink,
formattedDate: post.metadata.formattedDate,
authors: post.metadata.authors,
readingTime: post.metadata.readingTime,
date: post.metadata.date,
formattedDate: post.metadata.formattedDate,
lastUpdate: post.metadata.lastUpdate,
formattedLastUpdateDate: post.metadata.formattedLastUpdateDate,
};
}

Expand Down Expand Up @@ -427,17 +439,118 @@ function validateCategoryAndTags({ allBlogPosts, allowedValues }) {

if (unknownTags.size > 0) {
messageLines.push(
`Unknown tags (${unknownTags.size}): ${[...unknownTags].sort().join(", ")}`,
`Unknown tags (${unknownTags.size}): ${[...unknownTags]
.sort()
.join(", ")}`,
);
messageLines.push(...invalidTags);
messageLines.push("");
}

messageLines.push("Update ALLOWED_CATEGORIES / ALLOWED_TAGS in blog-plugin.js.");
messageLines.push(
"Update ALLOWED_CATEGORIES / ALLOWED_TAGS in blog-plugin.js.",
);

throw new Error(messageLines.join("\n"));
}

/**
* Patches the generated sitemap.xml with <lastmod> dates from blog frontmatter.
*
* Sitemap uses absolute URLs (https://refine.dev/blog/foo) while our map stores
* relative permalinks (/blog/foo), so we compare by pathname only.
*/
function injectSitemapLastmod(sitemapPath) {
const xmlOptions = {
ignoreAttributes: false,
preserveOrder: true,
commentPropName: "#comment",
format: true,
indentBy: " ",
};

// Normalize permalinks by stripping trailing slashes for consistent matching
const permalinkToLastmod = new Map();
for (const [permalink, lastmod] of Object.entries(blogLastmodMap)) {
permalinkToLastmod.set(permalink.replace(/\/+$/, ""), lastmod);
}

const sitemapXml = fs.readFileSync(sitemapPath, "utf-8");
const parser = new XMLParser(xmlOptions);
const parsed = parser.parse(sitemapXml);

const urlsetNode = parsed.find((node) => node.urlset);
if (!urlsetNode) return;

let patchCount = 0;

for (const child of urlsetNode.urlset) {
if (!child.url) continue;

const urlChildren = child.url;
const locNode = urlChildren.find((c) => c.loc !== undefined);
if (!locNode) continue;

const locValue = locNode.loc?.[0]?.["#text"];
if (!locValue) continue;

// Extract pathname from absolute URL to match against relative permalinks
let pathname;
try {
pathname = new URL(locValue).pathname;
} catch {
pathname = locValue;
}

const lastmod = permalinkToLastmod.get(pathname.replace(/\/+$/, ""));
if (!lastmod) continue;

// Don't duplicate if the entry already has a <lastmod> (e.g. from another plugin)
const hasLastmod = urlChildren.some((c) => c.lastmod !== undefined);
if (hasLastmod) continue;

// Insert <lastmod> right after <loc> to follow standard sitemap element order
const locIndex = urlChildren.indexOf(locNode);
urlChildren.splice(locIndex + 1, 0, {
lastmod: [{ "#text": lastmod }],
});
patchCount++;
}

if (patchCount > 0) {
const builder = new XMLBuilder(xmlOptions);
fs.writeFileSync(sitemapPath, builder.build(parsed), "utf-8");
console.log(
`[blog-plugin] Injected <lastmod> into ${patchCount} sitemap entries.`,
);
}
}

/**
* Waits for a file to appear on disk within a timeout.
* Returns true if the file exists, false if the timeout is reached.
*/
function waitForFile(filePath, { timeoutMs = 30_000, intervalMs = 200 } = {}) {
return new Promise((resolve) => {
if (fs.existsSync(filePath)) {
resolve(true);
return;
}

let elapsed = 0;
const timer = setInterval(() => {
elapsed += intervalMs;
if (fs.existsSync(filePath)) {
clearInterval(timer);
resolve(true);
} else if (elapsed >= timeoutMs) {
clearInterval(timer);
resolve(false);
}
}, intervalMs);
});
}

async function blogPluginExtended(...pluginArgs) {
const blogPluginInstance = await defaultBlogPlugin(...pluginArgs);

Expand All @@ -464,8 +577,15 @@ async function blogPluginExtended(...pluginArgs) {
allowedValues: allowedCategoryAndTagValues,
});

blogLastmodMap = {};

allBlogPosts.forEach((post) => {
post.metadata.formattedDate = formatBlogDate(post.metadata.date);
const lastUpdateRaw =
post.metadata.frontMatter.last_update ?? post.metadata.date;
post.metadata.lastUpdate = new Date(lastUpdateRaw).toISOString();
post.metadata.formattedLastUpdateDate = formatBlogDate(lastUpdateRaw);
blogLastmodMap[post.metadata.permalink] = post.metadata.lastUpdate;
});

const blogItemsToMetadata = {};
Expand All @@ -487,12 +607,14 @@ async function blogPluginExtended(...pluginArgs) {
}

const featuredBlogPosts = allBlogPosts.filter(
(post) => post.metadata.frontMatter[IS_FEATURED_FRONTMATTER_KEY] === true,
(post) =>
post.metadata.frontMatter[IS_FEATURED_FRONTMATTER_KEY] === true,
);
const featuredBlogPostIds = featuredBlogPosts.map((post) => post.id);

const blogPosts = allBlogPosts.filter(
(post) => post.metadata.frontMatter[IS_FEATURED_FRONTMATTER_KEY] !== true,
(post) =>
post.metadata.frontMatter[IS_FEATURED_FRONTMATTER_KEY] !== true,
);

const blogListPaginated = paginateBlogPosts({
Expand All @@ -518,7 +640,9 @@ async function blogPluginExtended(...pluginArgs) {
function getTagsPropPath() {
if (!tagsPropPathPromise) {
tagsPropPathPromise = createData(
`${utils.docuHash(`${blogTagsListPath}${TAGS_DATA_SUFFIX}`)}${JSON_FILE_EXTENSION}`,
`${utils.docuHash(
`${blogTagsListPath}${TAGS_DATA_SUFFIX}`,
)}${JSON_FILE_EXTENSION}`,
JSON.stringify(tagsProp, null, 2),
);
}
Expand All @@ -529,7 +653,9 @@ async function blogPluginExtended(...pluginArgs) {
function getCategoriesPropPath() {
if (!categoriesPropPathPromise) {
categoriesPropPathPromise = createData(
`${utils.docuHash(`${blogCategoriesListPath}${CATEGORIES_DATA_SUFFIX}`)}${JSON_FILE_EXTENSION}`,
`${utils.docuHash(
`${blogCategoriesListPath}${CATEGORIES_DATA_SUFFIX}`,
)}${JSON_FILE_EXTENSION}`,
JSON.stringify(categoriesProp, null, 2),
);
}
Expand Down Expand Up @@ -669,12 +795,16 @@ async function blogPluginExtended(...pluginArgs) {
};

const tagPropPath = await createData(
`${utils.docuHash(`${metadata.permalink}${ALL_TAGS_DATA_SUFFIX}`)}${JSON_FILE_EXTENSION}`,
`${utils.docuHash(
`${metadata.permalink}${ALL_TAGS_DATA_SUFFIX}`,
)}${JSON_FILE_EXTENSION}`,
JSON.stringify(tagProp, null, 2),
);

const listMetadataPath = await createData(
`${utils.docuHash(`${metadata.permalink}${ALL_TAGS_DATA_SUFFIX}`)}${LIST_DATA_SUFFIX}${JSON_FILE_EXTENSION}`,
`${utils.docuHash(
`${metadata.permalink}${ALL_TAGS_DATA_SUFFIX}`,
)}${LIST_DATA_SUFFIX}${JSON_FILE_EXTENSION}`,
JSON.stringify(metadata, null, 2),
);

Expand Down Expand Up @@ -709,7 +839,9 @@ async function blogPluginExtended(...pluginArgs) {
);

const listMetadataPath = await createData(
`${utils.docuHash(metadata.permalink)}${LIST_DATA_SUFFIX}${JSON_FILE_EXTENSION}`,
`${utils.docuHash(
metadata.permalink,
)}${LIST_DATA_SUFFIX}${JSON_FILE_EXTENSION}`,
JSON.stringify(metadata, null, 2),
);

Expand Down Expand Up @@ -757,12 +889,16 @@ async function blogPluginExtended(...pluginArgs) {
seoDescription: category.seo?.description,
};
const categoryPropPath = await createData(
`${utils.docuHash(`${metadata.permalink}${CATEGORY_DATA_SUFFIX}`)}${JSON_FILE_EXTENSION}`,
`${utils.docuHash(
`${metadata.permalink}${CATEGORY_DATA_SUFFIX}`,
)}${JSON_FILE_EXTENSION}`,
JSON.stringify(categoryProp, null, 2),
);

const listMetadataPath = await createData(
`${utils.docuHash(`${metadata.permalink}${CATEGORY_DATA_SUFFIX}`)}${LIST_DATA_SUFFIX}${JSON_FILE_EXTENSION}`,
`${utils.docuHash(
`${metadata.permalink}${CATEGORY_DATA_SUFFIX}`,
)}${LIST_DATA_SUFFIX}${JSON_FILE_EXTENSION}`,
JSON.stringify(metadata, null, 2),
);

Expand Down Expand Up @@ -796,6 +932,27 @@ async function blogPluginExtended(...pluginArgs) {
);
}
},

/**
* Runs after build completes. All plugin postBuild hooks execute
* concurrently via Promise.all, so we wait for the sitemap plugin
* to finish writing sitemap.xml before patching it.
*/
async postBuild({ outDir }) {
if (Object.keys(blogLastmodMap).length === 0) return;

const sitemapPath = path.join(outDir, "sitemap.xml");
const sitemapReady = await waitForFile(sitemapPath);

if (!sitemapReady) {
console.warn(
"[blog-plugin] sitemap.xml not found after timeout, skipping <lastmod> injection.",
);
return;
}

injectSitemapLastmod(sitemapPath);
},
};
}

Expand Down
22 changes: 22 additions & 0 deletions documentation/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 9 additions & 2 deletions documentation/src/components/banner/banner-blog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import clsx from "clsx";
const desktopImage =
"https://refine.ams3.cdn.digitaloceanspaces.com/blog-banners/blog-wide-banner.webp";
const mobileImage =
"https://refine.ams3.cdn.digitaloceanspaces.com/blog-banners/blog-wide-banner-mobile.webp";
"https://refine.ams3.cdn.digitaloceanspaces.com/blog-banners/blog-wide-banner-mobile2.webp";

const text = "Refine";
const description =
Expand All @@ -17,7 +17,14 @@ export const BannerBlog = () => {
to={"https://refine.dev/?ref=refine-banner"}
target="_blank"
rel="noopener"
className={clsx("flex", "w-full", "rounded-md", "overflow-hidden")}
className={clsx(
"flex",
"w-full",
"rounded-md",
"overflow-hidden",
"aspect-320/370",
"refine-md:aspect-720/196",
)}
title={text}
aria-label={description}
>
Expand Down
Loading