this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Site export config, with init command

+532 -38
+211 -4
README.md
··· 1 1 # sitebase 2 2 3 - To install dependencies: 3 + Export content from [standard.site](https://standard.site) publications to markdown files. 4 + 5 + Sitebase connects to ATProto Personal Data Servers (PDS) to fetch documents from `site.standard.publication` collections and exports them as markdown files with configurable templates and filtering. 6 + 7 + ## Installation 4 8 5 9 ```bash 6 10 bun install 7 11 ``` 8 12 9 - To run: 13 + ## Packages 14 + 15 + This is a monorepo with three packages: 16 + 17 + - **@sitebase/core** - Library for exporting publications and template utilities 18 + - **@sitebase/cli** - Command-line interface for running exports 19 + - **@sitebase/web** - Web UI for managing publications and documents 20 + 21 + ## CLI Usage 22 + 23 + ```bash 24 + # Auto-discover sitebase.config.ts in current directory 25 + sitebase export 26 + 27 + # Specify a config file 28 + sitebase export --config ./my-config.ts 29 + ``` 30 + 31 + The CLI looks for `sitebase.config.ts` or `sitebase.config.js` in the current directory. Use `-c` or `--config` to specify a different path. 32 + 33 + ## Configuration 34 + 35 + Create a `sitebase.config.ts` file in your project root: 36 + 37 + ```typescript 38 + import type { ExportConfig } from "@sitebase/core"; 39 + import { slugify } from "@sitebase/core"; 40 + 41 + const config: ExportConfig = { 42 + // AT URI of your publication 43 + publicationUri: "at://did:plc:xyz/site.standard.publication/rkey", 44 + 45 + // One or more export targets 46 + exports: [ 47 + { 48 + outputDir: "./content/posts", 49 + includeTags: ["post"], 50 + excludeTags: ["draft"], 51 + filename: (data) => { 52 + const date = data.publishedAt?.slice(0, 10) || "undated"; 53 + return `${date}_${slugify(data.title)}.md`; 54 + }, 55 + contentTemplate: "./templates/post.hbs", 56 + }, 57 + ], 58 + }; 59 + 60 + export default config; 61 + ``` 62 + 63 + ### Config Reference 64 + 65 + #### `ExportConfig` 66 + 67 + | Field | Type | Description | 68 + |-------|------|-------------| 69 + | `publicationUri` | `string` | AT URI of the publication (`at://did:plc:.../site.standard.publication/rkey`) | 70 + | `exports` | `ExportTarget[]` | Array of export targets | 71 + 72 + #### `ExportTarget` 73 + 74 + | Field | Type | Required | Description | 75 + |-------|------|----------|-------------| 76 + | `outputDir` | `string` | Yes | Directory to write output files | 77 + | `filename` | `(data: TemplateData) => string` | Yes | Function to generate filename | 78 + | `includeTags` | `string[]` | No | Only include documents with ANY of these tags | 79 + | `excludeTags` | `string[]` | No | Exclude documents with ANY of these tags | 80 + | `contentTemplate` | `string` | No | Path to Handlebars template file | 81 + | `content` | `(data: TemplateData) => string` | No | Function to generate content (overrides `contentTemplate`) | 82 + 83 + ### Template Data 84 + 85 + Both `filename` and `content` functions receive a `TemplateData` object: 86 + 87 + ```typescript 88 + interface TemplateData { 89 + title: string; 90 + path?: string; 91 + description?: string; 92 + content: string; // Markdown content 93 + tags: string[]; 94 + publishedAt?: string; // ISO 8601 date 95 + updatedAt?: string; // ISO 8601 date 96 + publication: { 97 + name: string; 98 + url: string; 99 + description?: string; 100 + }; 101 + } 102 + ``` 103 + 104 + ### Tag Filtering 105 + 106 + - **includeTags**: Only documents with at least one matching tag are included 107 + - **excludeTags**: Documents with any matching tag are excluded 108 + - When neither is specified, documents tagged "draft" are excluded by default 109 + 110 + ### Handlebars Templates 111 + 112 + Content templates use [Handlebars](https://handlebarsjs.com/) with these custom helpers: 113 + 114 + | Helper | Usage | Description | 115 + |--------|-------|-------------| 116 + | `slug` | `{{slug text}}` | Convert text to URL-safe slug | 117 + | `dateFormat` | `{{dateFormat date "YYYY-MM-DD"}}` | Format date (supports YYYY, MM, DD) | 118 + | `default` | `{{default value fallback}}` | Use fallback if value is empty | 119 + 120 + Example template (`templates/post.hbs`): 121 + 122 + ```handlebars 123 + --- 124 + title: "{{title}}" 125 + date: {{publishedAt}} 126 + slug: {{slug (default path title)}} 127 + {{#if tags.length}} 128 + tags: 129 + {{#each tags}} 130 + - {{this}} 131 + {{/each}} 132 + {{/if}} 133 + --- 134 + 135 + {{content}} 136 + ``` 137 + 138 + ### Using the Content Function 139 + 140 + For full control, use a `content` function instead of a template: 141 + 142 + ```typescript 143 + { 144 + outputDir: "./content", 145 + filename: (data) => `${slugify(data.title)}.md`, 146 + content: (data) => { 147 + return [ 148 + "---", 149 + `title: "${data.title}"`, 150 + `date: ${data.publishedAt}`, 151 + "---", 152 + "", 153 + data.content, 154 + ].join("\n"); 155 + }, 156 + } 157 + ``` 158 + 159 + ### Multiple Export Targets 160 + 161 + Export the same publication to different locations with different filters: 162 + 163 + ```typescript 164 + export default { 165 + publicationUri: "at://did:plc:xyz/site.standard.publication/rkey", 166 + exports: [ 167 + { 168 + outputDir: "./content/notes", 169 + includeTags: ["note"], 170 + filename: (data) => `${slugify(data.title)}.md`, 171 + }, 172 + { 173 + outputDir: "./content/posts", 174 + includeTags: ["post"], 175 + excludeTags: ["draft"], 176 + filename: (data) => `${data.publishedAt?.slice(0, 10)}_${slugify(data.title)}.md`, 177 + contentTemplate: "./templates/post.hbs", 178 + }, 179 + ], 180 + } as ExportConfig; 181 + ``` 182 + 183 + ## Core Library 184 + 185 + Use `@sitebase/core` directly in your own scripts: 186 + 187 + ```typescript 188 + import { exportPublication, slugify, createHandlebars } from "@sitebase/core"; 189 + 190 + const result = await exportPublication({ 191 + publicationUri: "at://did:plc:xyz/site.standard.publication/rkey", 192 + outputDir: "./output", 193 + filename: (data) => `${slugify(data.title)}.md`, 194 + }); 195 + 196 + console.log(`Wrote ${result.filesWritten.length} files`); 197 + ``` 198 + 199 + ### Exports 200 + 201 + - `exportPublication(options)` - Export a publication to markdown files 202 + - `exportFromConfig(config, baseDir)` - Export using a config object 203 + - `findConfigFile(dir)` - Find `sitebase.config.{ts,js}` in directory 204 + - `loadExportConfig(path)` - Load and validate a config file 205 + - `slugify(text)` - Convert text to URL-safe slug 206 + - `createHandlebars()` - Create Handlebars instance with helpers registered 207 + - `generateContent(hbs, template, data)` - Render a Handlebars template 208 + 209 + ## Web UI 210 + 211 + The web package provides a management interface for publications and documents with ATProto OAuth authentication. 10 212 11 213 ```bash 12 - bun run index.ts 214 + cd packages/web 215 + bun run dev 13 216 ``` 14 217 15 - This project was created using `bun init` in bun v1.3.5. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. 218 + See `packages/web/CLAUDE.md` for web package details. 219 + 220 + ## License 221 + 222 + MIT
+126 -1
packages/cli/src/index.ts
··· 1 1 #!/usr/bin/env bun 2 - import { dirname, resolve } from "node:path"; 2 + import { access, mkdir, writeFile } from "node:fs/promises"; 3 + import { dirname, join, resolve } from "node:path"; 3 4 import { Command } from "commander"; 4 5 import { 5 6 exportFromConfig, ··· 68 69 } 69 70 } 70 71 } 72 + } catch (error) { 73 + console.error( 74 + `Error: ${error instanceof Error ? error.message : String(error)}`, 75 + ); 76 + process.exit(1); 77 + } 78 + }); 79 + 80 + program 81 + .command("init") 82 + .description("Create a sitebase.config.ts file with starter configuration") 83 + .action(async () => { 84 + const cwd = process.cwd(); 85 + const configPath = join(cwd, "sitebase.config.ts"); 86 + const templatesDir = join(cwd, "templates"); 87 + const templatePath = join(templatesDir, "post.hbs"); 88 + 89 + // Check if config already exists 90 + try { 91 + await access(configPath); 92 + console.error("Error: sitebase.config.ts already exists"); 93 + process.exit(1); 94 + } catch { 95 + // File doesn't exist, we can proceed 96 + } 97 + 98 + // Config file template with comments 99 + const configContent = `// sitebase.config.ts 100 + import type { ExportConfig } from "@sitebase/core"; 101 + import { slugify } from "@sitebase/core"; 102 + 103 + /** 104 + * Sitebase Export Configuration 105 + * 106 + * This file configures how your AT Protocol publication is exported to markdown files. 107 + * For more information, see: https://github.com/sethetter/sitebase 108 + */ 109 + const config: ExportConfig = { 110 + // The AT URI of your publication (required) 111 + // Format: at://did:plc:xxx/site.standard.publication/rkey 112 + // Find this in your PDS or use the standard.site dashboard 113 + publicationUri: "at://YOUR_DID/site.standard.publication/YOUR_RKEY", 114 + 115 + // Export targets - each entry exports documents to a different location/format 116 + // You can have multiple targets to export the same publication different ways 117 + exports: [ 118 + { 119 + // Directory where markdown files will be written (relative to this config file) 120 + outputDir: "./content", 121 + 122 + // Optional: Only include documents with at least one of these tags 123 + // If not specified, all documents are included (except those matching excludeTags) 124 + // includeTags: ["post", "article"], 125 + 126 + // Optional: Exclude documents with any of these tags 127 + // Defaults to ["draft"] if includeTags is not specified 128 + // excludeTags: ["draft", "private"], 129 + 130 + // Function to generate the filename for each document (required) 131 + // Receives an object with: title, path, publishedAt, updatedAt, tags, content, etc. 132 + filename: (data) => { 133 + // Example: "2024-01-15_my-post-title.md" 134 + const date = data.publishedAt?.slice(0, 10) || "undated"; 135 + return \`\${date}_\${slugify(data.title)}.md\`; 136 + }, 137 + 138 + // Optional: Path to a Handlebars template for content generation 139 + // The template receives the same data object as the filename function 140 + contentTemplate: "./templates/post.hbs", 141 + 142 + // Optional: Function to generate content (overrides contentTemplate if both specified) 143 + // content: (data) => \`--- 144 + // title: "\${data.title}" 145 + // date: \${data.publishedAt} 146 + // --- 147 + // 148 + // \${data.content} 149 + // \`, 150 + }, 151 + ], 152 + }; 153 + 154 + export default config; 155 + `; 156 + 157 + // Handlebars template for posts 158 + const templateContent = `--- 159 + title: "{{title}}" 160 + {{#if description}} 161 + description: "{{description}}" 162 + {{/if}} 163 + {{#if publishedAt}} 164 + date: {{publishedAt}} 165 + {{/if}} 166 + {{#if updatedAt}} 167 + updated: {{updatedAt}} 168 + {{/if}} 169 + {{#if tags.length}} 170 + tags: 171 + {{#each tags}} 172 + - {{this}} 173 + {{/each}} 174 + {{/if}} 175 + --- 176 + 177 + {{{content}}} 178 + `; 179 + 180 + try { 181 + // Write config file 182 + await writeFile(configPath, configContent, "utf-8"); 183 + console.log("Created sitebase.config.ts"); 184 + 185 + // Create templates directory and template file 186 + await mkdir(templatesDir, { recursive: true }); 187 + await writeFile(templatePath, templateContent, "utf-8"); 188 + console.log("Created templates/post.hbs"); 189 + 190 + console.log("\nNext steps:"); 191 + console.log( 192 + " 1. Update publicationUri in sitebase.config.ts with your publication's AT URI", 193 + ); 194 + console.log(" 2. Customize the export targets as needed"); 195 + console.log(" 3. Run: sitebase export"); 71 196 } catch (error) { 72 197 console.error( 73 198 `Error: ${error instanceof Error ? error.message : String(error)}`,
+12 -12
packages/core/package.json
··· 1 1 { 2 - "name": "@sitebase/core", 3 - "type": "module", 4 - "version": "0.0.1", 5 - "exports": { 6 - ".": "./src/index.ts" 7 - }, 8 - "scripts": { 9 - "typecheck": "tsc --noEmit" 10 - }, 11 - "dependencies": { 12 - "handlebars": "^4.7.8" 13 - } 2 + "name": "@sitebase/core", 3 + "type": "module", 4 + "version": "0.0.1", 5 + "exports": { 6 + ".": "./src/index.ts" 7 + }, 8 + "scripts": { 9 + "typecheck": "tsc --noEmit" 10 + }, 11 + "dependencies": { 12 + "handlebars": "^4.7.8" 13 + } 14 14 }
+61
packages/core/src/config.ts
··· 1 + import { access } from "node:fs/promises"; 2 + import { join } from "node:path"; 3 + import { pathToFileURL } from "node:url"; 4 + import type { ExportConfig } from "./types.ts"; 5 + 6 + const CONFIG_FILENAMES = ["sitebase.config.ts", "sitebase.config.js"]; 7 + 8 + /** 9 + * Find config file in directory (auto-discovery) 10 + */ 11 + export async function findConfigFile(dir: string): Promise<string | null> { 12 + for (const filename of CONFIG_FILENAMES) { 13 + const configPath = join(dir, filename); 14 + try { 15 + await access(configPath); 16 + return configPath; 17 + } catch { 18 + // File doesn't exist, try next 19 + } 20 + } 21 + return null; 22 + } 23 + 24 + /** 25 + * Load export config from a JS/TS file 26 + */ 27 + export async function loadExportConfig( 28 + configPath: string, 29 + ): Promise<ExportConfig> { 30 + const configUrl = pathToFileURL(configPath).href; 31 + const module = await import(configUrl); 32 + const config = module.default as ExportConfig; 33 + 34 + // Validate required fields 35 + if (!config.publicationUri) { 36 + throw new Error("Config missing required field: publicationUri"); 37 + } 38 + if ( 39 + !config.exports || 40 + !Array.isArray(config.exports) || 41 + config.exports.length === 0 42 + ) { 43 + throw new Error( 44 + "Config missing required field: exports (must be non-empty array)", 45 + ); 46 + } 47 + 48 + // Validate each export target 49 + for (const [i, target] of config.exports.entries()) { 50 + if (!target.outputDir) { 51 + throw new Error(`Export target ${i} missing required field: outputDir`); 52 + } 53 + if (!target.filename || typeof target.filename !== "function") { 54 + throw new Error( 55 + `Export target ${i} missing required field: filename (must be a function)`, 56 + ); 57 + } 58 + } 59 + 60 + return config; 61 + }
+67 -12
packages/core/src/export.ts
··· 1 - import { mkdir, writeFile } from "node:fs/promises"; 2 - import { join } from "node:path"; 1 + import { mkdir, readFile, writeFile } from "node:fs/promises"; 2 + import { dirname, join, resolve } from "node:path"; 3 3 import { fetchPublicationWithDocuments } from "./atproto.ts"; 4 4 import { 5 5 createHandlebars, 6 6 DEFAULT_CONTENT_TEMPLATE, 7 - DEFAULT_FILENAME_TEMPLATE, 8 7 generateContent, 9 - generateFilename, 10 8 } from "./templates.ts"; 11 9 import type { 12 10 Document, 11 + ExportConfig, 13 12 ExportOptions, 14 13 ExportResult, 15 14 Publication, ··· 97 96 } 98 97 99 98 /** 99 + * Sanitize a filename by removing path traversal and invalid characters 100 + */ 101 + function sanitizeFilename(filename: string): string { 102 + return filename.replace(/\.\./g, "").replace(/[<>:"|?*]/g, ""); 103 + } 104 + 105 + /** 100 106 * Export a publication to markdown files 101 107 */ 102 108 export async function exportPublication( ··· 105 111 const { 106 112 publicationUri, 107 113 outputDir, 108 - contentTemplate = DEFAULT_CONTENT_TEMPLATE, 109 - filenameTemplate = DEFAULT_FILENAME_TEMPLATE, 114 + filename: filenameFunction, 115 + contentTemplate, 116 + contentFunction, 110 117 includeTags, 111 118 excludeTags, 112 119 } = options; ··· 136 143 // Track filenames to detect conflicts 137 144 const usedFilenames = new Set<string>(); 138 145 139 - // Set up Handlebars 146 + // Set up Handlebars (only needed if using content template) 140 147 const hbs = createHandlebars(); 141 148 142 149 // Process each document ··· 145 152 146 153 const data = buildTemplateData(doc, publication); 147 154 148 - // Generate filename 149 - const filename = generateFilename(hbs, filenameTemplate, data); 155 + // Generate filename using function 156 + let filename = filenameFunction(data); 157 + filename = sanitizeFilename(filename); 150 158 151 159 if (!filename) { 152 160 result.warnings.push( ··· 166 174 } 167 175 usedFilenames.add(filename); 168 176 169 - // Generate content 170 - const content = generateContent(hbs, contentTemplate, data); 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 + } 171 188 172 - // Write file 189 + // Write file (creating subdirectories if needed) 173 190 const filePath = join(outputDir, filename); 191 + const fileDir = dirname(filePath); 192 + await mkdir(fileDir, { recursive: true }); 174 193 await writeFile(filePath, content, "utf-8"); 175 194 result.filesWritten.push(filePath); 176 195 } 177 196 178 197 return result; 179 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 + */ 205 + export 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 + }
+11 -5
packages/core/src/index.ts
··· 1 - // Main export function 2 - export { exportPublication } from "./export.ts"; 1 + // Main export functions 2 + export { exportFromConfig, exportPublication } from "./export.ts"; 3 3 4 - // Templates 4 + // Config utilities 5 + export { findConfigFile, loadExportConfig } from "./config.ts"; 6 + 7 + // Templates and helpers 5 8 export { 6 9 DEFAULT_CONTENT_TEMPLATE, 7 - DEFAULT_FILENAME_TEMPLATE, 8 10 createHandlebars, 9 11 generateContent, 10 - generateFilename, 11 12 renderTemplate, 13 + slugify, 12 14 } from "./templates.ts"; 13 15 14 16 // AT Protocol utilities ··· 22 24 23 25 // Types 24 26 export type { 27 + ContentFunction, 25 28 Document, 29 + ExportConfig, 26 30 ExportOptions, 27 31 ExportResult, 32 + ExportTarget, 33 + FilenameFunction, 28 34 Publication, 29 35 TemplateData, 30 36 } from "./types.ts";
+1 -1
packages/core/src/templates.ts
··· 26 26 /** 27 27 * Convert a string to a URL-safe slug 28 28 */ 29 - function slugify(text: string): string { 29 + export function slugify(text: string): string { 30 30 return text 31 31 .toString() 32 32 .toLowerCase()
+43 -3
packages/core/src/types.ts
··· 1 + /** 2 + * Function to generate a filename from template data 3 + */ 4 + export type FilenameFunction = (data: TemplateData) => string; 5 + 6 + /** 7 + * Function to generate file content from template data 8 + */ 9 + export type ContentFunction = (data: TemplateData) => string; 10 + 1 11 /** 2 12 * Options for exporting a publication to markdown files 3 13 */ ··· 6 16 publicationUri: string; 7 17 /** Directory to write output files */ 8 18 outputDir: string; 9 - /** Handlebars template for file content (uses default if not provided) */ 19 + /** Only include documents with ANY of these tags */ 20 + includeTags?: string[]; 21 + /** Exclude documents with ANY of these tags */ 22 + excludeTags?: string[]; 23 + /** Function to generate filename (required) */ 24 + filename: FilenameFunction; 25 + /** Handlebars template string for file content */ 10 26 contentTemplate?: string; 11 - /** Handlebars template for filename (uses default if not provided) */ 12 - filenameTemplate?: string; 27 + /** Function to generate content (takes precedence over contentTemplate) */ 28 + contentFunction?: ContentFunction; 29 + } 30 + 31 + /** 32 + * Single export target configuration 33 + */ 34 + export interface ExportTarget { 35 + /** Directory to write output files */ 36 + outputDir: string; 13 37 /** Only include documents with ANY of these tags */ 14 38 includeTags?: string[]; 15 39 /** Exclude documents with ANY of these tags */ 16 40 excludeTags?: string[]; 41 + /** Function to generate filename (required) */ 42 + filename: FilenameFunction; 43 + /** Path to Handlebars template file for content */ 44 + contentTemplate?: string; 45 + /** Function to generate content (takes precedence over contentTemplate) */ 46 + content?: ContentFunction; 47 + } 48 + 49 + /** 50 + * Configuration for exporting a publication (config file format) 51 + */ 52 + export interface ExportConfig { 53 + /** AT URI of the publication */ 54 + publicationUri: string; 55 + /** Export targets */ 56 + exports: ExportTarget[]; 17 57 } 18 58 19 59 /**