this repo has no description
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}