this repo has no description

CLI for exporting standard.site data to an output directory

seth.computer d77a09bf 023d25ba

verified
+718 -7
+38 -6
bun.lock
··· 3 3 "configVersion": 1, 4 4 "workspaces": { 5 5 "": { 6 - "name": "stdpub", 6 + "name": "site-editor", 7 7 "devDependencies": { 8 8 "@biomejs/biome": "^2.3.11", 9 9 }, 10 10 }, 11 - "packages/lib": { 12 - "name": "@stdpub/lib", 11 + "packages/cli": { 12 + "name": "@stdsite/cli", 13 + "version": "0.0.1", 14 + "bin": { 15 + "stdsite": "./src/index.ts", 16 + }, 17 + "dependencies": { 18 + "@stdsite/cli": ".", 19 + "@stdsite/core": "workspace:*", 20 + "commander": "^12.1.0", 21 + }, 22 + }, 23 + "packages/core": { 24 + "name": "@stdsite/core", 25 + "version": "0.0.1", 26 + "dependencies": { 27 + "handlebars": "^4.7.8", 28 + }, 13 29 }, 14 30 "packages/web": { 15 - "name": "@stdpub/web", 31 + "name": "@site-editor/web", 16 32 "dependencies": { 17 33 "@atproto/api": "^0.18.13", 18 34 "@atproto/jwk-jose": "^0.1.11", ··· 93 109 94 110 "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="], 95 111 96 - "@stdpub/lib": ["@stdpub/lib@workspace:packages/lib"], 112 + "@site-editor/web": ["@site-editor/web@workspace:packages/web"], 97 113 98 - "@stdpub/web": ["@stdpub/web@workspace:packages/web"], 114 + "@stdsite/cli": ["@stdsite/cli@workspace:packages/cli"], 115 + 116 + "@stdsite/core": ["@stdsite/core@workspace:packages/core"], 99 117 100 118 "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], 101 119 ··· 105 123 106 124 "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], 107 125 126 + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], 127 + 108 128 "core-js": ["core-js@3.47.0", "", {}, "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg=="], 129 + 130 + "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], 109 131 110 132 "hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], 111 133 ··· 119 141 120 142 "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], 121 143 144 + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], 145 + 122 146 "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 123 147 148 + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], 149 + 150 + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], 151 + 124 152 "tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="], 125 153 126 154 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 127 155 128 156 "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 157 + 158 + "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], 129 159 130 160 "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 131 161 ··· 134 164 "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 135 165 136 166 "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="], 167 + 168 + "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], 137 169 138 170 "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 139 171 }
+1 -1
package.json
··· 1 1 { 2 - "name": "stdpub", 2 + "name": "site-editor", 3 3 "private": true, 4 4 "workspaces": [ 5 5 "packages/*"
+13
packages/cli/package.json
··· 1 + { 2 + "name": "@stdsite/cli", 3 + "type": "module", 4 + "version": "0.0.1", 5 + "bin": { 6 + "stdsite": "./src/index.ts" 7 + }, 8 + "dependencies": { 9 + "@stdsite/cli": "workspace:*", 10 + "@stdsite/core": "workspace:*", 11 + "commander": "^12.1.0" 12 + } 13 + }
+102
packages/cli/src/index.ts
··· 1 + #!/usr/bin/env bun 2 + import { readFile } from "node:fs/promises"; 3 + import { resolve } from "node:path"; 4 + import { Command } from "commander"; 5 + import { 6 + DEFAULT_FILENAME_TEMPLATE, 7 + exportPublication, 8 + } from "@stdsite/core"; 9 + 10 + const program = new Command(); 11 + 12 + program 13 + .name("stdsite") 14 + .description("CLI tools for standard.site publications") 15 + .version("0.0.1"); 16 + 17 + program 18 + .command("export") 19 + .description("Export a publication to markdown files") 20 + .argument("<at-uri>", "AT URI of the publication to export") 21 + .requiredOption("-o, --output <dir>", "Output directory for markdown files") 22 + .option("-t, --template <file>", "Path to custom content template file") 23 + .option("--filename-template <template>", "Handlebars template for filenames") 24 + .option( 25 + "--include-tags <tags>", 26 + "Only include documents with these tags (comma-separated)", 27 + ) 28 + .option( 29 + "--exclude-tags <tags>", 30 + "Exclude documents with these tags (comma-separated)", 31 + ) 32 + .action( 33 + async ( 34 + atUri: string, 35 + options: { 36 + output: string; 37 + template?: string; 38 + filenameTemplate?: string; 39 + includeTags?: string; 40 + excludeTags?: string; 41 + }, 42 + ) => { 43 + try { 44 + // Parse tag options 45 + const includeTags = options.includeTags 46 + ? options.includeTags.split(",").map((t) => t.trim()) 47 + : undefined; 48 + const excludeTags = options.excludeTags 49 + ? options.excludeTags.split(",").map((t) => t.trim()) 50 + : undefined; 51 + 52 + // Load custom content template if provided 53 + let contentTemplate: string | undefined; 54 + if (options.template) { 55 + const templatePath = resolve(options.template); 56 + contentTemplate = await readFile(templatePath, "utf-8"); 57 + } 58 + 59 + // Use custom filename template or default 60 + const filenameTemplate = 61 + options.filenameTemplate || DEFAULT_FILENAME_TEMPLATE; 62 + 63 + console.log(`Exporting publication: ${atUri}`); 64 + console.log(`Output directory: ${options.output}`); 65 + 66 + const result = await exportPublication({ 67 + publicationUri: atUri, 68 + outputDir: resolve(options.output), 69 + contentTemplate, 70 + filenameTemplate, 71 + includeTags, 72 + excludeTags, 73 + }); 74 + 75 + console.log(`\nExport complete:`); 76 + console.log(` Documents processed: ${result.documentsProcessed}`); 77 + console.log(` Documents skipped: ${result.documentsSkipped}`); 78 + console.log(` Files written: ${result.filesWritten.length}`); 79 + 80 + if (result.warnings.length > 0) { 81 + console.log(`\nWarnings:`); 82 + for (const warning of result.warnings) { 83 + console.log(` - ${warning}`); 84 + } 85 + } 86 + 87 + if (result.filesWritten.length > 0) { 88 + console.log(`\nFiles:`); 89 + for (const file of result.filesWritten) { 90 + console.log(` - ${file}`); 91 + } 92 + } 93 + } catch (error) { 94 + console.error( 95 + `Error: ${error instanceof Error ? error.message : String(error)}`, 96 + ); 97 + process.exit(1); 98 + } 99 + }, 100 + ); 101 + 102 + program.parse();
+11
packages/core/package.json
··· 1 + { 2 + "name": "@stdsite/core", 3 + "type": "module", 4 + "version": "0.0.1", 5 + "exports": { 6 + ".": "./src/index.ts" 7 + }, 8 + "dependencies": { 9 + "handlebars": "^4.7.8" 10 + } 11 + }
+136
packages/core/src/atproto.ts
··· 1 + import type { Document, Publication } from "./types.ts"; 2 + 3 + const DOCUMENT_COLLECTION = "site.standard.document"; 4 + const PUBLICATION_COLLECTION = "site.standard.publication"; 5 + 6 + interface AtUri { 7 + did: string; 8 + collection: string; 9 + rkey: string; 10 + } 11 + 12 + /** 13 + * Parse an AT URI into its components 14 + * Format: at://did:plc:xyz/collection/rkey 15 + */ 16 + export function parseAtUri(uri: string): AtUri { 17 + const match = uri.match(/^at:\/\/(did:[^/]+)\/([^/]+)\/([^/]+)$/); 18 + if (!match) { 19 + throw new Error(`Invalid AT URI: ${uri}`); 20 + } 21 + return { 22 + did: match[1], 23 + collection: match[2], 24 + rkey: match[3], 25 + }; 26 + } 27 + 28 + /** 29 + * Get the PDS endpoint for a DID by resolving through plc.directory 30 + */ 31 + export async function getPdsEndpoint(did: string): Promise<string> { 32 + const response = await fetch(`https://plc.directory/${did}`); 33 + if (!response.ok) { 34 + throw new Error(`Failed to resolve DID ${did}: ${response.status}`); 35 + } 36 + 37 + const doc = (await response.json()) as { 38 + service?: Array<{ id: string; serviceEndpoint: string }>; 39 + }; 40 + const pdsService = doc.service?.find((s) => s.id === "#atproto_pds"); 41 + 42 + if (!pdsService) { 43 + throw new Error(`No PDS service found for DID ${did}`); 44 + } 45 + 46 + return pdsService.serviceEndpoint; 47 + } 48 + 49 + /** 50 + * Fetch a publication record from a PDS 51 + */ 52 + export async function fetchPublication( 53 + pdsEndpoint: string, 54 + did: string, 55 + rkey: string, 56 + ): Promise<Publication> { 57 + const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${PUBLICATION_COLLECTION}&rkey=${encodeURIComponent(rkey)}`; 58 + 59 + const response = await fetch(url); 60 + if (!response.ok) { 61 + throw new Error(`Failed to fetch publication: ${response.status}`); 62 + } 63 + 64 + const data = (await response.json()) as { value: Publication }; 65 + return data.value; 66 + } 67 + 68 + /** 69 + * Fetch all documents for a publication from a PDS 70 + */ 71 + export async function fetchDocuments( 72 + pdsEndpoint: string, 73 + did: string, 74 + publicationUri: string, 75 + ): Promise<Document[]> { 76 + const documents: Document[] = []; 77 + let cursor: string | undefined; 78 + 79 + do { 80 + const params = new URLSearchParams({ 81 + repo: did, 82 + collection: DOCUMENT_COLLECTION, 83 + limit: "100", 84 + }); 85 + if (cursor) { 86 + params.set("cursor", cursor); 87 + } 88 + 89 + const url = `${pdsEndpoint}/xrpc/com.atproto.repo.listRecords?${params}`; 90 + const response = await fetch(url); 91 + 92 + if (!response.ok) { 93 + throw new Error(`Failed to fetch documents: ${response.status}`); 94 + } 95 + 96 + const data = (await response.json()) as { 97 + records: Array<{ value: Document }>; 98 + cursor?: string; 99 + }; 100 + 101 + // Filter to only documents belonging to this publication 102 + for (const record of data.records) { 103 + if (record.value.site === publicationUri) { 104 + documents.push(record.value); 105 + } 106 + } 107 + 108 + cursor = data.cursor; 109 + } while (cursor); 110 + 111 + return documents; 112 + } 113 + 114 + /** 115 + * Fetch a publication and its documents given a publication AT URI 116 + */ 117 + export async function fetchPublicationWithDocuments( 118 + publicationUri: string, 119 + ): Promise<{ 120 + publication: Publication; 121 + documents: Document[]; 122 + }> { 123 + const { did, collection, rkey } = parseAtUri(publicationUri); 124 + 125 + if (collection !== PUBLICATION_COLLECTION) { 126 + throw new Error( 127 + `Expected ${PUBLICATION_COLLECTION} collection, got ${collection}`, 128 + ); 129 + } 130 + 131 + const pdsEndpoint = await getPdsEndpoint(did); 132 + const publication = await fetchPublication(pdsEndpoint, did, rkey); 133 + const documents = await fetchDocuments(pdsEndpoint, did, publicationUri); 134 + 135 + return { publication, documents }; 136 + }
+179
packages/core/src/export.ts
··· 1 + import { mkdir, writeFile } from "node:fs/promises"; 2 + import { join } from "node:path"; 3 + import { fetchPublicationWithDocuments } from "./atproto.ts"; 4 + import { 5 + createHandlebars, 6 + DEFAULT_CONTENT_TEMPLATE, 7 + DEFAULT_FILENAME_TEMPLATE, 8 + generateContent, 9 + generateFilename, 10 + } from "./templates.ts"; 11 + import type { 12 + Document, 13 + ExportOptions, 14 + ExportResult, 15 + Publication, 16 + TemplateData, 17 + } from "./types.ts"; 18 + 19 + /** 20 + * Get the text content from a document, handling different content formats 21 + */ 22 + function getDocumentContent(doc: Document): string { 23 + // textContent is the raw markdown 24 + if (doc.textContent) { 25 + return doc.textContent; 26 + } 27 + // content might be a string or structured content 28 + if (typeof doc.content === "string") { 29 + return doc.content; 30 + } 31 + return ""; 32 + } 33 + 34 + /** 35 + * Filter documents based on tag inclusion/exclusion rules 36 + */ 37 + function filterDocuments( 38 + documents: Document[], 39 + includeTags?: string[], 40 + excludeTags?: string[], 41 + ): { included: Document[]; skipped: number } { 42 + let filtered = documents; 43 + let skipped = 0; 44 + 45 + // If includeTags specified, only keep docs with at least one matching tag 46 + if (includeTags && includeTags.length > 0) { 47 + filtered = filtered.filter((doc) => { 48 + const docTags = doc.tags || []; 49 + const matches = includeTags.some((tag) => docTags.includes(tag)); 50 + if (!matches) skipped++; 51 + return matches; 52 + }); 53 + } else { 54 + // Default behavior: exclude drafts unless explicitly included 55 + filtered = filtered.filter((doc) => { 56 + const docTags = doc.tags || []; 57 + const isDraft = docTags.includes("draft"); 58 + if (isDraft) skipped++; 59 + return !isDraft; 60 + }); 61 + } 62 + 63 + // Exclude docs with any of the excludeTags 64 + if (excludeTags && excludeTags.length > 0) { 65 + const beforeExclude = filtered.length; 66 + filtered = filtered.filter((doc) => { 67 + const docTags = doc.tags || []; 68 + return !excludeTags.some((tag) => docTags.includes(tag)); 69 + }); 70 + skipped += beforeExclude - filtered.length; 71 + } 72 + 73 + return { included: filtered, skipped }; 74 + } 75 + 76 + /** 77 + * Build template data from a document and publication 78 + */ 79 + function buildTemplateData( 80 + doc: Document, 81 + publication: Publication, 82 + ): TemplateData { 83 + return { 84 + title: doc.title, 85 + path: doc.path, 86 + description: doc.description, 87 + content: getDocumentContent(doc), 88 + tags: doc.tags || [], 89 + publishedAt: doc.publishedAt, 90 + updatedAt: doc.updatedAt, 91 + publication: { 92 + name: publication.name, 93 + url: publication.url, 94 + description: publication.description, 95 + }, 96 + }; 97 + } 98 + 99 + /** 100 + * Export a publication to markdown files 101 + */ 102 + export async function exportPublication( 103 + options: ExportOptions, 104 + ): Promise<ExportResult> { 105 + const { 106 + publicationUri, 107 + outputDir, 108 + contentTemplate = DEFAULT_CONTENT_TEMPLATE, 109 + filenameTemplate = DEFAULT_FILENAME_TEMPLATE, 110 + includeTags, 111 + excludeTags, 112 + } = options; 113 + 114 + const result: ExportResult = { 115 + filesWritten: [], 116 + documentsProcessed: 0, 117 + documentsSkipped: 0, 118 + warnings: [], 119 + }; 120 + 121 + // Fetch publication and documents 122 + const { publication, documents } = 123 + await fetchPublicationWithDocuments(publicationUri); 124 + 125 + // Filter documents by tags 126 + const { included, skipped } = filterDocuments( 127 + documents, 128 + includeTags, 129 + excludeTags, 130 + ); 131 + result.documentsSkipped = skipped; 132 + 133 + // Create output directory 134 + await mkdir(outputDir, { recursive: true }); 135 + 136 + // Track filenames to detect conflicts 137 + const usedFilenames = new Set<string>(); 138 + 139 + // Set up Handlebars 140 + const hbs = createHandlebars(); 141 + 142 + // Process each document 143 + for (const doc of included) { 144 + result.documentsProcessed++; 145 + 146 + const data = buildTemplateData(doc, publication); 147 + 148 + // Generate filename 149 + const filename = generateFilename(hbs, filenameTemplate, data); 150 + 151 + if (!filename) { 152 + result.warnings.push( 153 + `Skipping document "${doc.title}": could not generate filename`, 154 + ); 155 + result.documentsSkipped++; 156 + continue; 157 + } 158 + 159 + // Check for filename conflicts 160 + if (usedFilenames.has(filename)) { 161 + result.warnings.push( 162 + `Skipping document "${doc.title}": filename conflict with "${filename}"`, 163 + ); 164 + result.documentsSkipped++; 165 + continue; 166 + } 167 + usedFilenames.add(filename); 168 + 169 + // Generate content 170 + const content = generateContent(hbs, contentTemplate, data); 171 + 172 + // Write file 173 + const filePath = join(outputDir, filename); 174 + await writeFile(filePath, content, "utf-8"); 175 + result.filesWritten.push(filePath); 176 + } 177 + 178 + return result; 179 + }
+30
packages/core/src/index.ts
··· 1 + // Main export function 2 + export { exportPublication } from "./export.ts"; 3 + 4 + // Templates 5 + export { 6 + DEFAULT_CONTENT_TEMPLATE, 7 + DEFAULT_FILENAME_TEMPLATE, 8 + createHandlebars, 9 + generateContent, 10 + generateFilename, 11 + renderTemplate, 12 + } from "./templates.ts"; 13 + 14 + // AT Protocol utilities 15 + export { 16 + fetchDocuments, 17 + fetchPublication, 18 + fetchPublicationWithDocuments, 19 + getPdsEndpoint, 20 + parseAtUri, 21 + } from "./atproto.ts"; 22 + 23 + // Types 24 + export type { 25 + Document, 26 + ExportOptions, 27 + ExportResult, 28 + Publication, 29 + TemplateData, 30 + } from "./types.ts";
+139
packages/core/src/templates.ts
··· 1 + import Handlebars from "handlebars"; 2 + import type { TemplateData } from "./types.ts"; 3 + 4 + /** 5 + * Default template for generating filenames 6 + */ 7 + export const DEFAULT_FILENAME_TEMPLATE = `{{dateFormat publishedAt "YYYY-MM-DD"}}_{{slug (default path title)}}.md`; 8 + 9 + /** 10 + * Default template for generating file content 11 + */ 12 + export const DEFAULT_CONTENT_TEMPLATE = `--- 13 + title: "{{title}}" 14 + date: {{publishedAt}} 15 + {{#if tags.length}} 16 + tags: 17 + {{#each tags}} 18 + - {{this}} 19 + {{/each}} 20 + {{/if}} 21 + --- 22 + 23 + {{content}} 24 + `; 25 + 26 + /** 27 + * Convert a string to a URL-safe slug 28 + */ 29 + function slugify(text: string): string { 30 + return text 31 + .toString() 32 + .toLowerCase() 33 + .trim() 34 + .replace(/^\/+/, "") // Remove leading slashes 35 + .replace(/\/+$/, "") // Remove trailing slashes 36 + .replace(/\s+/g, "-") // Replace spaces with - 37 + .replace(/[^\w-]+/g, "") // Remove non-word chars (except -) 38 + .replace(/--+/g, "-") // Replace multiple - with single - 39 + .replace(/^-+/, "") // Trim - from start 40 + .replace(/-+$/, ""); // Trim - from end 41 + } 42 + 43 + /** 44 + * Format a date string 45 + */ 46 + function formatDate(dateStr: string | undefined, format: string): string { 47 + if (!dateStr) { 48 + return ""; 49 + } 50 + 51 + const date = new Date(dateStr); 52 + if (Number.isNaN(date.getTime())) { 53 + return ""; 54 + } 55 + 56 + // Simple format replacements 57 + const year = date.getFullYear(); 58 + const month = String(date.getMonth() + 1).padStart(2, "0"); 59 + const day = String(date.getDate()).padStart(2, "0"); 60 + 61 + return format 62 + .replace("YYYY", String(year)) 63 + .replace("MM", month) 64 + .replace("DD", day); 65 + } 66 + 67 + /** 68 + * Register custom Handlebars helpers 69 + */ 70 + function registerHelpers(hbs: typeof Handlebars): void { 71 + // Slug helper: {{slug text}} 72 + hbs.registerHelper("slug", (text: unknown) => { 73 + if (typeof text !== "string") { 74 + return ""; 75 + } 76 + return slugify(text); 77 + }); 78 + 79 + // Date format helper: {{dateFormat date "YYYY-MM-DD"}} 80 + hbs.registerHelper("dateFormat", (date: unknown, format: unknown) => { 81 + if (typeof date !== "string" || typeof format !== "string") { 82 + return ""; 83 + } 84 + return formatDate(date, format); 85 + }); 86 + 87 + // Default helper: {{default value fallback}} 88 + hbs.registerHelper("default", (value: unknown, fallback: unknown) => { 89 + if (value !== undefined && value !== null && value !== "") { 90 + return value; 91 + } 92 + return fallback; 93 + }); 94 + } 95 + 96 + /** 97 + * Create a configured Handlebars instance with helpers registered 98 + */ 99 + export function createHandlebars(): typeof Handlebars { 100 + const hbs = Handlebars.create(); 101 + registerHelpers(hbs); 102 + return hbs; 103 + } 104 + 105 + /** 106 + * Compile and render a template with the given data 107 + */ 108 + export function renderTemplate( 109 + hbs: typeof Handlebars, 110 + template: string, 111 + data: TemplateData, 112 + ): string { 113 + const compiled = hbs.compile(template); 114 + return compiled(data); 115 + } 116 + 117 + /** 118 + * Generate a filename from template and data 119 + */ 120 + export function generateFilename( 121 + hbs: typeof Handlebars, 122 + template: string, 123 + data: TemplateData, 124 + ): string { 125 + const filename = renderTemplate(hbs, template, data); 126 + // Sanitize filename - remove any path traversal attempts 127 + return filename.replace(/\.\./g, "").replace(/[<>:"|?*]/g, ""); 128 + } 129 + 130 + /** 131 + * Generate file content from template and data 132 + */ 133 + export function generateContent( 134 + hbs: typeof Handlebars, 135 + template: string, 136 + data: TemplateData, 137 + ): string { 138 + return renderTemplate(hbs, template, data); 139 + }
+69
packages/core/src/types.ts
··· 1 + /** 2 + * Options for exporting a publication to markdown files 3 + */ 4 + export interface ExportOptions { 5 + /** AT URI of the publication (e.g., at://did:plc:xyz/site.standard.publication/rkey) */ 6 + publicationUri: string; 7 + /** Directory to write output files */ 8 + outputDir: string; 9 + /** Handlebars template for file content (uses default if not provided) */ 10 + contentTemplate?: string; 11 + /** Handlebars template for filename (uses default if not provided) */ 12 + filenameTemplate?: string; 13 + /** Only include documents with ANY of these tags */ 14 + includeTags?: string[]; 15 + /** Exclude documents with ANY of these tags */ 16 + excludeTags?: string[]; 17 + } 18 + 19 + /** 20 + * Result of an export operation 21 + */ 22 + export interface ExportResult { 23 + /** Paths of successfully written files */ 24 + filesWritten: string[]; 25 + /** Number of documents processed */ 26 + documentsProcessed: number; 27 + /** Number of documents skipped (filtered out or errored) */ 28 + documentsSkipped: number; 29 + /** Warnings encountered during export */ 30 + warnings: string[]; 31 + } 32 + 33 + /** 34 + * Publication record from site.standard.publication 35 + */ 36 + export interface Publication { 37 + name: string; 38 + url: string; 39 + description?: string; 40 + } 41 + 42 + /** 43 + * Document record from site.standard.document 44 + */ 45 + export interface Document { 46 + title: string; 47 + path?: string; 48 + description?: string; 49 + content?: string; 50 + textContent?: string; 51 + tags?: string[]; 52 + publishedAt?: string; 53 + updatedAt?: string; 54 + site: string; 55 + } 56 + 57 + /** 58 + * Data passed to Handlebars templates 59 + */ 60 + export interface TemplateData { 61 + title: string; 62 + path?: string; 63 + description?: string; 64 + content: string; 65 + tags: string[]; 66 + publishedAt?: string; 67 + updatedAt?: string; 68 + publication: Publication; 69 + }