import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fetchPublicationWithDocuments } from "./atproto.ts"; import { createHandlebars, DEFAULT_CONTENT_TEMPLATE, generateContent, } from "./templates.ts"; import type { Document, ExportConfig, ExportOptions, ExportResult, Publication, TemplateData, } from "./types.ts"; /** * Get the text content from a document, handling different content formats */ function getDocumentContent(doc: Document): string { // textContent is the raw markdown if (doc.textContent) { return doc.textContent; } // content might be a string or structured content if (typeof doc.content === "string") { return doc.content; } return ""; } /** * Filter documents based on tag inclusion/exclusion rules */ function filterDocuments( documents: Document[], includeTags?: string[], excludeTags?: string[], ): { included: Document[]; skipped: number } { let filtered = documents; let skipped = 0; // If includeTags specified, only keep docs with at least one matching tag if (includeTags && includeTags.length > 0) { filtered = filtered.filter((doc) => { const docTags = doc.tags || []; const matches = includeTags.some((tag) => docTags.includes(tag)); if (!matches) skipped++; return matches; }); } else { // Default behavior: exclude drafts unless explicitly included filtered = filtered.filter((doc) => { const docTags = doc.tags || []; const isDraft = docTags.includes("draft"); if (isDraft) skipped++; return !isDraft; }); } // Exclude docs with any of the excludeTags if (excludeTags && excludeTags.length > 0) { const beforeExclude = filtered.length; filtered = filtered.filter((doc) => { const docTags = doc.tags || []; return !excludeTags.some((tag) => docTags.includes(tag)); }); skipped += beforeExclude - filtered.length; } return { included: filtered, skipped }; } /** * Build template data from a document and publication */ function buildTemplateData( doc: Document, publication: Publication, ): TemplateData { return { title: doc.title, path: doc.path, description: doc.description, content: getDocumentContent(doc), tags: doc.tags || [], publishedAt: doc.publishedAt, updatedAt: doc.updatedAt, publication: { name: publication.name, url: publication.url, description: publication.description, }, }; } /** * Sanitize a filename by removing path traversal and invalid characters */ function sanitizeFilename(filename: string): string { return filename.replace(/\.\./g, "").replace(/[<>:"|?*]/g, ""); } /** * Export a publication to markdown files */ export async function exportPublication( options: ExportOptions, ): Promise { const { publicationUri, outputDir, filename: filenameFunction, contentTemplate, contentFunction, includeTags, excludeTags, } = options; const result: ExportResult = { filesWritten: [], documentsProcessed: 0, documentsSkipped: 0, warnings: [], }; // Fetch publication and documents const { publication, documents } = await fetchPublicationWithDocuments(publicationUri); // Filter documents by tags const { included, skipped } = filterDocuments( documents, includeTags, excludeTags, ); result.documentsSkipped = skipped; // Create output directory await mkdir(outputDir, { recursive: true }); // Track filenames to detect conflicts const usedFilenames = new Set(); // Set up Handlebars (only needed if using content template) const hbs = createHandlebars(); // Process each document for (const doc of included) { result.documentsProcessed++; const data = buildTemplateData(doc, publication); // Generate filename using function let filename = filenameFunction(data); filename = sanitizeFilename(filename); if (!filename) { result.warnings.push( `Skipping document "${doc.title}": could not generate filename`, ); result.documentsSkipped++; continue; } // Check for filename conflicts if (usedFilenames.has(filename)) { result.warnings.push( `Skipping document "${doc.title}": filename conflict with "${filename}"`, ); result.documentsSkipped++; continue; } usedFilenames.add(filename); // Generate content using function or template let content: string; if (contentFunction) { content = contentFunction(data); } else { content = generateContent( hbs, contentTemplate || DEFAULT_CONTENT_TEMPLATE, data, ); } // Write file (creating subdirectories if needed) const filePath = join(outputDir, filename); const fileDir = dirname(filePath); await mkdir(fileDir, { recursive: true }); await writeFile(filePath, content, "utf-8"); result.filesWritten.push(filePath); } return result; } /** * Export a publication using a config with multiple export targets * @param config - The export configuration * @param configDir - Directory containing the config file (for resolving relative paths) */ export async function exportFromConfig( config: ExportConfig, configDir: string, ): Promise { const results: ExportResult[] = []; for (const target of config.exports) { // Load content template from file if specified let contentTemplate: string | undefined; if (target.contentTemplate) { const templatePath = resolve(configDir, target.contentTemplate); contentTemplate = await readFile(templatePath, "utf-8"); } const options: ExportOptions = { publicationUri: config.publicationUri, outputDir: resolve(configDir, target.outputDir), includeTags: target.includeTags, excludeTags: target.excludeTags, filename: target.filename, contentTemplate, contentFunction: target.content, }; const result = await exportPublication(options); results.push(result); } return results; }