mirror of
https://github.com/privacy-scaling-explorations/pse.dev.git
synced 2026-01-13 16:18:07 -05:00
464 lines
14 KiB
TypeScript
464 lines
14 KiB
TypeScript
import {
|
|
Article,
|
|
FetchMarkdownOptions,
|
|
MarkdownContent,
|
|
ProjectInterface,
|
|
} from "./types"
|
|
|
|
// Static content cache for deployment environments where filesystem access is limited
|
|
const staticContentCache: { [key: string]: MarkdownContent[] } = {}
|
|
|
|
// Production-ready dynamic imports for server-side only
|
|
async function getServerModules() {
|
|
// Only import on server-side to avoid bundling in client
|
|
if (typeof window !== "undefined") {
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const [{ default: fs }, { default: path }, { default: matter }, jsYaml] =
|
|
await Promise.all([
|
|
import("fs"),
|
|
import("path"),
|
|
import("gray-matter"),
|
|
import("js-yaml"),
|
|
])
|
|
|
|
return { fs, path, matter, jsYaml }
|
|
} catch (error) {
|
|
console.error("Failed to load server modules:", error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Function to populate static cache at build time
|
|
async function populateStaticCache(folderName: string) {
|
|
const modules = await getServerModules()
|
|
if (!modules) return []
|
|
|
|
const { fs, path } = modules
|
|
const contentDirectory = path.resolve(process.cwd(), folderName)
|
|
|
|
if (fs.existsSync(contentDirectory)) {
|
|
console.log(`📦 Populating static cache for ${folderName}`)
|
|
const content = await getMarkdownFilesFromPath(contentDirectory, modules, {
|
|
limit: 1000,
|
|
})
|
|
staticContentCache[folderName] = content
|
|
console.log(`✅ Cached ${content.length} items for ${folderName}`)
|
|
return content
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
// Simplified function to get markdown files from any folder
|
|
export async function getMarkdownFiles(
|
|
folderName: string,
|
|
options?: FetchMarkdownOptions
|
|
): Promise<MarkdownContent[]> {
|
|
// Return empty array if running on client-side
|
|
if (typeof window !== "undefined") {
|
|
console.warn(
|
|
"getMarkdownFiles called on client-side, returning empty array"
|
|
)
|
|
return []
|
|
}
|
|
|
|
// First, try to get from static cache
|
|
if (staticContentCache[folderName]) {
|
|
console.log(`📁 Using cached content for ${folderName}`)
|
|
return filterAndLimitContent(staticContentCache[folderName], options)
|
|
}
|
|
|
|
const modules = await getServerModules()
|
|
if (!modules) {
|
|
console.error("Failed to load server modules")
|
|
return []
|
|
}
|
|
|
|
const { fs, path, matter, jsYaml } = modules
|
|
const { limit = 1000, tag, project } = options ?? {}
|
|
|
|
// Enhanced path resolution for different environments
|
|
let contentDirectory = path.resolve(process.cwd(), folderName)
|
|
|
|
// Try alternative path resolution for Vercel deployment
|
|
if (!fs.existsSync(contentDirectory)) {
|
|
console.log(`First path attempt failed: ${contentDirectory}`)
|
|
|
|
// For Vercel deployment, try multiple path strategies
|
|
const altPaths = [
|
|
// Try relative to current working directory
|
|
path.join(process.cwd(), "..", folderName),
|
|
// Try relative to __dirname (built location)
|
|
path.join(__dirname, "..", "..", "..", "..", folderName),
|
|
path.join(__dirname, "..", "..", "..", folderName),
|
|
path.join(__dirname, "..", "..", folderName),
|
|
// Try from root of Next.js build
|
|
path.join(process.cwd(), ".next", "..", folderName),
|
|
// Try from source location if available
|
|
path.join("/var/task", folderName),
|
|
path.join("/tmp", folderName),
|
|
// Try alternative working directory patterns
|
|
path.join(process.cwd(), "app", "..", folderName),
|
|
path.join(process.cwd(), "src", "..", folderName),
|
|
]
|
|
|
|
console.log(`Trying ${altPaths.length} alternative paths...`)
|
|
|
|
for (let i = 0; i < altPaths.length; i++) {
|
|
const testPath = altPaths[i]
|
|
console.log(`Path ${i + 1}: ${testPath}`)
|
|
if (fs.existsSync(testPath)) {
|
|
contentDirectory = testPath
|
|
console.log(`✅ Found content directory at: ${contentDirectory}`)
|
|
break
|
|
}
|
|
}
|
|
|
|
// If still not found, try to populate cache from build time
|
|
if (!fs.existsSync(contentDirectory)) {
|
|
console.log("❌ Content directory not found, trying build-time cache...")
|
|
|
|
// Try to populate cache at runtime if possible
|
|
try {
|
|
await populateStaticCache(folderName)
|
|
if (staticContentCache[folderName]) {
|
|
return filterAndLimitContent(staticContentCache[folderName], options)
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to populate static cache:", error)
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Check if the directory exists
|
|
if (!fs.existsSync(contentDirectory)) {
|
|
console.error(`❌ Directory not found: ${contentDirectory}`)
|
|
console.error(`Current working directory: ${process.cwd()}`)
|
|
console.error(`__dirname: ${__dirname}`)
|
|
|
|
// List contents of current directory for debugging
|
|
try {
|
|
const cwdContents = fs.readdirSync(process.cwd())
|
|
console.error(`Contents of ${process.cwd()}:`, cwdContents.slice(0, 10))
|
|
|
|
// Also check parent directory
|
|
const parentDir = path.dirname(process.cwd())
|
|
if (fs.existsSync(parentDir)) {
|
|
const parentContents = fs.readdirSync(parentDir)
|
|
console.error(
|
|
`Contents of parent ${parentDir}:`,
|
|
parentContents.slice(0, 10)
|
|
)
|
|
}
|
|
} catch (e) {
|
|
console.error("Cannot read directories: " + e)
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
const files = fs.readdirSync(contentDirectory)
|
|
|
|
return getMarkdownFilesFromPath(contentDirectory, modules, {
|
|
limit,
|
|
tag,
|
|
project,
|
|
})
|
|
} catch (error) {
|
|
console.error(`❌ Error accessing directory ${folderName}:`, error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Helper function to filter and limit content
|
|
function filterAndLimitContent(
|
|
content: MarkdownContent[],
|
|
options?: FetchMarkdownOptions
|
|
): MarkdownContent[] {
|
|
const { limit = 1000, tag, project } = options ?? {}
|
|
|
|
let filteredContent = [...content]
|
|
|
|
// Filter by tag if provided
|
|
if (tag) {
|
|
filteredContent = filteredContent.filter((item) => {
|
|
// Handle array format (legacy articles)
|
|
if (Array.isArray(item.tags)) {
|
|
return item.tags.includes(tag)
|
|
}
|
|
// Handle object format (projects)
|
|
if (typeof item.tags === "object" && item.tags !== null) {
|
|
// Check all tag categories for the tag
|
|
return Object.values(item.tags).some((tagArray) =>
|
|
Array.isArray(tagArray) ? tagArray.includes(tag) : false
|
|
)
|
|
}
|
|
return false
|
|
})
|
|
}
|
|
|
|
// Filter by project if provided
|
|
if (project) {
|
|
filteredContent = filteredContent.filter(
|
|
(item) => Array.isArray(item.projects) && item.projects.includes(project)
|
|
)
|
|
}
|
|
|
|
// Sort content by date (if date exists)
|
|
return filteredContent
|
|
.sort((a, b) => {
|
|
const dateA = new Date(a.date || "1970-01-01")
|
|
const dateB = new Date(b.date || "1970-01-01")
|
|
|
|
// Sort in descending order (newest first)
|
|
return dateB.getTime() - dateA.getTime()
|
|
})
|
|
.slice(0, limit)
|
|
}
|
|
|
|
// Helper function to process files from a given path
|
|
async function getMarkdownFilesFromPath(
|
|
contentDirectory: string,
|
|
modules: any,
|
|
options: { limit: number; tag?: string; project?: string }
|
|
): Promise<MarkdownContent[]> {
|
|
const { fs, path, matter, jsYaml } = modules
|
|
const { limit, tag, project } = options
|
|
|
|
// Get file names under the specified folder
|
|
const fileNames = fs.readdirSync(contentDirectory)
|
|
const allContentData = fileNames
|
|
.filter((fileName: string) => fileName.endsWith(".md"))
|
|
.map((fileName: string) => {
|
|
const id = fileName.replace(/\.md$/, "")?.toLowerCase()
|
|
if (id === "readme" || id.startsWith("_")) {
|
|
return null
|
|
}
|
|
|
|
// Read markdown file as string
|
|
const fullPath = path.join(contentDirectory, fileName)
|
|
const fileContents = fs.readFileSync(fullPath, "utf8")
|
|
|
|
try {
|
|
// Use matter with options to handle multiline strings
|
|
const matterResult = matter(fileContents, {
|
|
engines: {
|
|
yaml: {
|
|
// Ensure multiline strings are parsed correctly
|
|
parse: (str: string) => {
|
|
try {
|
|
// Use js-yaml's safe load to parse the YAML with type assertion
|
|
return jsYaml.load(str, {
|
|
schema: jsYaml.DEFAULT_SCHEMA,
|
|
filename: fileName,
|
|
}) as object
|
|
} catch (e) {
|
|
console.error(
|
|
`Error parsing YAML frontmatter in ${fileName}:`,
|
|
e
|
|
)
|
|
console.error("YAML content preview:", str.substring(0, 200))
|
|
// Fallback to empty object if parsing fails
|
|
return {}
|
|
}
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Handle tags properly - preserve object structure for projects, array for articles
|
|
let processedTags = matterResult.data?.tags
|
|
|
|
// If tags is an array (legacy format), keep it as array
|
|
if (Array.isArray(matterResult.data?.tags)) {
|
|
processedTags = [
|
|
...matterResult.data.tags,
|
|
...(matterResult.data?.tag ? [matterResult.data.tag] : []),
|
|
]
|
|
}
|
|
// If tags is an object (new project format), preserve the object structure
|
|
else if (
|
|
typeof matterResult.data?.tags === "object" &&
|
|
matterResult.data?.tags !== null
|
|
) {
|
|
processedTags = matterResult.data.tags
|
|
}
|
|
// If no tags but there's a single tag field, create array
|
|
else if (matterResult.data?.tag) {
|
|
processedTags = [matterResult.data.tag]
|
|
}
|
|
// Default to empty array
|
|
else {
|
|
processedTags = []
|
|
}
|
|
|
|
return {
|
|
id,
|
|
...matterResult.data,
|
|
tags: processedTags,
|
|
content: matterResult.content,
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error processing ${fileName}:`, error)
|
|
// Return minimal content data if there's an error
|
|
return {
|
|
id,
|
|
title: `Error processing ${id}`,
|
|
content: "This content could not be processed due to an error.",
|
|
date: new Date().toISOString().split("T")[0],
|
|
tags: [],
|
|
}
|
|
}
|
|
})
|
|
|
|
let filteredContent = allContentData.filter(Boolean) as MarkdownContent[]
|
|
|
|
// Filter by tag if provided
|
|
if (tag) {
|
|
filteredContent = filteredContent.filter((content) => {
|
|
// Handle array format (legacy articles)
|
|
if (Array.isArray(content.tags)) {
|
|
return content.tags.includes(tag)
|
|
}
|
|
// Handle object format (projects)
|
|
if (typeof content.tags === "object" && content.tags !== null) {
|
|
// Check all tag categories for the tag
|
|
return Object.values(content.tags).some((tagArray) =>
|
|
Array.isArray(tagArray) ? tagArray.includes(tag) : false
|
|
)
|
|
}
|
|
return false
|
|
})
|
|
}
|
|
|
|
// Filter by project if provided
|
|
if (project) {
|
|
filteredContent = filteredContent.filter(
|
|
(content) =>
|
|
Array.isArray(content.projects) && content.projects.includes(project)
|
|
)
|
|
}
|
|
|
|
// Sort content by date (if date exists)
|
|
return filteredContent
|
|
.sort((a, b) => {
|
|
const dateA = new Date(a.date || "1970-01-01")
|
|
const dateB = new Date(b.date || "1970-01-01")
|
|
|
|
// Sort in descending order (newest first)
|
|
return dateB.getTime() - dateA.getTime()
|
|
})
|
|
.slice(0, limit)
|
|
}
|
|
|
|
// Generic function to get a specific markdown file by ID from a folder
|
|
export async function getMarkdownFileById(
|
|
folderName: string,
|
|
id: string
|
|
): Promise<MarkdownContent | undefined> {
|
|
// Return undefined if running on client-side
|
|
if (typeof window !== "undefined") {
|
|
console.warn(
|
|
"getMarkdownFileById called on client-side, returning undefined"
|
|
)
|
|
return undefined
|
|
}
|
|
|
|
const allContent = await getMarkdownFiles(folderName)
|
|
return allContent.find(
|
|
(content) => content.id?.toLowerCase() === id?.toLowerCase()
|
|
)
|
|
}
|
|
|
|
// Specific helper functions for common use cases
|
|
export async function getArticles(
|
|
options?: FetchMarkdownOptions
|
|
): Promise<Article[]> {
|
|
const content = await getMarkdownFiles("content/articles", options)
|
|
return content.map((item) => ({
|
|
...item,
|
|
authors: item.authors || [],
|
|
image: item.image || "",
|
|
tldr: item.tldr || "",
|
|
})) as Article[]
|
|
}
|
|
|
|
export async function getProjects(
|
|
options?: FetchMarkdownOptions
|
|
): Promise<ProjectInterface[]> {
|
|
const content = (await getMarkdownFiles(
|
|
"content/projects",
|
|
options
|
|
)) as unknown as ProjectInterface[]
|
|
|
|
return content.map((item) => ({
|
|
...item,
|
|
name: item.name,
|
|
section: item.section || "pse",
|
|
projectStatus: item.projectStatus || "active",
|
|
tldr: item.tldr || "",
|
|
image: item.image || "",
|
|
})) as ProjectInterface[]
|
|
}
|
|
|
|
export async function getArticleById(id: string): Promise<Article | undefined> {
|
|
const content = await getMarkdownFileById("content/articles", id)
|
|
if (!content) return undefined
|
|
|
|
return {
|
|
...content,
|
|
authors: content.authors || [],
|
|
image: content.image || "",
|
|
tldr: content.tldr || "",
|
|
} as Article
|
|
}
|
|
|
|
export async function getProjectById(
|
|
id: string
|
|
): Promise<ProjectInterface | undefined> {
|
|
const content = (await getMarkdownFileById(
|
|
"content/projects",
|
|
id
|
|
)) as unknown as ProjectInterface
|
|
if (!content) return undefined
|
|
|
|
return {
|
|
...content,
|
|
name: content.name,
|
|
section: content.section || "pse",
|
|
projectStatus: content.projectStatus || "active",
|
|
tldr: content.tldr || "",
|
|
image: content.image || "",
|
|
} as ProjectInterface
|
|
}
|
|
|
|
// Generic functions that return MarkdownContent for flexibility
|
|
export async function getAllArticles(
|
|
options?: FetchMarkdownOptions
|
|
): Promise<MarkdownContent[]> {
|
|
return await getMarkdownFiles("content/articles", options)
|
|
}
|
|
|
|
export async function getAllProjects(
|
|
options?: FetchMarkdownOptions
|
|
): Promise<MarkdownContent[]> {
|
|
return await getMarkdownFiles("content/projects", options)
|
|
}
|
|
|
|
const lib = {
|
|
getMarkdownFiles,
|
|
getMarkdownFileById,
|
|
getArticles,
|
|
getProjects,
|
|
getArticleById,
|
|
getProjectById,
|
|
getAllArticles,
|
|
getAllProjects,
|
|
}
|
|
|
|
export default lib
|