App reorganization
This commit is contained in:
-266
@@ -1,266 +0,0 @@
|
||||
/**
|
||||
* Content caching utilities for improved performance
|
||||
*/
|
||||
|
||||
import { logger } from "./logger";
|
||||
|
||||
// In-memory cache for blog posts
|
||||
const blogPostCache = new Map<string, CacheEntry<unknown>>();
|
||||
const blogListCache = new Map<string, CacheEntry<unknown[]>>();
|
||||
const tagCache = new Map<string, CacheEntry<string[]>>();
|
||||
const authorCache = new Map<string, CacheEntry<string[]>>();
|
||||
|
||||
// Cache configuration
|
||||
const isDevelopment =
|
||||
process.env.NODE_ENV === "development" || !process.env.NODE_ENV;
|
||||
const CACHE_TTL = isDevelopment ? 0 : 5 * 60 * 1000; // 0 in dev, 5 minutes in production
|
||||
const MAX_CACHE_SIZE = 100; // Maximum number of cached items
|
||||
|
||||
/**
|
||||
* Cache entry with timestamp
|
||||
*/
|
||||
class CacheEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
|
||||
constructor(data: T) {
|
||||
this.data = data;
|
||||
this.timestamp = Date.now();
|
||||
}
|
||||
|
||||
isExpired(): boolean {
|
||||
// In development, always consider cache expired (no caching)
|
||||
if (isDevelopment) return true;
|
||||
return Date.now() - this.timestamp > CACHE_TTL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached blog post data
|
||||
* @param key - Cache key
|
||||
* @returns Cached data or null if not found/expired
|
||||
*/
|
||||
function getCached<T>(key: string): T | null {
|
||||
const entry = blogPostCache.get(key) as CacheEntry<T> | undefined;
|
||||
if (!entry || entry.isExpired()) {
|
||||
blogPostCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cached blog post data
|
||||
* @param key - Cache key
|
||||
* @param data - Data to cache
|
||||
*/
|
||||
function setCached<T>(key: string, data: T): void {
|
||||
// Implement LRU eviction if cache is full
|
||||
if (blogPostCache.size >= MAX_CACHE_SIZE) {
|
||||
const oldestKey = blogPostCache.keys().next().value;
|
||||
blogPostCache.delete(oldestKey);
|
||||
}
|
||||
|
||||
blogPostCache.set(key, new CacheEntry(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear expired cache entries
|
||||
*/
|
||||
function clearExpiredCache(): void {
|
||||
for (const [key, entry] of blogPostCache.entries()) {
|
||||
if (entry.isExpired()) {
|
||||
blogPostCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all caches
|
||||
*/
|
||||
export function clearAllCaches(): void {
|
||||
blogPostCache.clear();
|
||||
blogListCache.clear();
|
||||
tagCache.clear();
|
||||
authorCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached blog post by slug
|
||||
* @param slug - Blog post slug
|
||||
* @returns Cached blog post or null
|
||||
*/
|
||||
export function getCachedBlogPost<T>(slug: string): T | null {
|
||||
return getCached<T>(`post:${slug}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache blog post data
|
||||
* @param slug - Blog post slug
|
||||
* @param postData - Blog post data
|
||||
*/
|
||||
export function cacheBlogPost<T>(slug: string, postData: T): void {
|
||||
setCached(`post:${slug}`, postData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached blog post list
|
||||
* @param key - Cache key for list (e.g., 'all', 'recent', 'tag:governance')
|
||||
* @returns Cached list or null
|
||||
*/
|
||||
export function getCachedBlogList<T>(key: string): T[] | null {
|
||||
const entry = blogListCache.get(key);
|
||||
if (!entry || entry.isExpired()) {
|
||||
blogListCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache blog post list
|
||||
* @param key - Cache key
|
||||
* @param listData - List data to cache
|
||||
*/
|
||||
export function cacheBlogList<T>(key: string, listData: T[]): void {
|
||||
blogListCache.set(key, new CacheEntry(listData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached tags
|
||||
* @returns Cached tags or null
|
||||
*/
|
||||
export function getCachedTags(): string[] | null {
|
||||
const entry = tagCache.get("all");
|
||||
if (!entry || entry.isExpired()) {
|
||||
tagCache.delete("all");
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache tags
|
||||
* @param tags - Tags to cache
|
||||
*/
|
||||
export function cacheTags(tags: string[]): void {
|
||||
tagCache.set("all", new CacheEntry(tags));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached authors
|
||||
* @returns Cached authors or null
|
||||
*/
|
||||
export function getCachedAuthors(): string[] | null {
|
||||
const entry = authorCache.get("all");
|
||||
if (!entry || entry.isExpired()) {
|
||||
authorCache.delete("all");
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache authors
|
||||
* @param authors - Authors to cache
|
||||
*/
|
||||
export function cacheAuthors(authors: string[]): void {
|
||||
authorCache.set("all", new CacheEntry(authors));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for a specific blog post
|
||||
* @param slug - Blog post slug
|
||||
*/
|
||||
export function invalidateBlogPostCache(slug: string): void {
|
||||
blogPostCache.delete(`post:${slug}`);
|
||||
// Also invalidate list caches since they might contain this post
|
||||
blogListCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all caches
|
||||
*/
|
||||
export function invalidateAllCaches(): void {
|
||||
clearAllCaches();
|
||||
}
|
||||
|
||||
export interface CacheStats {
|
||||
blogPostCacheSize: number;
|
||||
blogListCacheSize: number;
|
||||
tagCacheSize: number;
|
||||
authorCacheSize: number;
|
||||
totalCacheSize: number;
|
||||
maxCacheSize: number;
|
||||
cacheTTL: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
* @returns Cache statistics
|
||||
*/
|
||||
export function getCacheStats(): CacheStats {
|
||||
clearExpiredCache();
|
||||
|
||||
return {
|
||||
blogPostCacheSize: blogPostCache.size,
|
||||
blogListCacheSize: blogListCache.size,
|
||||
tagCacheSize: tagCache.size,
|
||||
authorCacheSize: authorCache.size,
|
||||
totalCacheSize:
|
||||
blogPostCache.size +
|
||||
blogListCache.size +
|
||||
tagCache.size +
|
||||
authorCache.size,
|
||||
maxCacheSize: MAX_CACHE_SIZE,
|
||||
cacheTTL: CACHE_TTL,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm up cache with frequently accessed data
|
||||
* @param getAllPosts - Function to get all blog posts
|
||||
* @param getAllTags - Function to get all tags
|
||||
*/
|
||||
export async function warmCache<T>(
|
||||
getAllPosts: () => T[],
|
||||
getAllTags: () => string[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Cache all blog posts
|
||||
const allPosts = getAllPosts();
|
||||
cacheBlogList("all", allPosts);
|
||||
|
||||
// Cache recent posts
|
||||
const recentPosts = allPosts.slice(0, 5);
|
||||
cacheBlogList("recent", recentPosts);
|
||||
|
||||
// Cache tags
|
||||
const tags = getAllTags();
|
||||
cacheTags(tags);
|
||||
|
||||
// Cache individual posts (first 10)
|
||||
allPosts.slice(0, 10).forEach((post) => {
|
||||
const postWithSlug = post as { slug: string };
|
||||
cacheBlogPost(postWithSlug.slug, post);
|
||||
});
|
||||
|
||||
logger.info("Cache warmed up successfully");
|
||||
} catch (error) {
|
||||
logger.error("Error warming up cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cache is healthy
|
||||
* @returns True if cache is healthy
|
||||
*/
|
||||
export function isCacheHealthy(): boolean {
|
||||
try {
|
||||
clearExpiredCache();
|
||||
return blogPostCache.size < MAX_CACHE_SIZE;
|
||||
} catch (error) {
|
||||
logger.error("Cache health check failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { CreateFlowState } from "../../app/create/types";
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
import { migrateLegacyCreateFlowState } from "./migrateLegacyCreateFlowState";
|
||||
|
||||
const jsonHeaders = { "Content-Type": "application/json" };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CoreValueDetailEntry, CreateFlowState } from "../../app/create/types";
|
||||
import type { CoreValueDetailEntry, CreateFlowState } from "../../app/(app)/create/types";
|
||||
import type { CommunityRuleDocumentSection } from "../../app/components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
|
||||
|
||||
function isDocumentEntry(x: unknown): x is { title: string; body: string } {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CreateFlowState } from "../../app/create/types";
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
|
||||
/** True when the client should treat a draft payload as non-empty for hydration / conflict checks. */
|
||||
export function createFlowStateHasKeys(state: CreateFlowState): boolean {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CreateFlowState } from "../../app/create/types";
|
||||
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||
|
||||
/** Legacy `currentStep` values mapped to the current `CreateFlowStep` id. */
|
||||
const LEGACY_CREATE_FLOW_STEP_RENAMES: Readonly<Record<string, string>> = {
|
||||
|
||||
@@ -26,31 +26,3 @@ export function getTranslation(messages: Messages, key: string): string {
|
||||
|
||||
return typeof value === "string" ? value : key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe helper to get nested values from messages
|
||||
* Usage: getNested(messages, "heroBanner", "title")
|
||||
*/
|
||||
export function getNested<T extends keyof Messages>(
|
||||
messages: Messages,
|
||||
namespace: T,
|
||||
key: string,
|
||||
): string {
|
||||
const namespaceObj = messages[namespace];
|
||||
if (!namespaceObj || typeof namespaceObj !== "object") {
|
||||
return key;
|
||||
}
|
||||
|
||||
const keys = key.split(".");
|
||||
let value: unknown = namespaceObj;
|
||||
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === "object" && k in value) {
|
||||
value = (value as Record<string, unknown>)[k];
|
||||
} else {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
return typeof value === "string" ? value : key;
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Type definitions for translation keys
|
||||
*
|
||||
* These types provide type safety when accessing translation keys.
|
||||
* The actual types are inferred from the JSON files in messages/en/
|
||||
*/
|
||||
|
||||
// Import the message structure to ensure type safety
|
||||
import type messages from "../../messages/en/index";
|
||||
|
||||
export type Messages = typeof messages;
|
||||
|
||||
// Helper type for nested key paths
|
||||
export type NestedKeyOf<ObjectType extends object> = {
|
||||
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
|
||||
? `${Key}` | `${Key}.${NestedKeyOf<ObjectType[Key]>}`
|
||||
: `${Key}`;
|
||||
}[keyof ObjectType & (string | number)];
|
||||
|
||||
// Type for all possible translation keys
|
||||
export type TranslationKey = NestedKeyOf<Messages>;
|
||||
-354
@@ -1,354 +0,0 @@
|
||||
/**
|
||||
* MDX processing utilities for enhanced markdown content
|
||||
*/
|
||||
|
||||
export interface Heading {
|
||||
level: number;
|
||||
text: string;
|
||||
id: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface Link {
|
||||
text: string;
|
||||
url: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface Image {
|
||||
alt: string;
|
||||
src: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface ProcessedMarkdown {
|
||||
content: string;
|
||||
htmlContent: string;
|
||||
headings: Heading[];
|
||||
links: Link[];
|
||||
images: Image[];
|
||||
}
|
||||
|
||||
export interface ProcessedFrontmatter {
|
||||
publishedDate: Date;
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
isRecent: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date consistently across the markdown pipeline
|
||||
* Uses "Month Year" format (e.g., "April 2025")
|
||||
*/
|
||||
export function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process markdown content and extract metadata
|
||||
* @param markdown - Raw markdown content
|
||||
* @returns Processed content with metadata
|
||||
*/
|
||||
export function processMarkdown(markdown: string): ProcessedMarkdown {
|
||||
if (!markdown) {
|
||||
return {
|
||||
content: "",
|
||||
htmlContent: "",
|
||||
headings: [],
|
||||
links: [],
|
||||
images: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Extract headings for table of contents
|
||||
const headings = extractHeadings(markdown);
|
||||
|
||||
// Extract links
|
||||
const links = extractLinks(markdown);
|
||||
|
||||
// Extract images
|
||||
const images = extractImages(markdown);
|
||||
|
||||
// Convert markdown to HTML
|
||||
const htmlContent = markdownToHtml(markdown);
|
||||
|
||||
return {
|
||||
content: markdown,
|
||||
htmlContent,
|
||||
headings,
|
||||
links,
|
||||
images,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all headings from markdown content
|
||||
* @param markdown - Raw markdown content
|
||||
* @returns Array of heading objects with level, text, and id
|
||||
*/
|
||||
function extractHeadings(markdown: string): Heading[] {
|
||||
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
||||
const headings: Heading[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = headingRegex.exec(markdown)) !== null) {
|
||||
const level = match[1].length;
|
||||
const text = match[2].trim();
|
||||
const id = generateHeadingId(text);
|
||||
|
||||
headings.push({
|
||||
level,
|
||||
text,
|
||||
id,
|
||||
line: markdown.substring(0, match.index).split("\n").length,
|
||||
});
|
||||
}
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all links from markdown content
|
||||
* @param markdown - Raw markdown content
|
||||
* @returns Array of link objects
|
||||
*/
|
||||
function extractLinks(markdown: string): Link[] {
|
||||
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
const links: Link[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = linkRegex.exec(markdown)) !== null) {
|
||||
links.push({
|
||||
text: match[1],
|
||||
url: match[2],
|
||||
index: match.index,
|
||||
});
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all images from markdown content
|
||||
* @param markdown - Raw markdown content
|
||||
* @returns Array of image objects
|
||||
*/
|
||||
function extractImages(markdown: string): Image[] {
|
||||
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
const images: Image[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = imageRegex.exec(markdown)) !== null) {
|
||||
images.push({
|
||||
alt: match[1],
|
||||
src: match[2],
|
||||
index: match.index,
|
||||
});
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for a heading
|
||||
* @param text - Heading text
|
||||
* @returns Unique ID
|
||||
*/
|
||||
function generateHeadingId(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert markdown to HTML with enhanced formatting
|
||||
* - Preserves extra blank lines between paragraphs as visible gaps
|
||||
* (each extra blank line becomes <p class="md-gap"> </p>)
|
||||
* @param markdown - Raw markdown content
|
||||
* @returns HTML content
|
||||
*/
|
||||
function markdownToHtml(markdown: string): string {
|
||||
if (!markdown) return "";
|
||||
|
||||
// Normalize line endings
|
||||
const src = String(markdown).replace(/\r\n?/g, "\n");
|
||||
|
||||
// For 3+ consecutive newlines, keep 2 for the paragraph break and
|
||||
// emit a counted gap token for additional blank lines to preserve spacing.
|
||||
const withGaps = src.replace(/\n{3,}/g, (m) => {
|
||||
const extra = m.length - 2;
|
||||
return `\n\n<GAP:${extra}/>`;
|
||||
});
|
||||
|
||||
return (
|
||||
withGaps
|
||||
// Headers with IDs
|
||||
.replace(
|
||||
/^###### (.*$)/gim,
|
||||
(_match, t) => `<h6 id="${generateHeadingId(t)}">${t}</h6>`,
|
||||
)
|
||||
.replace(
|
||||
/^##### (.*$)/gim,
|
||||
(_match, t) => `<h5 id="${generateHeadingId(t)}">${t}</h5>`,
|
||||
)
|
||||
.replace(
|
||||
/^#### (.*$)/gim,
|
||||
(_match, t) => `<h4 id="${generateHeadingId(t)}">${t}</h4>`,
|
||||
)
|
||||
.replace(
|
||||
/^### (.*$)/gim,
|
||||
(_match, t) => `<h3 id="${generateHeadingId(t)}">${t}</h3>`,
|
||||
)
|
||||
.replace(
|
||||
/^## (.*$)/gim,
|
||||
(_match, t) => `<h2 id="${generateHeadingId(t)}">${t}</h2>`,
|
||||
)
|
||||
.replace(
|
||||
/^# (.*$)/gim,
|
||||
(_match, t) => `<h1 id="${generateHeadingId(t)}">${t}</h1>`,
|
||||
)
|
||||
|
||||
// Code fences (block) and inline code
|
||||
.replace(
|
||||
/```(\w+)?\n([\s\S]*?)\n```/g,
|
||||
(_match, lang = "", code) =>
|
||||
`<pre><code class="language-${lang}">${code}</code></pre>`,
|
||||
)
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
|
||||
// Bold and italic (strong before em to avoid overlap issues)
|
||||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\*(.+?)\*/g, "<em>$1</em>")
|
||||
|
||||
// Links and images
|
||||
.replace(
|
||||
/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/g,
|
||||
(_match, alt, src, title = "") =>
|
||||
`<img alt="${alt}" src="${src}"${title ? ` title="${title}"` : ""}>`,
|
||||
)
|
||||
.replace(
|
||||
/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/g,
|
||||
(_match, text, href, title = "") =>
|
||||
`<a href="${href}"${title ? ` title="${title}"` : ""}>${text}</a>`,
|
||||
)
|
||||
|
||||
// Blockquotes
|
||||
.replace(/^(>\s?.+)(\n(>\s?.+))*$/gim, (m) => {
|
||||
const inner = m.replace(/^>\s?/gm, "");
|
||||
return `<blockquote><p>${inner.replace(
|
||||
/\n{2,}/g,
|
||||
"</p><p>",
|
||||
)}</p></blockquote>`;
|
||||
})
|
||||
|
||||
// Lists (ul/ol)
|
||||
.replace(/^(\s*[-*]\s.+(?:\n\s*[-*]\s.+)*)/gim, (m) => {
|
||||
const items = m
|
||||
.trim()
|
||||
.split(/\n/)
|
||||
.map((l) => l.replace(/^\s*[-*]\s+/, ""))
|
||||
.map((t) => `<li>${t}</li>`)
|
||||
.join("");
|
||||
return `<ul>${items}</ul>`;
|
||||
})
|
||||
.replace(/^(\s*\d+\.\s.+(?:\n\s*\d+\.\s.+)*)/gim, (m) => {
|
||||
const items = m
|
||||
.trim()
|
||||
.split(/\n/)
|
||||
.map((l) => l.replace(/^\s*\d+\.\s+/, ""))
|
||||
.map((t) => `<li>${t}</li>`)
|
||||
.join("");
|
||||
return `<ol>${items}</ol>`;
|
||||
})
|
||||
|
||||
// Horizontal rules
|
||||
.replace(/^\s*(?:-{3,}|\*{3,})\s*$/gm, "<hr>")
|
||||
|
||||
// Paragraphs:
|
||||
// 1) Convert double newlines to paragraph boundaries
|
||||
.replace(/\n\n/g, "</p><p>")
|
||||
// 2) Convert single line breaks to <br> tags within paragraphs
|
||||
.replace(/(?<!\n)\n(?!\n)/g, "<br>")
|
||||
// 3) Wrap remaining bare lines that are not already block-level elements.
|
||||
// (Also skip our GAP_TOKEN so we can turn it into gap paragraphs later.)
|
||||
.replace(
|
||||
/^(?!\s*<(h[1-6]|ul|ol|li|blockquote|hr|pre|code|table|img)\b)(?!\s*<\/)(?!\s*<GAP\/>)(.+)$/gim,
|
||||
"<p>$2</p>",
|
||||
)
|
||||
|
||||
// Clean up truly empty paragraphs but keep gap paragraphs
|
||||
.replace(/<p>\s*<\/p>/g, "")
|
||||
|
||||
// Turn counted GAP tokens into explicit, styleable gap elements
|
||||
.replace(
|
||||
/<GAP:(\d+)\/>/g,
|
||||
(_match, n) =>
|
||||
`<div class="md-gap" style="--gap:${Number(
|
||||
n,
|
||||
)}" aria-hidden="true"></div>`,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a table of contents from headings
|
||||
* @param headings - Array of heading objects
|
||||
* @returns HTML table of contents
|
||||
*/
|
||||
export function generateTableOfContents(headings: Heading[]): string {
|
||||
if (!headings || headings.length === 0) return "";
|
||||
|
||||
let toc = '<nav class="table-of-contents"><h4>Table of Contents</h4><ul>';
|
||||
|
||||
headings.forEach((heading) => {
|
||||
const indent = (heading.level - 1) * 20;
|
||||
toc += `<li style="margin-left: ${indent}px"><a href="#${heading.id}">${heading.text}</a></li>`;
|
||||
});
|
||||
|
||||
toc += "</ul></nav>";
|
||||
return toc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process frontmatter with enhanced validation
|
||||
* @param frontmatter - Raw frontmatter data
|
||||
* @returns Processed and validated frontmatter
|
||||
*/
|
||||
export function processFrontmatter(
|
||||
frontmatter: Record<string, unknown>,
|
||||
): ProcessedFrontmatter {
|
||||
// Add computed fields
|
||||
const date = frontmatter.date as string;
|
||||
const processed: ProcessedFrontmatter = {
|
||||
...frontmatter,
|
||||
publishedDate: new Date(date),
|
||||
year: new Date(date).getFullYear(),
|
||||
month: new Date(date).getMonth() + 1,
|
||||
day: new Date(date).getDate(),
|
||||
isRecent: isRecentPost(date),
|
||||
};
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a post is recent (within last 30 days)
|
||||
* @param date - Post date string
|
||||
* @returns True if post is recent
|
||||
*/
|
||||
function isRecentPost(date: string): boolean {
|
||||
const postDate = new Date(date);
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
return postDate > thirtyDaysAgo;
|
||||
}
|
||||
+203
-830
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { FLOW_STEP_ORDER } from "../../../app/create/utils/flowSteps";
|
||||
import { FLOW_STEP_ORDER } from "../../../app/(app)/create/utils/flowSteps";
|
||||
import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson";
|
||||
|
||||
const flowStepTuple = FLOW_STEP_ORDER as unknown as [string, ...string[]];
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Shared type definitions for the CommunityRule application
|
||||
*/
|
||||
|
||||
// Re-export types from other modules for convenience
|
||||
export type { BlogPost, BlogStats } from "./content";
|
||||
|
||||
export type { BlogPostFrontmatter, ValidationResult } from "./validation";
|
||||
|
||||
export type {
|
||||
Heading,
|
||||
Link,
|
||||
Image,
|
||||
ProcessedMarkdown,
|
||||
ProcessedFrontmatter,
|
||||
} from "./mdx";
|
||||
|
||||
export type { CacheStats } from "./cache";
|
||||
|
||||
// Additional shared types
|
||||
export interface ComponentProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface PageProps {
|
||||
params?: Record<string, string | string[]>;
|
||||
searchParams?: Record<string, string | string[]>;
|
||||
}
|
||||
Reference in New Issue
Block a user