this repo has no description
at main 234 lines 5.9 kB view raw
1import { mkdir, readFile, writeFile } from "node:fs/promises"; 2import { dirname, join, resolve } from "node:path"; 3import { fetchPublicationWithDocuments } from "./atproto.ts"; 4import { 5 createHandlebars, 6 DEFAULT_CONTENT_TEMPLATE, 7 generateContent, 8} from "./templates.ts"; 9import type { 10 Document, 11 ExportConfig, 12 ExportOptions, 13 ExportResult, 14 Publication, 15 TemplateData, 16} from "./types.ts"; 17 18/** 19 * Get the text content from a document, handling different content formats 20 */ 21function getDocumentContent(doc: Document): string { 22 // textContent is the raw markdown 23 if (doc.textContent) { 24 return doc.textContent; 25 } 26 // content might be a string or structured content 27 if (typeof doc.content === "string") { 28 return doc.content; 29 } 30 return ""; 31} 32 33/** 34 * Filter documents based on tag inclusion/exclusion rules 35 */ 36function filterDocuments( 37 documents: Document[], 38 includeTags?: string[], 39 excludeTags?: string[], 40): { included: Document[]; skipped: number } { 41 let filtered = documents; 42 let skipped = 0; 43 44 // If includeTags specified, only keep docs with at least one matching tag 45 if (includeTags && includeTags.length > 0) { 46 filtered = filtered.filter((doc) => { 47 const docTags = doc.tags || []; 48 const matches = includeTags.some((tag) => docTags.includes(tag)); 49 if (!matches) skipped++; 50 return matches; 51 }); 52 } else { 53 // Default behavior: exclude drafts unless explicitly included 54 filtered = filtered.filter((doc) => { 55 const docTags = doc.tags || []; 56 const isDraft = docTags.includes("draft"); 57 if (isDraft) skipped++; 58 return !isDraft; 59 }); 60 } 61 62 // Exclude docs with any of the excludeTags 63 if (excludeTags && excludeTags.length > 0) { 64 const beforeExclude = filtered.length; 65 filtered = filtered.filter((doc) => { 66 const docTags = doc.tags || []; 67 return !excludeTags.some((tag) => docTags.includes(tag)); 68 }); 69 skipped += beforeExclude - filtered.length; 70 } 71 72 return { included: filtered, skipped }; 73} 74 75/** 76 * Build template data from a document and publication 77 */ 78function buildTemplateData( 79 doc: Document, 80 publication: Publication, 81): TemplateData { 82 return { 83 title: doc.title, 84 path: doc.path, 85 description: doc.description, 86 content: getDocumentContent(doc), 87 tags: doc.tags || [], 88 publishedAt: doc.publishedAt, 89 updatedAt: doc.updatedAt, 90 publication: { 91 name: publication.name, 92 url: publication.url, 93 description: publication.description, 94 }, 95 }; 96} 97 98/** 99 * Sanitize a filename by removing path traversal and invalid characters 100 */ 101function sanitizeFilename(filename: string): string { 102 return filename.replace(/\.\./g, "").replace(/[<>:"|?*]/g, ""); 103} 104 105/** 106 * Export a publication to markdown files 107 */ 108export async function exportPublication( 109 options: ExportOptions, 110): Promise<ExportResult> { 111 const { 112 publicationUri, 113 outputDir, 114 filename: filenameFunction, 115 contentTemplate, 116 contentFunction, 117 includeTags, 118 excludeTags, 119 } = options; 120 121 const result: ExportResult = { 122 filesWritten: [], 123 documentsProcessed: 0, 124 documentsSkipped: 0, 125 warnings: [], 126 }; 127 128 // Fetch publication and documents 129 const { publication, documents } = 130 await fetchPublicationWithDocuments(publicationUri); 131 132 // Filter documents by tags 133 const { included, skipped } = filterDocuments( 134 documents, 135 includeTags, 136 excludeTags, 137 ); 138 result.documentsSkipped = skipped; 139 140 // Create output directory 141 await mkdir(outputDir, { recursive: true }); 142 143 // Track filenames to detect conflicts 144 const usedFilenames = new Set<string>(); 145 146 // Set up Handlebars (only needed if using content template) 147 const hbs = createHandlebars(); 148 149 // Process each document 150 for (const doc of included) { 151 result.documentsProcessed++; 152 153 const data = buildTemplateData(doc, publication); 154 155 // Generate filename using function 156 let filename = filenameFunction(data); 157 filename = sanitizeFilename(filename); 158 159 if (!filename) { 160 result.warnings.push( 161 `Skipping document "${doc.title}": could not generate filename`, 162 ); 163 result.documentsSkipped++; 164 continue; 165 } 166 167 // Check for filename conflicts 168 if (usedFilenames.has(filename)) { 169 result.warnings.push( 170 `Skipping document "${doc.title}": filename conflict with "${filename}"`, 171 ); 172 result.documentsSkipped++; 173 continue; 174 } 175 usedFilenames.add(filename); 176 177 // Generate content using function or template 178 let content: string; 179 if (contentFunction) { 180 content = contentFunction(data); 181 } else { 182 content = generateContent( 183 hbs, 184 contentTemplate || DEFAULT_CONTENT_TEMPLATE, 185 data, 186 ); 187 } 188 189 // Write file (creating subdirectories if needed) 190 const filePath = join(outputDir, filename); 191 const fileDir = dirname(filePath); 192 await mkdir(fileDir, { recursive: true }); 193 await writeFile(filePath, content, "utf-8"); 194 result.filesWritten.push(filePath); 195 } 196 197 return result; 198} 199 200/** 201 * Export a publication using a config with multiple export targets 202 * @param config - The export configuration 203 * @param configDir - Directory containing the config file (for resolving relative paths) 204 */ 205export async function exportFromConfig( 206 config: ExportConfig, 207 configDir: string, 208): Promise<ExportResult[]> { 209 const results: ExportResult[] = []; 210 211 for (const target of config.exports) { 212 // Load content template from file if specified 213 let contentTemplate: string | undefined; 214 if (target.contentTemplate) { 215 const templatePath = resolve(configDir, target.contentTemplate); 216 contentTemplate = await readFile(templatePath, "utf-8"); 217 } 218 219 const options: ExportOptions = { 220 publicationUri: config.publicationUri, 221 outputDir: resolve(configDir, target.outputDir), 222 includeTags: target.includeTags, 223 excludeTags: target.excludeTags, 224 filename: target.filename, 225 contentTemplate, 226 contentFunction: target.content, 227 }; 228 229 const result = await exportPublication(options); 230 results.push(result); 231 } 232 233 return results; 234}