- Create Economist SubmissionTracking package correctly: * mainArticle = full blog post content * coverLetter = 216-word SIR— letter * Links to blog post via blogPostId - Archive 'Letter to The Economist' from blog posts (it's the cover letter) - Fix date display on article cards (use published_at) - Target publication already displaying via blue badge Database changes: - Make blogPostId optional in SubmissionTracking model - Economist package ID: 68fa85ae49d4900e7f2ecd83 - Le Monde package ID: 68fa2abd2e6acd5691932150 Next: Enhanced modal with tabs, validation, export 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
367 lines
10 KiB
JavaScript
367 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Generate Card Presentation Sections from Markdown Documents
|
|
*
|
|
* Parses markdown files and creates structured sections for card-based UI presentation.
|
|
* Handles H2/H3 headers, converts to HTML, generates excerpts, estimates reading time.
|
|
*
|
|
* Usage: node scripts/generate-card-sections.js <markdown-file> [--update-db]
|
|
*
|
|
* Example: node scripts/generate-card-sections.js introduction-to-the-tractatus-framework.md --update-db
|
|
*/
|
|
|
|
// Load environment variables
|
|
require('dotenv').config();
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { marked } = require('marked');
|
|
|
|
// Configuration
|
|
const WORDS_PER_MINUTE = 200; // Average reading speed
|
|
|
|
/**
|
|
* Extract sections from markdown content
|
|
* @param {string} markdown - Raw markdown content
|
|
* @returns {Array} Array of section objects
|
|
*/
|
|
function extractSections(markdown) {
|
|
const lines = markdown.split('\n');
|
|
const sections = [];
|
|
let currentSection = null;
|
|
let contentBuffer = [];
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
|
|
// Match H2 headers (## Title)
|
|
const h2Match = line.match(/^## (.+)$/);
|
|
if (h2Match) {
|
|
// Save previous section if exists
|
|
if (currentSection) {
|
|
currentSection.content_md = contentBuffer.join('\n').trim();
|
|
sections.push(currentSection);
|
|
}
|
|
|
|
// Start new section
|
|
currentSection = {
|
|
title: h2Match[1].trim(),
|
|
content_md: '',
|
|
subsections: []
|
|
};
|
|
contentBuffer = [];
|
|
continue;
|
|
}
|
|
|
|
// Collect content for current section
|
|
if (currentSection) {
|
|
contentBuffer.push(line);
|
|
}
|
|
}
|
|
|
|
// Save final section
|
|
if (currentSection) {
|
|
currentSection.content_md = contentBuffer.join('\n').trim();
|
|
sections.push(currentSection);
|
|
}
|
|
|
|
return sections;
|
|
}
|
|
|
|
/**
|
|
* Generate excerpt from markdown content
|
|
* @param {string} markdown - Markdown content
|
|
* @param {number} maxLength - Maximum excerpt length
|
|
* @returns {string} Excerpt text
|
|
*/
|
|
function generateExcerpt(markdown, maxLength = 150) {
|
|
// Remove markdown formatting
|
|
let text = markdown
|
|
.replace(/^#+\s+/gm, '') // Remove headers
|
|
.replace(/\*\*(.+?)\*\*/g, '$1') // Remove bold
|
|
.replace(/\*(.+?)\*/g, '$1') // Remove italic
|
|
.replace(/\[(.+?)\]\(.+?\)/g, '$1') // Remove links
|
|
.replace(/`(.+?)`/g, '$1') // Remove inline code
|
|
.replace(/^[-*+]\s+/gm, '') // Remove list markers
|
|
.replace(/^\d+\.\s+/gm, '') // Remove numbered lists
|
|
.replace(/\n{2,}/g, ' ') // Collapse multiple newlines
|
|
.trim();
|
|
|
|
// Truncate to maxLength
|
|
if (text.length > maxLength) {
|
|
text = text.substring(0, maxLength).trim();
|
|
// Find last complete sentence
|
|
const lastPeriod = text.lastIndexOf('.');
|
|
if (lastPeriod > maxLength * 0.7) {
|
|
text = text.substring(0, lastPeriod + 1);
|
|
} else {
|
|
text += '...';
|
|
}
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
/**
|
|
* Estimate reading time based on word count
|
|
* @param {string} text - Text content
|
|
* @returns {number} Reading time in minutes
|
|
*/
|
|
function estimateReadingTime(text) {
|
|
const wordCount = text.split(/\s+/).length;
|
|
const minutes = Math.ceil(wordCount / WORDS_PER_MINUTE);
|
|
return Math.max(1, minutes); // Minimum 1 minute
|
|
}
|
|
|
|
/**
|
|
* Classify section category based on content analysis
|
|
* @param {string} title - Section title
|
|
* @param {string} content - Section content
|
|
* @returns {string} Category (conceptual|practical|technical|reference|critical)
|
|
*/
|
|
function classifySection(title, content) {
|
|
const titleLower = title.toLowerCase();
|
|
const contentLower = content.toLowerCase();
|
|
|
|
// Critical: Security, limitations, failures, warnings
|
|
if (
|
|
titleLower.includes('limitation') ||
|
|
titleLower.includes('failure') ||
|
|
titleLower.includes('warning') ||
|
|
titleLower.includes('security') ||
|
|
titleLower.includes('risk') ||
|
|
content.match(/⚠️|critical|warning|caution|danger/gi)
|
|
) {
|
|
return 'critical';
|
|
}
|
|
|
|
// Reference: Glossaries, definitions, specifications
|
|
if (
|
|
titleLower.includes('glossary') ||
|
|
titleLower.includes('reference') ||
|
|
titleLower.includes('contact') ||
|
|
titleLower.includes('license') ||
|
|
titleLower.includes('getting started')
|
|
) {
|
|
return 'reference';
|
|
}
|
|
|
|
// Technical: Code, APIs, architecture, implementation details
|
|
if (
|
|
titleLower.includes('technical') ||
|
|
titleLower.includes('architecture') ||
|
|
titleLower.includes('implementation') ||
|
|
titleLower.includes('integration') ||
|
|
titleLower.includes('api') ||
|
|
content.match(/```|`[a-z]+`|function|class|const|import/gi)
|
|
) {
|
|
return 'technical';
|
|
}
|
|
|
|
// Practical: How-to, tutorials, guides, use cases
|
|
if (
|
|
titleLower.includes('how') ||
|
|
titleLower.includes('guide') ||
|
|
titleLower.includes('tutorial') ||
|
|
titleLower.includes('example') ||
|
|
titleLower.includes('use case') ||
|
|
titleLower.includes('should use') ||
|
|
titleLower.includes('contributing')
|
|
) {
|
|
return 'practical';
|
|
}
|
|
|
|
// Default to conceptual: Theory, principles, explanations
|
|
return 'conceptual';
|
|
}
|
|
|
|
/**
|
|
* Determine technical level based on content complexity
|
|
* @param {string} content - Section content
|
|
* @returns {string} Technical level (beginner|intermediate|advanced)
|
|
*/
|
|
function determineTechnicalLevel(content) {
|
|
const contentLower = content.toLowerCase();
|
|
|
|
// Advanced: Code examples, APIs, complex architecture
|
|
if (
|
|
content.match(/```[\s\S]+```/g) ||
|
|
contentLower.includes('api') ||
|
|
contentLower.includes('implementation') ||
|
|
contentLower.includes('integration') ||
|
|
contentLower.includes('architecture')
|
|
) {
|
|
return 'advanced';
|
|
}
|
|
|
|
// Intermediate: Technical concepts without code
|
|
if (
|
|
contentLower.includes('service') ||
|
|
contentLower.includes('component') ||
|
|
contentLower.includes('system') ||
|
|
contentLower.includes('framework')
|
|
) {
|
|
return 'intermediate';
|
|
}
|
|
|
|
// Beginner: High-level concepts, introductions
|
|
return 'beginner';
|
|
}
|
|
|
|
/**
|
|
* Generate slug from title
|
|
* @param {string} title - Section title
|
|
* @returns {string} URL-friendly slug
|
|
*/
|
|
function generateSlug(title) {
|
|
return title
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s-]/g, '')
|
|
.replace(/\s+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
}
|
|
|
|
/**
|
|
* Process markdown file and generate card sections
|
|
* @param {string} filePath - Path to markdown file
|
|
* @returns {Array} Array of section objects ready for MongoDB
|
|
*/
|
|
async function processMarkdownFile(filePath) {
|
|
console.log(`\n📄 Processing: ${filePath}`);
|
|
|
|
// Read markdown file
|
|
const markdown = fs.readFileSync(filePath, 'utf8');
|
|
|
|
// Extract sections
|
|
const rawSections = extractSections(markdown);
|
|
console.log(` Found ${rawSections.length} sections`);
|
|
|
|
// Process each section
|
|
const sections = [];
|
|
for (let i = 0; i < rawSections.length; i++) {
|
|
const raw = rawSections[i];
|
|
|
|
// Skip empty sections
|
|
if (!raw.content_md.trim()) {
|
|
continue;
|
|
}
|
|
|
|
// Convert markdown to HTML
|
|
const content_html = marked(raw.content_md);
|
|
|
|
// Generate metadata
|
|
const excerpt = generateExcerpt(raw.content_md);
|
|
const readingTime = estimateReadingTime(raw.content_md);
|
|
const category = classifySection(raw.title, raw.content_md);
|
|
const technicalLevel = determineTechnicalLevel(raw.content_md);
|
|
const slug = generateSlug(raw.title);
|
|
|
|
const section = {
|
|
number: i + 1,
|
|
title: raw.title,
|
|
slug,
|
|
content_html,
|
|
excerpt,
|
|
readingTime,
|
|
technicalLevel,
|
|
category
|
|
};
|
|
|
|
sections.push(section);
|
|
|
|
console.log(` ${i + 1}. ${section.title}`);
|
|
console.log(` Category: ${category} | Level: ${technicalLevel} | ${readingTime} min`);
|
|
}
|
|
|
|
return sections;
|
|
}
|
|
|
|
/**
|
|
* Update document in MongoDB with generated sections
|
|
* @param {string} slug - Document slug
|
|
* @param {Array} sections - Array of section objects
|
|
*/
|
|
async function updateDatabase(slug, sections) {
|
|
try {
|
|
// Get Document model (uses MongoDB driver directly, not Mongoose)
|
|
const Document = require('../src/models/Document.model.js');
|
|
|
|
// Find document by slug
|
|
const doc = await Document.findBySlug(slug);
|
|
if (!doc) {
|
|
console.error(` ❌ Document not found: ${slug}`);
|
|
return false;
|
|
}
|
|
|
|
// Update sections
|
|
const success = await Document.update(doc._id.toString(), { sections });
|
|
|
|
if (!success) {
|
|
console.error(` ❌ Failed to update document`);
|
|
return false;
|
|
}
|
|
|
|
console.log(` ✅ Updated document in MongoDB: ${doc.title}`);
|
|
console.log(` 📊 Sections: ${sections.length}`);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error(` ❌ Database error: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main execution
|
|
*/
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
|
|
if (args.length === 0) {
|
|
console.error('Usage: node scripts/generate-card-sections.js <markdown-file> [--update-db]');
|
|
console.error('Example: node scripts/generate-card-sections.js introduction-to-the-tractatus-framework.md --update-db');
|
|
process.exit(1);
|
|
}
|
|
|
|
const markdownFile = args[0];
|
|
const updateDb = args.includes('--update-db');
|
|
|
|
if (!fs.existsSync(markdownFile)) {
|
|
console.error(`❌ File not found: ${markdownFile}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Generate sections
|
|
const sections = await processMarkdownFile(markdownFile);
|
|
|
|
// Output JSON
|
|
console.log(`\n📦 Generated ${sections.length} sections\n`);
|
|
|
|
if (!updateDb) {
|
|
console.log(JSON.stringify(sections, null, 2));
|
|
console.log('\n💡 To update database, add --update-db flag');
|
|
} else {
|
|
// Extract slug from filename
|
|
const slug = path.basename(markdownFile, '.md');
|
|
const success = await updateDatabase(slug, sections);
|
|
|
|
if (success) {
|
|
console.log(`\n✅ Card presentation sections updated successfully!`);
|
|
} else {
|
|
console.log(`\n❌ Failed to update database`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run if called directly
|
|
if (require.main === module) {
|
|
main().catch(error => {
|
|
console.error('Fatal error:', error);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
module.exports = { processMarkdownFile, extractSections, classifySection };
|