feat(blog): add file-based blog with dynamic slugs, MDX content and layout shell

- Introduced blog routing using Next.js App Router
- Implemented dynamic [slug] pages for blog posts
- Added MDX-based content loading via lib/posts
- Integrated shared TopBar layout with navigation
- Established clear content, lib and component separation
This commit is contained in:
PascalSchattenburg
2026-01-22 14:14:15 +01:00
parent b717952234
commit d147843c76
10412 changed files with 2475583 additions and 0 deletions

View File

@@ -0,0 +1,187 @@
import { Batcher } from '../../lib/batcher';
import { scheduleOnNextTick } from '../../lib/scheduler';
import { fromResponseCacheEntry, routeKindToIncrementalCacheKind, toResponseCacheEntry } from './utils';
export * from './types';
export default class ResponseCache {
constructor(minimal_mode){
this.getBatcher = Batcher.create({
// Ensure on-demand revalidate doesn't block normal requests, it should be
// safe to run an on-demand revalidate for the same key as a normal request.
cacheKeyFn: ({ key, isOnDemandRevalidate })=>`${key}-${isOnDemandRevalidate ? '1' : '0'}`,
// We wait to do any async work until after we've added our promise to
// `pendingResponses` to ensure that any any other calls will reuse the
// same promise until we've fully finished our work.
schedulerFn: scheduleOnNextTick
});
this.revalidateBatcher = Batcher.create({
// We wait to do any async work until after we've added our promise to
// `pendingResponses` to ensure that any any other calls will reuse the
// same promise until we've fully finished our work.
schedulerFn: scheduleOnNextTick
});
this.minimal_mode = minimal_mode;
}
/**
* Gets the response cache entry for the given key.
*
* @param key - The key to get the response cache entry for.
* @param responseGenerator - The response generator to use to generate the response cache entry.
* @param context - The context for the get request.
* @returns The response cache entry.
*/ async get(key, responseGenerator, context) {
var _this_previousCacheItem;
// If there is no key for the cache, we can't possibly look this up in the
// cache so just return the result of the response generator.
if (!key) {
return responseGenerator({
hasResolved: false,
previousCacheEntry: null
});
}
// Check minimal mode cache before doing any other work
if (this.minimal_mode && ((_this_previousCacheItem = this.previousCacheItem) == null ? void 0 : _this_previousCacheItem.key) === key && this.previousCacheItem.expiresAt > Date.now()) {
return toResponseCacheEntry(this.previousCacheItem.entry);
}
const { incrementalCache, isOnDemandRevalidate = false, isFallback = false, isRoutePPREnabled = false, isPrefetch = false, waitUntil, routeKind } = context;
const response = await this.getBatcher.batch({
key,
isOnDemandRevalidate
}, ({ resolve })=>{
const promise = this.handleGet(key, responseGenerator, {
incrementalCache,
isOnDemandRevalidate,
isFallback,
isRoutePPREnabled,
isPrefetch,
routeKind
}, resolve);
// We need to ensure background revalidates are passed to waitUntil.
if (waitUntil) waitUntil(promise);
return promise;
});
return toResponseCacheEntry(response);
}
/**
* Handles the get request for the response cache.
*
* @param key - The key to get the response cache entry for.
* @param responseGenerator - The response generator to use to generate the response cache entry.
* @param context - The context for the get request.
* @param resolve - The resolve function to use to resolve the response cache entry.
* @returns The response cache entry.
*/ async handleGet(key, responseGenerator, context, resolve) {
let previousIncrementalCacheEntry = null;
let resolved = false;
try {
// Get the previous cache entry if not in minimal mode
previousIncrementalCacheEntry = !this.minimal_mode ? await context.incrementalCache.get(key, {
kind: routeKindToIncrementalCacheKind(context.routeKind),
isRoutePPREnabled: context.isRoutePPREnabled,
isFallback: context.isFallback
}) : null;
if (previousIncrementalCacheEntry && !context.isOnDemandRevalidate) {
resolve(previousIncrementalCacheEntry);
resolved = true;
if (!previousIncrementalCacheEntry.isStale || context.isPrefetch) {
// The cached value is still valid, so we don't need to update it yet.
return previousIncrementalCacheEntry;
}
}
// Revalidate the cache entry
const incrementalResponseCacheEntry = await this.revalidate(key, context.incrementalCache, context.isRoutePPREnabled, context.isFallback, responseGenerator, previousIncrementalCacheEntry, previousIncrementalCacheEntry !== null && !context.isOnDemandRevalidate);
// Handle null response
if (!incrementalResponseCacheEntry) {
// Unset the previous cache item if it was set so we don't use it again.
if (this.minimal_mode) this.previousCacheItem = undefined;
return null;
}
// Resolve for on-demand revalidation or if not already resolved
if (context.isOnDemandRevalidate && !resolved) {
return incrementalResponseCacheEntry;
}
return incrementalResponseCacheEntry;
} catch (err) {
// If we've already resolved the cache entry, we can't reject as we
// already resolved the cache entry so log the error here.
if (resolved) {
console.error(err);
return null;
}
throw err;
}
}
/**
* Revalidates the cache entry for the given key.
*
* @param key - The key to revalidate the cache entry for.
* @param incrementalCache - The incremental cache to use to revalidate the cache entry.
* @param isRoutePPREnabled - Whether the route is PPR enabled.
* @param isFallback - Whether the route is a fallback.
* @param responseGenerator - The response generator to use to generate the response cache entry.
* @param previousIncrementalCacheEntry - The previous cache entry to use to revalidate the cache entry.
* @param hasResolved - Whether the response has been resolved.
* @returns The revalidated cache entry.
*/ async revalidate(key, incrementalCache, isRoutePPREnabled, isFallback, responseGenerator, previousIncrementalCacheEntry, hasResolved, waitUntil) {
return this.revalidateBatcher.batch(key, ()=>{
const promise = this.handleRevalidate(key, incrementalCache, isRoutePPREnabled, isFallback, responseGenerator, previousIncrementalCacheEntry, hasResolved);
// We need to ensure background revalidates are passed to waitUntil.
if (waitUntil) waitUntil(promise);
return promise;
});
}
async handleRevalidate(key, incrementalCache, isRoutePPREnabled, isFallback, responseGenerator, previousIncrementalCacheEntry, hasResolved) {
try {
// Generate the response cache entry using the response generator.
const responseCacheEntry = await responseGenerator({
hasResolved,
previousCacheEntry: previousIncrementalCacheEntry,
isRevalidating: true
});
if (!responseCacheEntry) {
return null;
}
// Convert the response cache entry to an incremental response cache entry.
const incrementalResponseCacheEntry = await fromResponseCacheEntry({
...responseCacheEntry,
isMiss: !previousIncrementalCacheEntry
});
// We want to persist the result only if it has a cache control value
// defined.
if (incrementalResponseCacheEntry.cacheControl) {
if (this.minimal_mode) {
this.previousCacheItem = {
key,
entry: incrementalResponseCacheEntry,
expiresAt: Date.now() + 1000
};
} else {
await incrementalCache.set(key, incrementalResponseCacheEntry.value, {
cacheControl: incrementalResponseCacheEntry.cacheControl,
isRoutePPREnabled,
isFallback
});
}
}
return incrementalResponseCacheEntry;
} catch (err) {
// When a path is erroring we automatically re-set the existing cache
// with new revalidate and expire times to prevent non-stop retrying.
if (previousIncrementalCacheEntry == null ? void 0 : previousIncrementalCacheEntry.cacheControl) {
const revalidate = Math.min(Math.max(previousIncrementalCacheEntry.cacheControl.revalidate || 3, 3), 30);
const expire = previousIncrementalCacheEntry.cacheControl.expire === undefined ? undefined : Math.max(revalidate + 3, previousIncrementalCacheEntry.cacheControl.expire);
await incrementalCache.set(key, previousIncrementalCacheEntry.value, {
cacheControl: {
revalidate: revalidate,
expire: expire
},
isRoutePPREnabled,
isFallback
});
}
// We haven't resolved yet, so let's throw to indicate an error.
throw err;
}
}
}
//# sourceMappingURL=index.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,19 @@
export var CachedRouteKind = /*#__PURE__*/ function(CachedRouteKind) {
CachedRouteKind["APP_PAGE"] = "APP_PAGE";
CachedRouteKind["APP_ROUTE"] = "APP_ROUTE";
CachedRouteKind["PAGES"] = "PAGES";
CachedRouteKind["FETCH"] = "FETCH";
CachedRouteKind["REDIRECT"] = "REDIRECT";
CachedRouteKind["IMAGE"] = "IMAGE";
return CachedRouteKind;
}({});
export var IncrementalCacheKind = /*#__PURE__*/ function(IncrementalCacheKind) {
IncrementalCacheKind["APP_PAGE"] = "APP_PAGE";
IncrementalCacheKind["APP_ROUTE"] = "APP_ROUTE";
IncrementalCacheKind["PAGES"] = "PAGES";
IncrementalCacheKind["FETCH"] = "FETCH";
IncrementalCacheKind["IMAGE"] = "IMAGE";
return IncrementalCacheKind;
}({});
//# sourceMappingURL=types.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,72 @@
import { CachedRouteKind, IncrementalCacheKind } from './types';
import RenderResult from '../render-result';
import { RouteKind } from '../route-kind';
import { HTML_CONTENT_TYPE_HEADER } from '../../lib/constants';
export async function fromResponseCacheEntry(cacheEntry) {
var _cacheEntry_value, _cacheEntry_value1;
return {
...cacheEntry,
value: ((_cacheEntry_value = cacheEntry.value) == null ? void 0 : _cacheEntry_value.kind) === CachedRouteKind.PAGES ? {
kind: CachedRouteKind.PAGES,
html: await cacheEntry.value.html.toUnchunkedString(true),
pageData: cacheEntry.value.pageData,
headers: cacheEntry.value.headers,
status: cacheEntry.value.status
} : ((_cacheEntry_value1 = cacheEntry.value) == null ? void 0 : _cacheEntry_value1.kind) === CachedRouteKind.APP_PAGE ? {
kind: CachedRouteKind.APP_PAGE,
html: await cacheEntry.value.html.toUnchunkedString(true),
postponed: cacheEntry.value.postponed,
rscData: cacheEntry.value.rscData,
headers: cacheEntry.value.headers,
status: cacheEntry.value.status,
segmentData: cacheEntry.value.segmentData
} : cacheEntry.value
};
}
export async function toResponseCacheEntry(response) {
var _response_value, _response_value1;
if (!response) return null;
return {
isMiss: response.isMiss,
isStale: response.isStale,
cacheControl: response.cacheControl,
value: ((_response_value = response.value) == null ? void 0 : _response_value.kind) === CachedRouteKind.PAGES ? {
kind: CachedRouteKind.PAGES,
html: RenderResult.fromStatic(response.value.html, HTML_CONTENT_TYPE_HEADER),
pageData: response.value.pageData,
headers: response.value.headers,
status: response.value.status
} : ((_response_value1 = response.value) == null ? void 0 : _response_value1.kind) === CachedRouteKind.APP_PAGE ? {
kind: CachedRouteKind.APP_PAGE,
html: RenderResult.fromStatic(response.value.html, HTML_CONTENT_TYPE_HEADER),
rscData: response.value.rscData,
headers: response.value.headers,
status: response.value.status,
postponed: response.value.postponed,
segmentData: response.value.segmentData
} : response.value
};
}
export function routeKindToIncrementalCacheKind(routeKind) {
switch(routeKind){
case RouteKind.PAGES:
return IncrementalCacheKind.PAGES;
case RouteKind.APP_PAGE:
return IncrementalCacheKind.APP_PAGE;
case RouteKind.IMAGE:
return IncrementalCacheKind.IMAGE;
case RouteKind.APP_ROUTE:
return IncrementalCacheKind.APP_ROUTE;
case RouteKind.PAGES_API:
// Pages Router API routes are not cached in the incremental cache.
throw Object.defineProperty(new Error(`Unexpected route kind ${routeKind}`), "__NEXT_ERROR_CODE", {
value: "E64",
enumerable: false,
configurable: true
});
default:
return routeKind;
}
}
//# sourceMappingURL=utils.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,91 @@
import { DetachedPromise } from '../../lib/detached-promise';
/**
* In the web server, there is currently no incremental cache provided and we
* always SSR the page.
*/ export default class WebResponseCache {
constructor(minimalMode){
this.pendingResponses = new Map();
// this is a hack to avoid Webpack knowing this is equal to this.minimalMode
// because we replace this.minimalMode to true in production bundles.
Object.assign(this, {
minimalMode
});
}
get(key, responseGenerator, context) {
var _this_previousCacheItem;
// ensure on-demand revalidate doesn't block normal requests
const pendingResponseKey = key ? `${key}-${context.isOnDemandRevalidate ? '1' : '0'}` : null;
const pendingResponse = pendingResponseKey ? this.pendingResponses.get(pendingResponseKey) : null;
if (pendingResponse) {
return pendingResponse;
}
const { promise, resolve: resolver, reject: rejecter } = new DetachedPromise();
if (pendingResponseKey) {
this.pendingResponses.set(pendingResponseKey, promise);
}
let hasResolved = false;
const resolve = (cacheEntry)=>{
if (pendingResponseKey) {
// Ensure all reads from the cache get the latest value.
this.pendingResponses.set(pendingResponseKey, Promise.resolve(cacheEntry));
}
if (!hasResolved) {
hasResolved = true;
resolver(cacheEntry);
}
};
// we keep the previous cache entry around to leverage
// when the incremental cache is disabled in minimal mode
if (pendingResponseKey && this.minimalMode && ((_this_previousCacheItem = this.previousCacheItem) == null ? void 0 : _this_previousCacheItem.key) === pendingResponseKey && this.previousCacheItem.expiresAt > Date.now()) {
resolve(this.previousCacheItem.entry);
this.pendingResponses.delete(pendingResponseKey);
return promise;
}
// We wait to do any async work until after we've added our promise to
// `pendingResponses` to ensure that any any other calls will reuse the
// same promise until we've fully finished our work.
;
(async ()=>{
try {
const cacheEntry = await responseGenerator({
hasResolved
});
const resolveValue = cacheEntry === null ? null : {
...cacheEntry,
isMiss: true
};
// for on-demand revalidate wait to resolve until cache is set
if (!context.isOnDemandRevalidate) {
resolve(resolveValue);
}
if (key && cacheEntry && cacheEntry.cacheControl) {
this.previousCacheItem = {
key: pendingResponseKey || key,
entry: cacheEntry,
expiresAt: Date.now() + 1000
};
} else {
this.previousCacheItem = undefined;
}
if (context.isOnDemandRevalidate) {
resolve(resolveValue);
}
} catch (err) {
// while revalidating in the background we can't reject as
// we already resolved the cache entry so log the error here
if (hasResolved) {
console.error(err);
} else {
rejecter(err);
}
} finally{
if (pendingResponseKey) {
this.pendingResponses.delete(pendingResponseKey);
}
}
})();
return promise;
}
}
//# sourceMappingURL=web.js.map

File diff suppressed because one or more lines are too long