tangled
alpha
login
or
join now
stevedylan.dev
/
sequoia
36
fork
atom
A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
36
fork
atom
overview
issues
5
pulls
1
pipelines
chore: cleaned up commands and libs
stevedylan.dev
1 month ago
544483ba
88658187
+100
-210
8 changed files
expand all
collapse all
unified
split
packages
cli
src
commands
init.ts
publish.ts
sync.ts
lib
atproto.ts
config.ts
markdown.ts
tid.ts
types.ts
+49
-78
packages/cli/src/commands/init.ts
···
53
53
},
54
54
);
55
55
56
56
-
const hasImages = await consola.prompt(
57
57
-
"Do you have a separate directory for cover images?",
56
56
+
const imagesDir = await consola.prompt(
57
57
+
"Cover images directory (where cover/og images are stored, leave empty to skip):",
58
58
{
59
59
-
type: "confirm",
60
60
-
initial: false,
59
59
+
type: "text",
60
60
+
placeholder: "./public/images",
61
61
},
62
62
);
63
63
-
64
64
-
let imagesDir: string | undefined;
65
65
-
if (hasImages) {
66
66
-
const imgDir = await consola.prompt(
67
67
-
"Cover images directory (where cover/og images are stored):",
68
68
-
{
69
69
-
type: "text",
70
70
-
placeholder: "./public/images",
71
71
-
},
72
72
-
);
73
73
-
imagesDir = imgDir as string;
74
74
-
}
75
63
76
64
// Public/static directory for .well-known files
77
65
const publicDir = await consola.prompt(
···
104
92
);
105
93
106
94
// Frontmatter mapping configuration
107
107
-
const customFrontmatter = await consola.prompt(
108
108
-
"Do you use custom frontmatter field names?",
109
109
-
{
110
110
-
type: "confirm",
111
111
-
initial: false,
112
112
-
},
95
95
+
consola.info(
96
96
+
"Configure your frontmatter field mappings (press Enter to use defaults):",
113
97
);
114
98
115
115
-
let frontmatterMapping: FrontmatterMapping | undefined;
116
116
-
if (customFrontmatter) {
117
117
-
consola.info(
118
118
-
"Configure your frontmatter field mappings (press Enter to use defaults):",
119
119
-
);
99
99
+
const titleField = await consola.prompt("Field name for title:", {
100
100
+
type: "text",
101
101
+
default: "title",
102
102
+
placeholder: "title",
103
103
+
});
120
104
121
121
-
const titleField = await consola.prompt("Field name for title:", {
122
122
-
type: "text",
123
123
-
default: "title",
124
124
-
placeholder: "title",
125
125
-
});
105
105
+
const descField = await consola.prompt("Field name for description:", {
106
106
+
type: "text",
107
107
+
default: "description",
108
108
+
placeholder: "description",
109
109
+
});
126
110
127
127
-
const descField = await consola.prompt("Field name for description:", {
128
128
-
type: "text",
129
129
-
default: "description",
130
130
-
placeholder: "description",
131
131
-
});
111
111
+
const dateField = await consola.prompt("Field name for publish date:", {
112
112
+
type: "text",
113
113
+
default: "publishDate",
114
114
+
placeholder: "publishDate, pubDate, date, etc.",
115
115
+
});
132
116
133
133
-
const dateField = await consola.prompt("Field name for publish date:", {
134
134
-
type: "text",
135
135
-
default: "publishDate",
136
136
-
placeholder: "publishDate, pubDate, date, etc.",
137
137
-
});
117
117
+
const coverField = await consola.prompt("Field name for cover image:", {
118
118
+
type: "text",
119
119
+
default: "ogImage",
120
120
+
placeholder: "ogImage, coverImage, image, hero, etc.",
121
121
+
});
138
122
139
139
-
const coverField = await consola.prompt("Field name for cover image:", {
140
140
-
type: "text",
141
141
-
default: "ogImage",
142
142
-
placeholder: "ogImage, coverImage, image, hero, etc.",
143
143
-
});
123
123
+
let frontmatterMapping: FrontmatterMapping | undefined = {};
144
124
145
145
-
frontmatterMapping = {};
125
125
+
if (titleField && titleField !== "title") {
126
126
+
frontmatterMapping.title = titleField as string;
127
127
+
}
128
128
+
if (descField && descField !== "description") {
129
129
+
frontmatterMapping.description = descField as string;
130
130
+
}
131
131
+
if (dateField && dateField !== "publishDate") {
132
132
+
frontmatterMapping.publishDate = dateField as string;
133
133
+
}
134
134
+
if (coverField && coverField !== "ogImage") {
135
135
+
frontmatterMapping.coverImage = coverField as string;
136
136
+
}
146
137
147
147
-
if (titleField && titleField !== "title") {
148
148
-
frontmatterMapping.title = titleField as string;
149
149
-
}
150
150
-
if (descField && descField !== "description") {
151
151
-
frontmatterMapping.description = descField as string;
152
152
-
}
153
153
-
if (dateField && dateField !== "publishDate") {
154
154
-
frontmatterMapping.publishDate = dateField as string;
155
155
-
}
156
156
-
if (coverField && coverField !== "ogImage") {
157
157
-
frontmatterMapping.coverImage = coverField as string;
158
158
-
}
159
159
-
160
160
-
// Only keep frontmatterMapping if it has any custom fields
161
161
-
if (Object.keys(frontmatterMapping).length === 0) {
162
162
-
frontmatterMapping = undefined;
163
163
-
}
138
138
+
// Only keep frontmatterMapping if it has any custom fields
139
139
+
if (Object.keys(frontmatterMapping).length === 0) {
140
140
+
frontmatterMapping = undefined;
164
141
}
165
142
166
143
// Publication setup
···
214
191
},
215
192
);
216
193
217
217
-
const hasIcon = await consola.prompt("Add an icon image?", {
218
218
-
type: "confirm",
219
219
-
initial: false,
220
220
-
});
221
221
-
222
222
-
let iconPath: string | undefined;
223
223
-
if (hasIcon) {
224
224
-
const icon = await consola.prompt("Icon image path:", {
194
194
+
const iconPath = await consola.prompt(
195
195
+
"Icon image path (leave empty to skip):",
196
196
+
{
225
197
type: "text",
226
198
placeholder: "./icon.png",
227
227
-
});
228
228
-
iconPath = icon as string;
229
229
-
}
199
199
+
},
200
200
+
);
230
201
231
202
const showInDiscover = await consola.prompt("Show in Discover feed?", {
232
203
type: "confirm",
···
239
210
url: siteUrl as string,
240
211
name: pubName as string,
241
212
description: (pubDescription as string) || undefined,
242
242
-
iconPath,
213
213
+
iconPath: (iconPath as string) || undefined,
243
214
showInDiscover,
244
215
});
245
216
consola.success(`Publication created: ${publicationUri}`);
···
267
238
const configContent = generateConfigTemplate({
268
239
siteUrl: siteUrl as string,
269
240
contentDir: contentDir as string,
270
270
-
imagesDir,
241
241
+
imagesDir: imagesDir || undefined,
271
242
publicDir: publicDir as string,
272
243
outputDir: outputDir as string,
273
244
pathPrefix: pathPrefix as string,
+1
-6
packages/cli/src/commands/publish.ts
···
84
84
85
85
// Scan for posts
86
86
consola.start("Scanning for posts...");
87
87
-
const posts = await scanContentDirectory(contentDir, config.include, config.exclude, config.frontmatter);
87
87
+
const posts = await scanContentDirectory(contentDir, config.frontmatter);
88
88
consola.info(`Found ${posts.length} posts`);
89
89
90
90
// Determine which posts need publishing
···
95
95
}> = [];
96
96
97
97
for (const post of posts) {
98
98
-
// Skip hidden posts
99
99
-
if (post.frontmatter.hidden) {
100
100
-
continue;
101
101
-
}
102
102
-
103
98
const contentHash = await getContentHash(post.rawContent);
104
99
const relativeFilePath = path.relative(configDir, post.filePath);
105
100
const postState = state.posts[relativeFilePath];
+1
-1
packages/cli/src/commands/sync.ts
···
86
86
87
87
// Scan local posts
88
88
consola.start("Scanning local content...");
89
89
-
const localPosts = await scanContentDirectory(contentDir, config.include, config.exclude, config.frontmatter);
89
89
+
const localPosts = await scanContentDirectory(contentDir, config.frontmatter);
90
90
consola.info(`Found ${localPosts.length} local posts`);
91
91
92
92
// Build a map of path -> local post for matching
+1
-16
packages/cli/src/lib/atproto.ts
···
1
1
import { AtpAgent } from "@atproto/api";
2
2
import * as path from "path";
3
3
-
import type { Credentials, BlogPost, BlobObject, PublisherConfig, PublicationRecord } from "./types";
4
4
-
import { generateTid } from "./tid";
3
3
+
import type { Credentials, BlogPost, BlobObject, PublisherConfig } from "./types";
5
4
import { stripMarkdownForText } from "./markdown";
6
5
7
6
export async function resolveHandleToPDS(handle: string): Promise<string> {
···
184
183
record.coverImage = coverImage;
185
184
}
186
185
187
187
-
if (config.location) {
188
188
-
record.location = config.location;
189
189
-
}
190
190
-
191
191
-
const rkey = generateTid();
192
192
-
193
186
const response = await agent.com.atproto.repo.createRecord({
194
187
repo: agent.session!.did,
195
188
collection: "site.standard.document",
196
196
-
rkey,
197
189
record,
198
190
});
199
191
···
233
225
234
226
if (coverImage) {
235
227
record.coverImage = coverImage;
236
236
-
}
237
237
-
238
238
-
if (config.location) {
239
239
-
record.location = config.location;
240
228
}
241
229
242
230
await agent.com.atproto.repo.putRecord({
···
342
330
};
343
331
}
344
332
345
345
-
const rkey = generateTid();
346
346
-
347
333
const response = await agent.com.atproto.repo.createRecord({
348
334
repo: agent.session!.did,
349
335
collection: "site.standard.publication",
350
350
-
rkey,
351
336
record,
352
337
});
353
338
-5
packages/cli/src/lib/config.ts
···
66
66
pathPrefix?: string;
67
67
publicationUri: string;
68
68
pdsUrl?: string;
69
69
-
location?: string;
70
69
frontmatter?: FrontmatterMapping;
71
70
}): string {
72
71
const config: Record<string, unknown> = {
···
94
93
95
94
if (options.pdsUrl && options.pdsUrl !== "https://bsky.social") {
96
95
config.pdsUrl = options.pdsUrl;
97
97
-
}
98
98
-
99
99
-
if (options.location) {
100
100
-
config.location = options.location;
101
96
}
102
97
103
98
if (options.frontmatter && Object.keys(options.frontmatter).length > 0) {
+1
-11
packages/cli/src/lib/markdown.ts
···
82
82
const coverField = mapping?.coverImage || "ogImage";
83
83
frontmatter.ogImage = raw[coverField] || raw.ogImage;
84
84
85
85
-
// Hidden mapping
86
86
-
const hiddenField = mapping?.hidden || "hidden";
87
87
-
frontmatter.hidden = raw[hiddenField] || raw.hidden;
88
88
-
89
85
// Tags mapping
90
86
const tagsField = mapping?.tags || "tags";
91
87
frontmatter.tags = raw[tagsField] || raw.tags;
···
113
109
114
110
export async function scanContentDirectory(
115
111
contentDir: string,
116
116
-
include?: string[],
117
117
-
exclude?: string[],
118
112
frontmatterMapping?: FrontmatterMapping
119
113
): Promise<BlogPost[]> {
120
120
-
const patterns = include || ["**/*.md", "**/*.mdx"];
114
114
+
const patterns = ["**/*.md", "**/*.mdx"];
121
115
const posts: BlogPost[] = [];
122
116
123
117
for (const pattern of patterns) {
···
127
121
cwd: contentDir,
128
122
absolute: false,
129
123
})) {
130
130
-
// Check exclusions
131
131
-
if (exclude?.some((ex) => relativePath.includes(ex))) {
132
132
-
continue;
133
133
-
}
134
124
135
125
const filePath = path.join(contentDir, relativePath);
136
126
const file = Bun.file(filePath);
-41
packages/cli/src/lib/tid.ts
···
1
1
-
// TID (Timestamp Identifier) generation per ATProto spec
2
2
-
// Format: base32-sortable encoded, 13 characters
3
3
-
// Structure: 53 bits of timestamp (microseconds since epoch) + 10 bits of clock ID
4
4
-
5
5
-
const S32_CHAR = "234567abcdefghijklmnopqrstuvwxyz";
6
6
-
7
7
-
let lastTimestamp = 0;
8
8
-
let clockId = Math.floor(Math.random() * 1024);
9
9
-
10
10
-
export function generateTid(): string {
11
11
-
// Get current timestamp in microseconds
12
12
-
let timestamp = Date.now() * 1000;
13
13
-
14
14
-
// Ensure monotonically increasing timestamps
15
15
-
if (timestamp <= lastTimestamp) {
16
16
-
timestamp = lastTimestamp + 1;
17
17
-
}
18
18
-
lastTimestamp = timestamp;
19
19
-
20
20
-
// Combine timestamp (53 bits) and clock ID (10 bits)
21
21
-
// TID is a 63-bit integer encoded as 13 base32 characters
22
22
-
const tid = (BigInt(timestamp) << 10n) | BigInt(clockId);
23
23
-
24
24
-
// Convert to base32-sortable
25
25
-
let result = "";
26
26
-
let value = tid;
27
27
-
for (let i = 0; i < 13; i++) {
28
28
-
result = S32_CHAR[Number(value % 32n)] + result;
29
29
-
value = value / 32n;
30
30
-
}
31
31
-
32
32
-
return result;
33
33
-
}
34
34
-
35
35
-
export function isValidTid(tid: string): boolean {
36
36
-
if (tid.length !== 13) return false;
37
37
-
for (const char of tid) {
38
38
-
if (!S32_CHAR.includes(char)) return false;
39
39
-
}
40
40
-
return true;
41
41
-
}
+47
-52
packages/cli/src/lib/types.ts
···
1
1
export interface FrontmatterMapping {
2
2
-
title?: string; // Field name for title (default: "title")
3
3
-
description?: string; // Field name for description (default: "description")
4
4
-
publishDate?: string; // Field name for publish date (default: "publishDate", also checks "pubDate", "date", "createdAt", "created_at")
5
5
-
coverImage?: string; // Field name for cover image (default: "ogImage")
6
6
-
hidden?: string; // Field name for hidden flag (default: "hidden")
7
7
-
tags?: string; // Field name for tags (default: "tags")
2
2
+
title?: string; // Field name for title (default: "title")
3
3
+
description?: string; // Field name for description (default: "description")
4
4
+
publishDate?: string; // Field name for publish date (default: "publishDate", also checks "pubDate", "date", "createdAt", "created_at")
5
5
+
coverImage?: string; // Field name for cover image (default: "ogImage")
6
6
+
tags?: string; // Field name for tags (default: "tags")
8
7
}
9
8
10
9
export interface PublisherConfig {
11
11
-
siteUrl: string;
12
12
-
contentDir: string;
13
13
-
imagesDir?: string; // Directory containing cover images
14
14
-
publicDir?: string; // Static/public folder for .well-known files (default: public)
15
15
-
outputDir?: string; // Built output directory for inject command
16
16
-
pathPrefix?: string; // URL path prefix for posts (default: /posts)
17
17
-
publicationUri: string;
18
18
-
pdsUrl?: string;
19
19
-
location?: string;
20
20
-
include?: string[];
21
21
-
exclude?: string[];
22
22
-
identity?: string; // Which stored identity to use (matches identifier)
23
23
-
frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings
10
10
+
siteUrl: string;
11
11
+
contentDir: string;
12
12
+
imagesDir?: string; // Directory containing cover images
13
13
+
publicDir?: string; // Static/public folder for .well-known files (default: public)
14
14
+
outputDir?: string; // Built output directory for inject command
15
15
+
pathPrefix?: string; // URL path prefix for posts (default: /posts)
16
16
+
publicationUri: string;
17
17
+
pdsUrl?: string;
18
18
+
identity?: string; // Which stored identity to use (matches identifier)
19
19
+
frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings
24
20
}
25
21
26
22
export interface Credentials {
27
27
-
pdsUrl: string;
28
28
-
identifier: string;
29
29
-
password: string;
23
23
+
pdsUrl: string;
24
24
+
identifier: string;
25
25
+
password: string;
30
26
}
31
27
32
28
export interface PostFrontmatter {
33
33
-
title: string;
34
34
-
description?: string;
35
35
-
publishDate: string;
36
36
-
tags?: string[];
37
37
-
ogImage?: string;
38
38
-
hidden?: boolean;
39
39
-
atUri?: string;
29
29
+
title: string;
30
30
+
description?: string;
31
31
+
publishDate: string;
32
32
+
tags?: string[];
33
33
+
ogImage?: string;
34
34
+
atUri?: string;
40
35
}
41
36
42
37
export interface BlogPost {
43
43
-
filePath: string;
44
44
-
slug: string;
45
45
-
frontmatter: PostFrontmatter;
46
46
-
content: string;
47
47
-
rawContent: string;
38
38
+
filePath: string;
39
39
+
slug: string;
40
40
+
frontmatter: PostFrontmatter;
41
41
+
content: string;
42
42
+
rawContent: string;
48
43
}
49
44
50
45
export interface BlobRef {
51
51
-
$link: string;
46
46
+
$link: string;
52
47
}
53
48
54
49
export interface BlobObject {
55
55
-
$type: "blob";
56
56
-
ref: BlobRef;
57
57
-
mimeType: string;
58
58
-
size: number;
50
50
+
$type: "blob";
51
51
+
ref: BlobRef;
52
52
+
mimeType: string;
53
53
+
size: number;
59
54
}
60
55
61
56
export interface PublisherState {
62
62
-
posts: Record<string, PostState>;
57
57
+
posts: Record<string, PostState>;
63
58
}
64
59
65
60
export interface PostState {
66
66
-
contentHash: string;
67
67
-
atUri?: string;
68
68
-
lastPublished?: string;
61
61
+
contentHash: string;
62
62
+
atUri?: string;
63
63
+
lastPublished?: string;
69
64
}
70
65
71
66
export interface PublicationRecord {
72
72
-
$type: "site.standard.publication";
73
73
-
url: string;
74
74
-
name: string;
75
75
-
description?: string;
76
76
-
icon?: BlobObject;
77
77
-
createdAt: string;
78
78
-
preferences?: {
79
79
-
showInDiscover?: boolean;
80
80
-
};
67
67
+
$type: "site.standard.publication";
68
68
+
url: string;
69
69
+
name: string;
70
70
+
description?: string;
71
71
+
icon?: BlobObject;
72
72
+
createdAt: string;
73
73
+
preferences?: {
74
74
+
showInDiscover?: boolean;
75
75
+
};
81
76
}