Skip to content
Open
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
299 changes: 248 additions & 51 deletions api/[fileName].js
Original file line number Diff line number Diff line change
@@ -1,67 +1,264 @@
import fs from "fs";
import path from "path";
import Papa from "papaparse";

const PAPAPARSE_CONFIG = {
header: true,
dynamicTyping: true,
skipEmptyLines: true,
transformHeader: (header) => header.trim(),
transform: (value, header) => {
// Trim whitespace
if (typeof value === "string") {
value = value.trim();
}

// List your actual boolean columns here
const booleanColumns = ["anomaly", "is_anomaly", "threshold"];

// Only convert to boolean for specific columns
if (booleanColumns.includes(header)) {
const lowerValue =
typeof value === "string" ? value.toLowerCase() : String(value);
if (lowerValue === "true" || lowerValue === "1") return true;
if (lowerValue === "false" || lowerValue === "0") return false;
}

return value;
},
};

const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};

export default function handler(req, res) {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
Object.entries(CORS_HEADERS).forEach(([key, value]) => {
res.setHeader(key, value);
});

if (req.method === "OPTIONS") {
res.status(200).end();
return;
return res.status(200).end();
}

try {
const { fileName } = req.query;

if (!fileName) {
return res.status(400).json({ error: "Missing fileName parameter" });
}

const postSlug = sanitizeFileName(fileName);
const mdPath = findMarkdownFile(postSlug);

if (!mdPath) {
return res.status(404).json({ error: "Markdown file not found" });
}

const markdownContent = fs.readFileSync(mdPath, "utf-8");
const { frontmatter, content } = parseFrontmatter(markdownContent);
const { contentWithPlaceholders, charts } = extractCharts(
content,
postSlug
);

const response = {
title: frontmatter.title || null,
author_name: frontmatter.author_name || null,
author_image: frontmatter.author_image || null,
author_position: frontmatter.author_position || null,
publication_date: frontmatter.publication_date || null,
description: frontmatter.description || null,
image: frontmatter.image || null,
categories: frontmatter.categories || null,
tags: frontmatter.tags || null,
fileName: postSlug,
readTimeMinutes: calculateReadTime(content),
content: contentWithPlaceholders,
charts,
};

return res.json(response);
} catch (error) {
console.error("API Error:", error);
return res.status(500).json({
error: "Internal server error",
message: error.message,
});
}
}

function findMarkdownFile(postSlug) {
const possiblePaths = [
path.join(process.cwd(), "posts", `${postSlug}.md`),
path.join(process.cwd(), "..", "posts", `${postSlug}.md`),
path.join("/var/task/posts", `${postSlug}.md`),
];

for (const filePath of possiblePaths) {
if (fs.existsSync(filePath)) {
return filePath;
}
}

return null;
}

function sanitizeFileName(fileName) {
if (!fileName || typeof fileName !== "string") {
throw new Error("Invalid fileName parameter");
}

const sanitized = fileName
.replace(/\.md$/, "")
.replace(/[\\/]/g, "")
.replace(/\.\./g, "")
.replace(/^\.+/, "")
.replace(/[^a-zA-Z0-9_-]/g, "");

if (!sanitized) {
throw new Error("Invalid fileName after sanitization");
}

return sanitized;
}

function sanitizeDataSource(dataSource) {
if (!dataSource || typeof dataSource !== "string") {
throw new Error("Invalid dataSource parameter");
}
const { fileName } = req.query;
if (!fileName) {
return res.status(400).json({ error: "Missing fileName parameter" });

const sanitized = dataSource
.replace(/\\/g, "/")
.replace(/\.\.\/*/g, "")
.replace(/^\/+/, "");

if (!/^[a-zA-Z0-9_\-\/\.]+$/.test(sanitized)) {
throw new Error("Invalid characters in dataSource");
}
const mdPath = path.join(process.cwd(), "posts", `${fileName}.md`);
if (!fs.existsSync(mdPath)) {
return res.status(404).json({ error: "Markdown file not found" });

if (!sanitized.endsWith(".csv")) {
throw new Error("dataSource must be a CSV file");
}
const raw = fs.readFileSync(mdPath, "utf-8");
// Parse frontmatter

return sanitized;
}

function parseFrontmatter(raw) {
const match = raw.match(/^---([\s\S]*?)---\s*([\s\S]*)$/);

if (!match) {
return res
.status(500)
.json({ error: "Invalid markdown frontmatter format" });
throw new Error("Invalid markdown frontmatter format");
}
const frontmatterRaw = match[1];
const content = match[2].trim();
// Parse YAML frontmatter manually (simple key: value pairs)

const [, frontmatterRaw, content] = match;
const frontmatter = {};

frontmatterRaw.split("\n").forEach((line) => {
const m = line.match(/^([a-zA-Z0-9_\-]+):\s*(.*)$/);
if (m) {
let key = m[1].trim();
let value = m[2].trim();
// Remove quotes if present
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
// Parse arrays (e.g. tags: ["a", "b"])
if (value.startsWith("[") && value.endsWith("]")) {
try {
value = JSON.parse(value.replace(/'/g, '"'));
} catch {}
const lineMatch = line.match(/^([a-zA-Z0-9_\-]+):\s*(.*)$/);
if (!lineMatch) return;

const key = lineMatch[1].trim();
let value = lineMatch[2].trim();

value = removeQuotes(value);
value = parseArrayValue(value);

frontmatter[key] = value;
});

return { frontmatter, content: content.trim() };
}

function removeQuotes(value) {
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1);
}
return value;
}

function parseArrayValue(value) {
if (value.startsWith("[") && value.endsWith("]")) {
try {
return JSON.parse(value.replace(/'/g, '"'));
} catch {
return value;
}
}
return value;
}

function extractCharts(content, postSlug) {
if (!content.includes("```chart")) {
return { contentWithPlaceholders: content, charts: {} };
}

const charts = {};
let chartIndex = 0;

const processChart = (match, chartJson, type) => {
try {
const chartData = JSON.parse(chartJson.trim());
const chartId = chartData.id || `${type}-${chartIndex++}`;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const chartId = chartData.id || `${type}-${chartIndex++}`;
let chartId = chartData.id;
if (!chartId) {
// Find an ID that hasn't been used yet
while (charts[`${type}-${chartIndex}`]) {
chartIndex++;
}
chartId = `${type}-${chartIndex}`;
chartIndex++;
}

The chartIndex counter doesn't increment consistently across all charts, only when a chart lacks an explicit ID. This can cause ID collisions when mixing explicit IDs with auto-generated ones.

View Details

Analysis

Chart ID collision in extractCharts() causes data loss when mixing explicit and auto-generated IDs

What fails: The extractCharts() function in api/[fileName].js at line 207 loses charts when mixing explicit chart IDs with auto-generated ones. When a chart has an explicit ID (e.g., "chart-1") and a subsequent chart requires an auto-generated ID that happens to be the same (e.g., "chart-1"), the second chart overwrites the first in the charts object, causing permanent data loss.

How to reproduce:

// Create a markdown file with three charts:
// 1. Explicit ID "chart-1"
// 2. Auto-generated ID (no explicit id field)
// 3. Auto-generated ID (no explicit id field)

const markdown = `
\`\`\`chart
{"id": "chart-1", "title": "Chart One"}
\`\`\`

\`\`\`chart
{"title": "Chart Two"}
\`\`\`

\`\`\`chart
{"title": "Chart Three"}
\`\`\`
`;

// Process through extractCharts

Result: Only 2 charts are stored in the charts object:

  • chart-0: "Chart Two"
  • chart-1: "Chart Three" (overwrites the explicit "Chart One")

Expected: 3 charts stored with all data preserved:

  • chart-1: "Chart One"
  • chart-0: "Chart Two"
  • chart-2: "Chart Three"

Root cause: The original code only increments chartIndex when generating auto-IDs due to short-circuit evaluation of the || operator:

const chartId = chartData.id || ` 

When chartData.id is truthy (explicit ID provided), chartIndex++ never executes. This causes auto-generated IDs to potentially collide with explicit IDs that follow the type-number naming pattern.

Fix implemented: Added collision detection by checking if a generated ID is already in use before assigning it:

if (!chartId) {
  // Find an ID that hasn't been used yet
  while (charts[` 

This ensures auto-generated IDs skip any numbers that are already occupied by explicit IDs, preventing collisions entirely.


if (chartData.dataSource) {
chartData.data = loadChartData(postSlug, chartData.dataSource);
}
frontmatter[key] = value;

chartData.type = type;
charts[chartId] = chartData;

return `{{CHART:${chartId}}}`;
} catch (error) {
console.error(`Failed to process ${type}:`, error.message);
return match;
}
});
res.json({
title: frontmatter.title || null,
author_name: frontmatter.author_name || null,
author_image: frontmatter.author_image || null,
author_position: frontmatter.author_position || null,
publication_date: frontmatter.publication_date || null,
description: frontmatter.description || null,
image: frontmatter.image || null,
categories: frontmatter.categories || null,
tags: frontmatter.tags || null,
fileName: fileName.replace(/\.md$/, ""),
readTimeMinutes: Math.round(content.split(" ").length / 200),
content,
});
};

let contentWithPlaceholders = content
.replace(/```chart-multiple\s*\n([\s\S]*?)\n```/g, (match, json) =>
processChart(match, json, "chart-multiple")
)
.replace(/```chart\s*\n([\s\S]*?)\n```/g, (match, json) =>
processChart(match, json, "chart")
);

return { contentWithPlaceholders, charts };
}

function loadChartData(postSlug, dataSource) {
const sanitizedDataSource = sanitizeDataSource(dataSource);
const csvPath = path.join(
process.cwd(),
"blogCharts",
postSlug,
sanitizedDataSource
);

if (!fs.existsSync(csvPath)) {
throw new Error(`CSV file not found: ${sanitizedDataSource}`);
}

const csvContent = fs.readFileSync(csvPath, "utf-8");
const result = Papa.parse(csvContent, PAPAPARSE_CONFIG);

if (result.errors.length > 0) {
console.warn(
`CSV parsing warnings for ${sanitizedDataSource}:`,
result.errors
);
}

return result.data;
}

function calculateReadTime(content) {
const wordCount = content.split(" ").length;
const wordsPerMinute = 200;
return Math.round(wordCount / wordsPerMinute);
}
Loading