···11# sitebase
2233-To install dependencies:
33+Export content from [standard.site](https://standard.site) publications to markdown files.
44+55+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.
66+77+## Installation
4859```bash
610bun install
711```
81299-To run:
1313+## Packages
1414+1515+This is a monorepo with three packages:
1616+1717+- **@sitebase/core** - Library for exporting publications and template utilities
1818+- **@sitebase/cli** - Command-line interface for running exports
1919+- **@sitebase/web** - Web UI for managing publications and documents
2020+2121+## CLI Usage
2222+2323+```bash
2424+# Auto-discover sitebase.config.ts in current directory
2525+sitebase export
2626+2727+# Specify a config file
2828+sitebase export --config ./my-config.ts
2929+```
3030+3131+The CLI looks for `sitebase.config.ts` or `sitebase.config.js` in the current directory. Use `-c` or `--config` to specify a different path.
3232+3333+## Configuration
3434+3535+Create a `sitebase.config.ts` file in your project root:
3636+3737+```typescript
3838+import type { ExportConfig } from "@sitebase/core";
3939+import { slugify } from "@sitebase/core";
4040+4141+const config: ExportConfig = {
4242+ // AT URI of your publication
4343+ publicationUri: "at://did:plc:xyz/site.standard.publication/rkey",
4444+4545+ // One or more export targets
4646+ exports: [
4747+ {
4848+ outputDir: "./content/posts",
4949+ includeTags: ["post"],
5050+ excludeTags: ["draft"],
5151+ filename: (data) => {
5252+ const date = data.publishedAt?.slice(0, 10) || "undated";
5353+ return `${date}_${slugify(data.title)}.md`;
5454+ },
5555+ contentTemplate: "./templates/post.hbs",
5656+ },
5757+ ],
5858+};
5959+6060+export default config;
6161+```
6262+6363+### Config Reference
6464+6565+#### `ExportConfig`
6666+6767+| Field | Type | Description |
6868+|-------|------|-------------|
6969+| `publicationUri` | `string` | AT URI of the publication (`at://did:plc:.../site.standard.publication/rkey`) |
7070+| `exports` | `ExportTarget[]` | Array of export targets |
7171+7272+#### `ExportTarget`
7373+7474+| Field | Type | Required | Description |
7575+|-------|------|----------|-------------|
7676+| `outputDir` | `string` | Yes | Directory to write output files |
7777+| `filename` | `(data: TemplateData) => string` | Yes | Function to generate filename |
7878+| `includeTags` | `string[]` | No | Only include documents with ANY of these tags |
7979+| `excludeTags` | `string[]` | No | Exclude documents with ANY of these tags |
8080+| `contentTemplate` | `string` | No | Path to Handlebars template file |
8181+| `content` | `(data: TemplateData) => string` | No | Function to generate content (overrides `contentTemplate`) |
8282+8383+### Template Data
8484+8585+Both `filename` and `content` functions receive a `TemplateData` object:
8686+8787+```typescript
8888+interface TemplateData {
8989+ title: string;
9090+ path?: string;
9191+ description?: string;
9292+ content: string; // Markdown content
9393+ tags: string[];
9494+ publishedAt?: string; // ISO 8601 date
9595+ updatedAt?: string; // ISO 8601 date
9696+ publication: {
9797+ name: string;
9898+ url: string;
9999+ description?: string;
100100+ };
101101+}
102102+```
103103+104104+### Tag Filtering
105105+106106+- **includeTags**: Only documents with at least one matching tag are included
107107+- **excludeTags**: Documents with any matching tag are excluded
108108+- When neither is specified, documents tagged "draft" are excluded by default
109109+110110+### Handlebars Templates
111111+112112+Content templates use [Handlebars](https://handlebarsjs.com/) with these custom helpers:
113113+114114+| Helper | Usage | Description |
115115+|--------|-------|-------------|
116116+| `slug` | `{{slug text}}` | Convert text to URL-safe slug |
117117+| `dateFormat` | `{{dateFormat date "YYYY-MM-DD"}}` | Format date (supports YYYY, MM, DD) |
118118+| `default` | `{{default value fallback}}` | Use fallback if value is empty |
119119+120120+Example template (`templates/post.hbs`):
121121+122122+```handlebars
123123+---
124124+title: "{{title}}"
125125+date: {{publishedAt}}
126126+slug: {{slug (default path title)}}
127127+{{#if tags.length}}
128128+tags:
129129+{{#each tags}}
130130+ - {{this}}
131131+{{/each}}
132132+{{/if}}
133133+---
134134+135135+{{content}}
136136+```
137137+138138+### Using the Content Function
139139+140140+For full control, use a `content` function instead of a template:
141141+142142+```typescript
143143+{
144144+ outputDir: "./content",
145145+ filename: (data) => `${slugify(data.title)}.md`,
146146+ content: (data) => {
147147+ return [
148148+ "---",
149149+ `title: "${data.title}"`,
150150+ `date: ${data.publishedAt}`,
151151+ "---",
152152+ "",
153153+ data.content,
154154+ ].join("\n");
155155+ },
156156+}
157157+```
158158+159159+### Multiple Export Targets
160160+161161+Export the same publication to different locations with different filters:
162162+163163+```typescript
164164+export default {
165165+ publicationUri: "at://did:plc:xyz/site.standard.publication/rkey",
166166+ exports: [
167167+ {
168168+ outputDir: "./content/notes",
169169+ includeTags: ["note"],
170170+ filename: (data) => `${slugify(data.title)}.md`,
171171+ },
172172+ {
173173+ outputDir: "./content/posts",
174174+ includeTags: ["post"],
175175+ excludeTags: ["draft"],
176176+ filename: (data) => `${data.publishedAt?.slice(0, 10)}_${slugify(data.title)}.md`,
177177+ contentTemplate: "./templates/post.hbs",
178178+ },
179179+ ],
180180+} as ExportConfig;
181181+```
182182+183183+## Core Library
184184+185185+Use `@sitebase/core` directly in your own scripts:
186186+187187+```typescript
188188+import { exportPublication, slugify, createHandlebars } from "@sitebase/core";
189189+190190+const result = await exportPublication({
191191+ publicationUri: "at://did:plc:xyz/site.standard.publication/rkey",
192192+ outputDir: "./output",
193193+ filename: (data) => `${slugify(data.title)}.md`,
194194+});
195195+196196+console.log(`Wrote ${result.filesWritten.length} files`);
197197+```
198198+199199+### Exports
200200+201201+- `exportPublication(options)` - Export a publication to markdown files
202202+- `exportFromConfig(config, baseDir)` - Export using a config object
203203+- `findConfigFile(dir)` - Find `sitebase.config.{ts,js}` in directory
204204+- `loadExportConfig(path)` - Load and validate a config file
205205+- `slugify(text)` - Convert text to URL-safe slug
206206+- `createHandlebars()` - Create Handlebars instance with helpers registered
207207+- `generateContent(hbs, template, data)` - Render a Handlebars template
208208+209209+## Web UI
210210+211211+The web package provides a management interface for publications and documents with ATProto OAuth authentication.
1021211213```bash
1212-bun run index.ts
214214+cd packages/web
215215+bun run dev
13216```
142171515-This project was created using `bun init` in bun v1.3.5. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
218218+See `packages/web/CLAUDE.md` for web package details.
219219+220220+## License
221221+222222+MIT
+126-1
packages/cli/src/index.ts
···11#!/usr/bin/env bun
22-import { dirname, resolve } from "node:path";
22+import { access, mkdir, writeFile } from "node:fs/promises";
33+import { dirname, join, resolve } from "node:path";
34import { Command } from "commander";
45import {
56 exportFromConfig,
···6869 }
6970 }
7071 }
7272+ } catch (error) {
7373+ console.error(
7474+ `Error: ${error instanceof Error ? error.message : String(error)}`,
7575+ );
7676+ process.exit(1);
7777+ }
7878+ });
7979+8080+program
8181+ .command("init")
8282+ .description("Create a sitebase.config.ts file with starter configuration")
8383+ .action(async () => {
8484+ const cwd = process.cwd();
8585+ const configPath = join(cwd, "sitebase.config.ts");
8686+ const templatesDir = join(cwd, "templates");
8787+ const templatePath = join(templatesDir, "post.hbs");
8888+8989+ // Check if config already exists
9090+ try {
9191+ await access(configPath);
9292+ console.error("Error: sitebase.config.ts already exists");
9393+ process.exit(1);
9494+ } catch {
9595+ // File doesn't exist, we can proceed
9696+ }
9797+9898+ // Config file template with comments
9999+ const configContent = `// sitebase.config.ts
100100+import type { ExportConfig } from "@sitebase/core";
101101+import { slugify } from "@sitebase/core";
102102+103103+/**
104104+ * Sitebase Export Configuration
105105+ *
106106+ * This file configures how your AT Protocol publication is exported to markdown files.
107107+ * For more information, see: https://github.com/sethetter/sitebase
108108+ */
109109+const config: ExportConfig = {
110110+ // The AT URI of your publication (required)
111111+ // Format: at://did:plc:xxx/site.standard.publication/rkey
112112+ // Find this in your PDS or use the standard.site dashboard
113113+ publicationUri: "at://YOUR_DID/site.standard.publication/YOUR_RKEY",
114114+115115+ // Export targets - each entry exports documents to a different location/format
116116+ // You can have multiple targets to export the same publication different ways
117117+ exports: [
118118+ {
119119+ // Directory where markdown files will be written (relative to this config file)
120120+ outputDir: "./content",
121121+122122+ // Optional: Only include documents with at least one of these tags
123123+ // If not specified, all documents are included (except those matching excludeTags)
124124+ // includeTags: ["post", "article"],
125125+126126+ // Optional: Exclude documents with any of these tags
127127+ // Defaults to ["draft"] if includeTags is not specified
128128+ // excludeTags: ["draft", "private"],
129129+130130+ // Function to generate the filename for each document (required)
131131+ // Receives an object with: title, path, publishedAt, updatedAt, tags, content, etc.
132132+ filename: (data) => {
133133+ // Example: "2024-01-15_my-post-title.md"
134134+ const date = data.publishedAt?.slice(0, 10) || "undated";
135135+ return \`\${date}_\${slugify(data.title)}.md\`;
136136+ },
137137+138138+ // Optional: Path to a Handlebars template for content generation
139139+ // The template receives the same data object as the filename function
140140+ contentTemplate: "./templates/post.hbs",
141141+142142+ // Optional: Function to generate content (overrides contentTemplate if both specified)
143143+ // content: (data) => \`---
144144+ // title: "\${data.title}"
145145+ // date: \${data.publishedAt}
146146+ // ---
147147+ //
148148+ // \${data.content}
149149+ // \`,
150150+ },
151151+ ],
152152+};
153153+154154+export default config;
155155+`;
156156+157157+ // Handlebars template for posts
158158+ const templateContent = `---
159159+title: "{{title}}"
160160+{{#if description}}
161161+description: "{{description}}"
162162+{{/if}}
163163+{{#if publishedAt}}
164164+date: {{publishedAt}}
165165+{{/if}}
166166+{{#if updatedAt}}
167167+updated: {{updatedAt}}
168168+{{/if}}
169169+{{#if tags.length}}
170170+tags:
171171+{{#each tags}}
172172+ - {{this}}
173173+{{/each}}
174174+{{/if}}
175175+---
176176+177177+{{{content}}}
178178+`;
179179+180180+ try {
181181+ // Write config file
182182+ await writeFile(configPath, configContent, "utf-8");
183183+ console.log("Created sitebase.config.ts");
184184+185185+ // Create templates directory and template file
186186+ await mkdir(templatesDir, { recursive: true });
187187+ await writeFile(templatePath, templateContent, "utf-8");
188188+ console.log("Created templates/post.hbs");
189189+190190+ console.log("\nNext steps:");
191191+ console.log(
192192+ " 1. Update publicationUri in sitebase.config.ts with your publication's AT URI",
193193+ );
194194+ console.log(" 2. Customize the export targets as needed");
195195+ console.log(" 3. Run: sitebase export");
71196 } catch (error) {
72197 console.error(
73198 `Error: ${error instanceof Error ? error.message : String(error)}`,
···11+import { access } from "node:fs/promises";
22+import { join } from "node:path";
33+import { pathToFileURL } from "node:url";
44+import type { ExportConfig } from "./types.ts";
55+66+const CONFIG_FILENAMES = ["sitebase.config.ts", "sitebase.config.js"];
77+88+/**
99+ * Find config file in directory (auto-discovery)
1010+ */
1111+export async function findConfigFile(dir: string): Promise<string | null> {
1212+ for (const filename of CONFIG_FILENAMES) {
1313+ const configPath = join(dir, filename);
1414+ try {
1515+ await access(configPath);
1616+ return configPath;
1717+ } catch {
1818+ // File doesn't exist, try next
1919+ }
2020+ }
2121+ return null;
2222+}
2323+2424+/**
2525+ * Load export config from a JS/TS file
2626+ */
2727+export async function loadExportConfig(
2828+ configPath: string,
2929+): Promise<ExportConfig> {
3030+ const configUrl = pathToFileURL(configPath).href;
3131+ const module = await import(configUrl);
3232+ const config = module.default as ExportConfig;
3333+3434+ // Validate required fields
3535+ if (!config.publicationUri) {
3636+ throw new Error("Config missing required field: publicationUri");
3737+ }
3838+ if (
3939+ !config.exports ||
4040+ !Array.isArray(config.exports) ||
4141+ config.exports.length === 0
4242+ ) {
4343+ throw new Error(
4444+ "Config missing required field: exports (must be non-empty array)",
4545+ );
4646+ }
4747+4848+ // Validate each export target
4949+ for (const [i, target] of config.exports.entries()) {
5050+ if (!target.outputDir) {
5151+ throw new Error(`Export target ${i} missing required field: outputDir`);
5252+ }
5353+ if (!target.filename || typeof target.filename !== "function") {
5454+ throw new Error(
5555+ `Export target ${i} missing required field: filename (must be a function)`,
5656+ );
5757+ }
5858+ }
5959+6060+ return config;
6161+}
+67-12
packages/core/src/export.ts
···11-import { mkdir, writeFile } from "node:fs/promises";
22-import { join } from "node:path";
11+import { mkdir, readFile, writeFile } from "node:fs/promises";
22+import { dirname, join, resolve } from "node:path";
33import { fetchPublicationWithDocuments } from "./atproto.ts";
44import {
55 createHandlebars,
66 DEFAULT_CONTENT_TEMPLATE,
77- DEFAULT_FILENAME_TEMPLATE,
87 generateContent,
99- generateFilename,
108} from "./templates.ts";
119import type {
1210 Document,
1111+ ExportConfig,
1312 ExportOptions,
1413 ExportResult,
1514 Publication,
···9796}
98979998/**
9999+ * Sanitize a filename by removing path traversal and invalid characters
100100+ */
101101+function sanitizeFilename(filename: string): string {
102102+ return filename.replace(/\.\./g, "").replace(/[<>:"|?*]/g, "");
103103+}
104104+105105+/**
100106 * Export a publication to markdown files
101107 */
102108export async function exportPublication(
···105111 const {
106112 publicationUri,
107113 outputDir,
108108- contentTemplate = DEFAULT_CONTENT_TEMPLATE,
109109- filenameTemplate = DEFAULT_FILENAME_TEMPLATE,
114114+ filename: filenameFunction,
115115+ contentTemplate,
116116+ contentFunction,
110117 includeTags,
111118 excludeTags,
112119 } = options;
···136143 // Track filenames to detect conflicts
137144 const usedFilenames = new Set<string>();
138145139139- // Set up Handlebars
146146+ // Set up Handlebars (only needed if using content template)
140147 const hbs = createHandlebars();
141148142149 // Process each document
···145152146153 const data = buildTemplateData(doc, publication);
147154148148- // Generate filename
149149- const filename = generateFilename(hbs, filenameTemplate, data);
155155+ // Generate filename using function
156156+ let filename = filenameFunction(data);
157157+ filename = sanitizeFilename(filename);
150158151159 if (!filename) {
152160 result.warnings.push(
···166174 }
167175 usedFilenames.add(filename);
168176169169- // Generate content
170170- const content = generateContent(hbs, contentTemplate, data);
177177+ // Generate content using function or template
178178+ let content: string;
179179+ if (contentFunction) {
180180+ content = contentFunction(data);
181181+ } else {
182182+ content = generateContent(
183183+ hbs,
184184+ contentTemplate || DEFAULT_CONTENT_TEMPLATE,
185185+ data,
186186+ );
187187+ }
171188172172- // Write file
189189+ // Write file (creating subdirectories if needed)
173190 const filePath = join(outputDir, filename);
191191+ const fileDir = dirname(filePath);
192192+ await mkdir(fileDir, { recursive: true });
174193 await writeFile(filePath, content, "utf-8");
175194 result.filesWritten.push(filePath);
176195 }
177196178197 return result;
179198}
199199+200200+/**
201201+ * Export a publication using a config with multiple export targets
202202+ * @param config - The export configuration
203203+ * @param configDir - Directory containing the config file (for resolving relative paths)
204204+ */
205205+export async function exportFromConfig(
206206+ config: ExportConfig,
207207+ configDir: string,
208208+): Promise<ExportResult[]> {
209209+ const results: ExportResult[] = [];
210210+211211+ for (const target of config.exports) {
212212+ // Load content template from file if specified
213213+ let contentTemplate: string | undefined;
214214+ if (target.contentTemplate) {
215215+ const templatePath = resolve(configDir, target.contentTemplate);
216216+ contentTemplate = await readFile(templatePath, "utf-8");
217217+ }
218218+219219+ const options: ExportOptions = {
220220+ publicationUri: config.publicationUri,
221221+ outputDir: resolve(configDir, target.outputDir),
222222+ includeTags: target.includeTags,
223223+ excludeTags: target.excludeTags,
224224+ filename: target.filename,
225225+ contentTemplate,
226226+ contentFunction: target.content,
227227+ };
228228+229229+ const result = await exportPublication(options);
230230+ results.push(result);
231231+ }
232232+233233+ return results;
234234+}
+11-5
packages/core/src/index.ts
···11-// Main export function
22-export { exportPublication } from "./export.ts";
11+// Main export functions
22+export { exportFromConfig, exportPublication } from "./export.ts";
3344-// Templates
44+// Config utilities
55+export { findConfigFile, loadExportConfig } from "./config.ts";
66+77+// Templates and helpers
58export {
69 DEFAULT_CONTENT_TEMPLATE,
77- DEFAULT_FILENAME_TEMPLATE,
810 createHandlebars,
911 generateContent,
1010- generateFilename,
1112 renderTemplate,
1313+ slugify,
1214} from "./templates.ts";
13151416// AT Protocol utilities
···22242325// Types
2426export type {
2727+ ContentFunction,
2528 Document,
2929+ ExportConfig,
2630 ExportOptions,
2731 ExportResult,
3232+ ExportTarget,
3333+ FilenameFunction,
2834 Publication,
2935 TemplateData,
3036} from "./types.ts";
+1-1
packages/core/src/templates.ts
···2626/**
2727 * Convert a string to a URL-safe slug
2828 */
2929-function slugify(text: string): string {
2929+export function slugify(text: string): string {
3030 return text
3131 .toString()
3232 .toLowerCase()
+43-3
packages/core/src/types.ts
···11+/**
22+ * Function to generate a filename from template data
33+ */
44+export type FilenameFunction = (data: TemplateData) => string;
55+66+/**
77+ * Function to generate file content from template data
88+ */
99+export type ContentFunction = (data: TemplateData) => string;
1010+111/**
212 * Options for exporting a publication to markdown files
313 */
···616 publicationUri: string;
717 /** Directory to write output files */
818 outputDir: string;
99- /** Handlebars template for file content (uses default if not provided) */
1919+ /** Only include documents with ANY of these tags */
2020+ includeTags?: string[];
2121+ /** Exclude documents with ANY of these tags */
2222+ excludeTags?: string[];
2323+ /** Function to generate filename (required) */
2424+ filename: FilenameFunction;
2525+ /** Handlebars template string for file content */
1026 contentTemplate?: string;
1111- /** Handlebars template for filename (uses default if not provided) */
1212- filenameTemplate?: string;
2727+ /** Function to generate content (takes precedence over contentTemplate) */
2828+ contentFunction?: ContentFunction;
2929+}
3030+3131+/**
3232+ * Single export target configuration
3333+ */
3434+export interface ExportTarget {
3535+ /** Directory to write output files */
3636+ outputDir: string;
1337 /** Only include documents with ANY of these tags */
1438 includeTags?: string[];
1539 /** Exclude documents with ANY of these tags */
1640 excludeTags?: string[];
4141+ /** Function to generate filename (required) */
4242+ filename: FilenameFunction;
4343+ /** Path to Handlebars template file for content */
4444+ contentTemplate?: string;
4545+ /** Function to generate content (takes precedence over contentTemplate) */
4646+ content?: ContentFunction;
4747+}
4848+4949+/**
5050+ * Configuration for exporting a publication (config file format)
5151+ */
5252+export interface ExportConfig {
5353+ /** AT URI of the publication */
5454+ publicationUri: string;
5555+ /** Export targets */
5656+ exports: ExportTarget[];
1757}
18581959/**