tangled
alpha
login
or
join now
apoena.dev
/
sequoia
forked from
stevedylan.dev/sequoia
0
fork
atom
A CLI for publishing standard.site documents to ATProto
0
fork
atom
overview
issues
pulls
pipelines
feat: add deletion
Julien Calixte
1 month ago
836e2edb
0a2bd2cc
1/1
lint.yml
success
4s
+404
-310
10 changed files
expand all
collapse all
unified
split
packages
cli
src
commands
init.ts
publish.ts
components
sequoia-comments.js
extensions
remanso.test.ts
remanso.ts
lib
atproto.ts
markdown.test.ts
markdown.ts
types.ts
utils.ts
+1
-9
packages/cli/src/commands/init.ts
···
17
17
import { createAgent, createPublication } from "../lib/atproto";
18
18
import { selectCredential } from "../lib/credential-select";
19
19
import type { FrontmatterMapping, BlueskyConfig } from "../lib/types";
20
20
-
21
21
-
async function fileExists(filePath: string): Promise<boolean> {
22
22
-
try {
23
23
-
await fs.access(filePath);
24
24
-
return true;
25
25
-
} catch {
26
26
-
return false;
27
27
-
}
28
28
-
}
20
20
+
import { fileExists } from "../lib/utils";
29
21
30
22
const onCancel = () => {
31
23
outro("Setup cancelled");
+68
-22
packages/cli/src/commands/publish.ts
···
17
17
resolveImagePath,
18
18
createBlueskyPost,
19
19
addBskyPostRefToDocument,
20
20
+
deleteRecord,
20
21
} from "../lib/atproto";
21
22
import {
22
23
scanContentDirectory,
···
25
26
} from "../lib/markdown";
26
27
import type { BlogPost, BlobObject, StrongRef } from "../lib/types";
27
28
import { exitOnCancel } from "../lib/prompts";
28
28
-
import { createNote, updateNote, findPostsWithStaleLinks, type NoteOptions } from "../extensions/remanso"
29
29
+
import {
30
30
+
createNote,
31
31
+
updateNote,
32
32
+
deleteNote,
33
33
+
findPostsWithStaleLinks,
34
34
+
type NoteOptions,
35
35
+
} from "../extensions/remanso";
36
36
+
import { fileExists } from "../lib/utils";
29
37
30
38
export const publishCommand = command({
31
39
name: "publish",
···
159
167
});
160
168
s.stop(`Found ${posts.length} posts`);
161
169
170
170
+
// Detect deleted files: state entries whose local files no longer exist
171
171
+
const scannedPaths = new Set(
172
172
+
posts.map((p) => path.relative(configDir, p.filePath)),
173
173
+
);
174
174
+
const deletedEntries: Array<{ filePath: string; atUri: string }> = [];
175
175
+
for (const [filePath, postState] of Object.entries(state.posts)) {
176
176
+
if (!scannedPaths.has(filePath) && postState.atUri) {
177
177
+
// Check if the file truly doesn't exist (not just excluded by ignore patterns)
178
178
+
const absolutePath = path.resolve(configDir, filePath);
179
179
+
180
180
+
// If file exists but wasn't scanned (e.g. draft or ignored) — skip
181
181
+
if (!(await fileExists(absolutePath))) {
182
182
+
deletedEntries.push({ filePath, atUri: postState.atUri });
183
183
+
}
184
184
+
}
185
185
+
}
186
186
+
187
187
+
// Shared agent — created lazily, reused across deletion and publishing
188
188
+
let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
189
189
+
async function getAgent(): Promise<
190
190
+
Awaited<ReturnType<typeof createAgent>>
191
191
+
> {
192
192
+
if (agent) return agent;
193
193
+
194
194
+
if (!credentials) {
195
195
+
throw new Error("credentials not found");
196
196
+
}
197
197
+
198
198
+
const connectingTo =
199
199
+
credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl;
200
200
+
s.start(`Connecting as ${connectingTo}...`);
201
201
+
try {
202
202
+
agent = await createAgent(credentials);
203
203
+
s.stop(`Logged in as ${agent.did}`);
204
204
+
return agent;
205
205
+
} catch (error) {
206
206
+
s.stop("Failed to login");
207
207
+
log.error(`Failed to login: ${error}`);
208
208
+
process.exit(1);
209
209
+
}
210
210
+
}
211
211
+
162
212
// Determine which posts need publishing
163
213
const postsToPublish: Array<{
164
214
post: BlogPost;
···
256
306
return;
257
307
}
258
308
259
259
-
// Create agent
260
260
-
const connectingTo =
261
261
-
credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl;
262
262
-
s.start(`Connecting as ${connectingTo}...`);
263
263
-
let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
264
264
-
try {
265
265
-
agent = await createAgent(credentials);
266
266
-
s.stop(`Logged in as ${agent.did}`);
267
267
-
} catch (error) {
268
268
-
s.stop("Failed to login");
269
269
-
log.error(`Failed to login: ${error}`);
270
270
-
process.exit(1);
309
309
+
// Ensure agent is connected
310
310
+
await getAgent();
311
311
+
312
312
+
if (!agent) {
313
313
+
throw new Error("agent is not connected");
271
314
}
272
315
273
316
// Publish posts
···
290
333
}> = [];
291
334
292
335
for (const { post, action } of postsToPublish) {
293
293
-
const trimmedContent = post.content.trim()
294
294
-
const titleMatch = trimmedContent.match(/^# (.+)$/m)
295
295
-
const title = titleMatch ? titleMatch[1] : post.frontmatter.title
296
296
-
s.start(`Publishing: ${title}`);
336
336
+
const trimmedContent = post.content.trim();
337
337
+
const titleMatch = trimmedContent.match(/^# (.+)$/m);
338
338
+
const title = titleMatch ? titleMatch[1] : post.frontmatter.title;
339
339
+
s.start(`Publishing: ${title}`);
297
340
298
298
-
// Init publish date
299
299
-
if (!post.frontmatter.publishDate) {
300
300
-
const [publishDate] = new Date().toISOString().split("T")
301
301
-
post.frontmatter.publishDate = publishDate!
302
302
-
}
341
341
+
// Init publish date
342
342
+
if (!post.frontmatter.publishDate) {
343
343
+
const [publishDate] = new Date().toISOString().split("T");
344
344
+
post.frontmatter.publishDate = publishDate!;
345
345
+
}
303
346
304
347
try {
305
348
// Handle cover image upload
···
470
513
471
514
// Summary
472
515
log.message("\n---");
516
516
+
if (deletedEntries.length > 0) {
517
517
+
log.info(`Deleted: ${deletedEntries.length}`);
518
518
+
}
473
519
log.info(`Published: ${publishedCount}`);
474
520
log.info(`Updated: ${updatedCount}`);
475
521
if (bskyPostCount > 0) {
+1
-2
packages/cli/src/components/sequoia-comments.js
···
588
588
this.commentsContainer = container;
589
589
this.state = { type: "loading" };
590
590
this.abortController = null;
591
591
-
592
591
}
593
592
594
593
static get observedAttributes() {
···
701
700
</div>
702
701
`;
703
702
if (this.hide) {
704
704
-
this.commentsContainer.style.display = 'none';
703
703
+
this.commentsContainer.style.display = "none";
705
704
}
706
705
break;
707
706
+8
-33
packages/cli/src/extensions/remanso.test.ts
···
31
31
32
32
test("rewrites published link to remanso atUri", () => {
33
33
const posts = [
34
34
-
makePost(
35
35
-
"other-post",
36
36
-
"at://did:plc:abc/site.standard.document/abc123",
37
37
-
),
34
34
+
makePost("other-post", "at://did:plc:abc/site.standard.document/abc123"),
38
35
];
39
36
const content = "See [my post](./other-post)";
40
37
expect(resolveInternalLinks(content, posts)).toBe(
···
60
57
61
58
test("handles .md extension in link path", () => {
62
59
const posts = [
63
63
-
makePost(
64
64
-
"guide",
65
65
-
"at://did:plc:abc/site.standard.document/guide123",
66
66
-
),
60
60
+
makePost("guide", "at://did:plc:abc/site.standard.document/guide123"),
67
61
];
68
62
const content = "Read the [guide](guide.md)";
69
63
expect(resolveInternalLinks(content, posts)).toBe(
···
73
67
74
68
test("handles nested slug matching", () => {
75
69
const posts = [
76
76
-
makePost(
77
77
-
"blog/my-post",
78
78
-
"at://did:plc:abc/site.standard.document/rkey1",
79
79
-
),
70
70
+
makePost("blog/my-post", "at://did:plc:abc/site.standard.document/rkey1"),
80
71
];
81
72
const content = "See [post](my-post)";
82
73
expect(resolveInternalLinks(content, posts)).toBe(
···
86
77
87
78
test("does not rewrite image embeds", () => {
88
79
const posts = [
89
89
-
makePost(
90
90
-
"photo",
91
91
-
"at://did:plc:abc/site.standard.document/photo1",
92
92
-
),
80
80
+
makePost("photo", "at://did:plc:abc/site.standard.document/photo1"),
93
81
];
94
82
const content = "";
95
83
expect(resolveInternalLinks(content, posts)).toBe("");
···
97
85
98
86
test("does not rewrite @mention links", () => {
99
87
const posts = [
100
100
-
makePost(
101
101
-
"mention",
102
102
-
"at://did:plc:abc/site.standard.document/m1",
103
103
-
),
88
88
+
makePost("mention", "at://did:plc:abc/site.standard.document/m1"),
104
89
];
105
90
const content = "@[name](mention)";
106
91
expect(resolveInternalLinks(content, posts)).toBe("@[name](mention)");
···
108
93
109
94
test("handles multiple links in same content", () => {
110
95
const posts = [
111
111
-
makePost(
112
112
-
"published",
113
113
-
"at://did:plc:abc/site.standard.document/pub1",
114
114
-
),
96
96
+
makePost("published", "at://did:plc:abc/site.standard.document/pub1"),
115
97
makePost("unpublished"),
116
98
];
117
99
const content =
···
123
105
124
106
test("handles index path normalization", () => {
125
107
const posts = [
126
126
-
makePost(
127
127
-
"docs",
128
128
-
"at://did:plc:abc/site.standard.document/docs1",
129
129
-
),
108
108
+
makePost("docs", "at://did:plc:abc/site.standard.document/docs1"),
130
109
];
131
110
const content = "See [docs](./docs/index)";
132
111
expect(resolveInternalLinks(content, posts)).toBe(
···
218
197
content: "Check out [post](my-post)",
219
198
}),
220
199
];
221
221
-
const result = findPostsWithStaleLinks(
222
222
-
posts,
223
223
-
["blog/my-post"],
224
224
-
new Set(),
225
225
-
);
200
200
+
const result = findPostsWithStaleLinks(posts, ["blog/my-post"], new Set());
226
201
expect(result).toHaveLength(1);
227
202
});
228
203
+229
-211
packages/cli/src/extensions/remanso.ts
···
1
1
-
import type { Agent } from "@atproto/api"
2
2
-
import * as fs from "node:fs/promises"
3
3
-
import * as path from "node:path"
4
4
-
import mimeTypes from "mime-types"
5
5
-
import type { BlogPost, BlobObject } from "../lib/types"
1
1
+
import type { Agent } from "@atproto/api";
2
2
+
import * as fs from "node:fs/promises";
3
3
+
import * as path from "node:path";
4
4
+
import mimeTypes from "mime-types";
5
5
+
import type { BlogPost, BlobObject } from "../lib/types";
6
6
7
7
-
const LEXICON = "space.remanso.note"
8
8
-
const MAX_CONTENT = 10000
7
7
+
const LEXICON = "space.remanso.note";
8
8
+
const MAX_CONTENT = 10000;
9
9
10
10
interface ImageRecord {
11
11
-
image: BlobObject
12
12
-
alt?: string
11
11
+
image: BlobObject;
12
12
+
alt?: string;
13
13
}
14
14
15
15
export interface NoteOptions {
16
16
-
contentDir: string
17
17
-
imagesDir?: string
18
18
-
allPosts: BlogPost[]
16
16
+
contentDir: string;
17
17
+
imagesDir?: string;
18
18
+
allPosts: BlogPost[];
19
19
}
20
20
21
21
async function fileExists(filePath: string): Promise<boolean> {
22
22
-
try {
23
23
-
await fs.access(filePath)
24
24
-
return true
25
25
-
} catch {
26
26
-
return false
27
27
-
}
22
22
+
try {
23
23
+
await fs.access(filePath);
24
24
+
return true;
25
25
+
} catch {
26
26
+
return false;
27
27
+
}
28
28
}
29
29
30
30
export function isLocalPath(url: string): boolean {
31
31
-
return (
32
32
-
!url.startsWith("http://") &&
33
33
-
!url.startsWith("https://") &&
34
34
-
!url.startsWith("#") &&
35
35
-
!url.startsWith("mailto:")
36
36
-
)
31
31
+
return (
32
32
+
!url.startsWith("http://") &&
33
33
+
!url.startsWith("https://") &&
34
34
+
!url.startsWith("#") &&
35
35
+
!url.startsWith("mailto:")
36
36
+
);
37
37
}
38
38
39
39
function getImageCandidates(
40
40
-
src: string,
41
41
-
postFilePath: string,
42
42
-
contentDir: string,
43
43
-
imagesDir?: string,
40
40
+
src: string,
41
41
+
postFilePath: string,
42
42
+
contentDir: string,
43
43
+
imagesDir?: string,
44
44
): string[] {
45
45
-
const candidates = [
46
46
-
path.resolve(path.dirname(postFilePath), src),
47
47
-
path.resolve(contentDir, src),
48
48
-
]
49
49
-
if (imagesDir) {
50
50
-
candidates.push(path.resolve(imagesDir, src))
51
51
-
const baseName = path.basename(imagesDir)
52
52
-
const idx = src.indexOf(baseName)
53
53
-
if (idx !== -1) {
54
54
-
const after = src.substring(idx + baseName.length).replace(/^[/\\]/, "")
55
55
-
candidates.push(path.resolve(imagesDir, after))
56
56
-
}
57
57
-
}
58
58
-
return candidates
45
45
+
const candidates = [
46
46
+
path.resolve(path.dirname(postFilePath), src),
47
47
+
path.resolve(contentDir, src),
48
48
+
];
49
49
+
if (imagesDir) {
50
50
+
candidates.push(path.resolve(imagesDir, src));
51
51
+
const baseName = path.basename(imagesDir);
52
52
+
const idx = src.indexOf(baseName);
53
53
+
if (idx !== -1) {
54
54
+
const after = src.substring(idx + baseName.length).replace(/^[/\\]/, "");
55
55
+
candidates.push(path.resolve(imagesDir, after));
56
56
+
}
57
57
+
}
58
58
+
return candidates;
59
59
}
60
60
61
61
async function uploadBlob(
62
62
-
agent: Agent,
63
63
-
candidates: string[],
62
62
+
agent: Agent,
63
63
+
candidates: string[],
64
64
): Promise<BlobObject | undefined> {
65
65
-
for (const filePath of candidates) {
66
66
-
if (!(await fileExists(filePath))) continue
65
65
+
for (const filePath of candidates) {
66
66
+
if (!(await fileExists(filePath))) continue;
67
67
68
68
-
try {
69
69
-
const imageBuffer = await fs.readFile(filePath)
70
70
-
if (imageBuffer.byteLength === 0) continue
71
71
-
const mimeType = mimeTypes.lookup(filePath) || "application/octet-stream"
72
72
-
const response = await agent.com.atproto.repo.uploadBlob(
73
73
-
new Uint8Array(imageBuffer),
74
74
-
{ encoding: mimeType },
75
75
-
)
76
76
-
return {
77
77
-
$type: "blob",
78
78
-
ref: { $link: response.data.blob.ref.toString() },
79
79
-
mimeType,
80
80
-
size: imageBuffer.byteLength,
81
81
-
}
82
82
-
} catch {}
83
83
-
}
84
84
-
return undefined
68
68
+
try {
69
69
+
const imageBuffer = await fs.readFile(filePath);
70
70
+
if (imageBuffer.byteLength === 0) continue;
71
71
+
const mimeType = mimeTypes.lookup(filePath) || "application/octet-stream";
72
72
+
const response = await agent.com.atproto.repo.uploadBlob(
73
73
+
new Uint8Array(imageBuffer),
74
74
+
{ encoding: mimeType },
75
75
+
);
76
76
+
return {
77
77
+
$type: "blob",
78
78
+
ref: { $link: response.data.blob.ref.toString() },
79
79
+
mimeType,
80
80
+
size: imageBuffer.byteLength,
81
81
+
};
82
82
+
} catch {}
83
83
+
}
84
84
+
return undefined;
85
85
}
86
86
87
87
async function processImages(
88
88
-
agent: Agent,
89
89
-
content: string,
90
90
-
postFilePath: string,
91
91
-
contentDir: string,
92
92
-
imagesDir?: string,
88
88
+
agent: Agent,
89
89
+
content: string,
90
90
+
postFilePath: string,
91
91
+
contentDir: string,
92
92
+
imagesDir?: string,
93
93
): Promise<{ content: string; images: ImageRecord[] }> {
94
94
-
const images: ImageRecord[] = []
95
95
-
const uploadCache = new Map<string, BlobObject>()
96
96
-
let processedContent = content
94
94
+
const images: ImageRecord[] = [];
95
95
+
const uploadCache = new Map<string, BlobObject>();
96
96
+
let processedContent = content;
97
97
98
98
-
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g
99
99
-
const matches = [...content.matchAll(imageRegex)]
98
98
+
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
99
99
+
const matches = [...content.matchAll(imageRegex)];
100
100
101
101
-
for (const match of matches) {
102
102
-
const fullMatch = match[0]
103
103
-
const alt = match[1] ?? ""
104
104
-
const src = match[2]!
105
105
-
if (!isLocalPath(src)) continue
101
101
+
for (const match of matches) {
102
102
+
const fullMatch = match[0];
103
103
+
const alt = match[1] ?? "";
104
104
+
const src = match[2]!;
105
105
+
if (!isLocalPath(src)) continue;
106
106
107
107
-
let blob = uploadCache.get(src)
108
108
-
if (!blob) {
109
109
-
const candidates = getImageCandidates(src, postFilePath, contentDir, imagesDir)
110
110
-
blob = await uploadBlob(agent, candidates)
111
111
-
if (!blob) continue
112
112
-
uploadCache.set(src, blob)
113
113
-
}
107
107
+
let blob = uploadCache.get(src);
108
108
+
if (!blob) {
109
109
+
const candidates = getImageCandidates(
110
110
+
src,
111
111
+
postFilePath,
112
112
+
contentDir,
113
113
+
imagesDir,
114
114
+
);
115
115
+
blob = await uploadBlob(agent, candidates);
116
116
+
if (!blob) continue;
117
117
+
uploadCache.set(src, blob);
118
118
+
}
114
119
115
115
-
images.push({ image: blob, alt: alt || undefined })
116
116
-
processedContent = processedContent.replace(
117
117
-
fullMatch,
118
118
-
``,
119
119
-
)
120
120
-
}
120
120
+
images.push({ image: blob, alt: alt || undefined });
121
121
+
processedContent = processedContent.replace(
122
122
+
fullMatch,
123
123
+
``,
124
124
+
);
125
125
+
}
121
126
122
122
-
return { content: processedContent, images }
127
127
+
return { content: processedContent, images };
123
128
}
124
129
125
130
export function resolveInternalLinks(
126
126
-
content: string,
127
127
-
allPosts: BlogPost[],
131
131
+
content: string,
132
132
+
allPosts: BlogPost[],
128
133
): string {
129
129
-
const linkRegex = /(?<![!@])\[([^\]]+)\]\(([^)]+)\)/g
134
134
+
const linkRegex = /(?<![!@])\[([^\]]+)\]\(([^)]+)\)/g;
130
135
131
131
-
return content.replace(linkRegex, (fullMatch, text, url) => {
132
132
-
if (!isLocalPath(url)) return fullMatch
136
136
+
return content.replace(linkRegex, (fullMatch, text, url) => {
137
137
+
if (!isLocalPath(url)) return fullMatch;
133
138
134
134
-
// Normalize to a slug-like string for comparison
135
135
-
const normalized = url
136
136
-
.replace(/^\.?\/?/, "")
137
137
-
.replace(/\/?$/, "")
138
138
-
.replace(/\.mdx?$/, "")
139
139
-
.replace(/\/index$/, "")
139
139
+
// Normalize to a slug-like string for comparison
140
140
+
const normalized = url
141
141
+
.replace(/^\.?\/?/, "")
142
142
+
.replace(/\/?$/, "")
143
143
+
.replace(/\.mdx?$/, "")
144
144
+
.replace(/\/index$/, "");
140
145
141
141
-
const matchedPost = allPosts.find((p) => {
142
142
-
if (!p.frontmatter.atUri) return false
143
143
-
return (
144
144
-
p.slug === normalized ||
145
145
-
p.slug.endsWith(`/${normalized}`) ||
146
146
-
normalized.endsWith(`/${p.slug}`)
147
147
-
)
148
148
-
})
146
146
+
const matchedPost = allPosts.find((p) => {
147
147
+
if (!p.frontmatter.atUri) return false;
148
148
+
return (
149
149
+
p.slug === normalized ||
150
150
+
p.slug.endsWith(`/${normalized}`) ||
151
151
+
normalized.endsWith(`/${p.slug}`)
152
152
+
);
153
153
+
});
149
154
150
150
-
if (!matchedPost) return text
155
155
+
if (!matchedPost) return text;
151
156
152
152
-
const noteUri = matchedPost.frontmatter.atUri!.replace(
153
153
-
/\/[^/]+\/([^/]+)$/,
154
154
-
`/space.remanso.note/$1`,
155
155
-
)
156
156
-
return `[${text}](${noteUri})`
157
157
-
})
157
157
+
const noteUri = matchedPost.frontmatter.atUri!.replace(
158
158
+
/\/[^/]+\/([^/]+)$/,
159
159
+
`/space.remanso.note/$1`,
160
160
+
);
161
161
+
return `[${text}](${noteUri})`;
162
162
+
});
158
163
}
159
164
160
165
async function processNoteContent(
161
161
-
agent: Agent,
162
162
-
post: BlogPost,
163
163
-
options: NoteOptions,
166
166
+
agent: Agent,
167
167
+
post: BlogPost,
168
168
+
options: NoteOptions,
164
169
): Promise<{ content: string; images: ImageRecord[] }> {
165
165
-
let content = post.content.trim()
170
170
+
let content = post.content.trim();
166
171
167
167
-
content = resolveInternalLinks(content, options.allPosts)
172
172
+
content = resolveInternalLinks(content, options.allPosts);
168
173
169
169
-
const result = await processImages(
170
170
-
agent, content, post.filePath, options.contentDir, options.imagesDir,
171
171
-
)
174
174
+
const result = await processImages(
175
175
+
agent,
176
176
+
content,
177
177
+
post.filePath,
178
178
+
options.contentDir,
179
179
+
options.imagesDir,
180
180
+
);
172
181
173
173
-
return result
182
182
+
return result;
174
183
}
175
184
176
185
function parseRkey(atUri: string): string {
177
177
-
const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/)
178
178
-
if (!uriMatch) {
179
179
-
throw new Error(`Invalid atUri format: ${atUri}`)
180
180
-
}
181
181
-
return uriMatch[3]!
186
186
+
const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
187
187
+
if (!uriMatch) {
188
188
+
throw new Error(`Invalid atUri format: ${atUri}`);
189
189
+
}
190
190
+
return uriMatch[3]!;
182
191
}
183
192
184
193
async function buildNoteRecord(
185
185
-
agent: Agent,
186
186
-
post: BlogPost,
187
187
-
options: NoteOptions,
194
194
+
agent: Agent,
195
195
+
post: BlogPost,
196
196
+
options: NoteOptions,
188
197
): Promise<Record<string, unknown>> {
189
189
-
const publishDate = new Date(post.frontmatter.publishDate).toISOString()
190
190
-
const trimmedContent = post.content.trim()
191
191
-
const titleMatch = trimmedContent.match(/^# (.+)$/m)
192
192
-
const title = titleMatch ? titleMatch[1] : post.frontmatter.title
198
198
+
const publishDate = new Date(post.frontmatter.publishDate).toISOString();
199
199
+
const trimmedContent = post.content.trim();
200
200
+
const titleMatch = trimmedContent.match(/^# (.+)$/m);
201
201
+
const title = titleMatch ? titleMatch[1] : post.frontmatter.title;
193
202
194
194
-
const { content, images } = await processNoteContent(agent, post, options)
203
203
+
const { content, images } = await processNoteContent(agent, post, options);
195
204
196
196
-
const record: Record<string, unknown> = {
197
197
-
$type: LEXICON,
198
198
-
title,
199
199
-
content: content.slice(0, MAX_CONTENT),
200
200
-
createdAt: publishDate,
201
201
-
publishedAt: publishDate,
202
202
-
}
205
205
+
const record: Record<string, unknown> = {
206
206
+
$type: LEXICON,
207
207
+
title,
208
208
+
content: content.slice(0, MAX_CONTENT),
209
209
+
createdAt: publishDate,
210
210
+
publishedAt: publishDate,
211
211
+
};
203
212
204
204
-
if (images.length > 0) {
205
205
-
record.images = images
206
206
-
}
213
213
+
if (images.length > 0) {
214
214
+
record.images = images;
215
215
+
}
207
216
208
208
-
if (post.frontmatter.theme) {
209
209
-
record.theme = post.frontmatter.theme
210
210
-
}
217
217
+
if (post.frontmatter.theme) {
218
218
+
record.theme = post.frontmatter.theme;
219
219
+
}
211
220
212
212
-
if (post.frontmatter.fontSize) {
213
213
-
record.fontSize = post.frontmatter.fontSize
214
214
-
}
221
221
+
if (post.frontmatter.fontSize) {
222
222
+
record.fontSize = post.frontmatter.fontSize;
223
223
+
}
215
224
216
216
-
if (post.frontmatter.fontFamily) {
217
217
-
record.fontFamily = post.frontmatter.fontFamily
218
218
-
}
225
225
+
if (post.frontmatter.fontFamily) {
226
226
+
record.fontFamily = post.frontmatter.fontFamily;
227
227
+
}
219
228
220
220
-
return record
229
229
+
return record;
230
230
+
}
231
231
+
232
232
+
export async function deleteNote(agent: Agent, atUri: string): Promise<void> {
233
233
+
const rkey = parseRkey(atUri);
234
234
+
await agent.com.atproto.repo.deleteRecord({
235
235
+
repo: agent.did!,
236
236
+
collection: LEXICON,
237
237
+
rkey,
238
238
+
});
221
239
}
222
240
223
241
export async function createNote(
224
224
-
agent: Agent,
225
225
-
post: BlogPost,
226
226
-
atUri: string,
227
227
-
options: NoteOptions,
242
242
+
agent: Agent,
243
243
+
post: BlogPost,
244
244
+
atUri: string,
245
245
+
options: NoteOptions,
228
246
): Promise<void> {
229
229
-
const rkey = parseRkey(atUri)
230
230
-
const record = await buildNoteRecord(agent, post, options)
247
247
+
const rkey = parseRkey(atUri);
248
248
+
const record = await buildNoteRecord(agent, post, options);
231
249
232
232
-
await agent.com.atproto.repo.createRecord({
233
233
-
repo: agent.did!,
234
234
-
collection: LEXICON,
235
235
-
record,
236
236
-
rkey,
237
237
-
validate: false,
238
238
-
})
250
250
+
await agent.com.atproto.repo.createRecord({
251
251
+
repo: agent.did!,
252
252
+
collection: LEXICON,
253
253
+
record,
254
254
+
rkey,
255
255
+
validate: false,
256
256
+
});
239
257
}
240
258
241
259
export async function updateNote(
242
242
-
agent: Agent,
243
243
-
post: BlogPost,
244
244
-
atUri: string,
245
245
-
options: NoteOptions,
260
260
+
agent: Agent,
261
261
+
post: BlogPost,
262
262
+
atUri: string,
263
263
+
options: NoteOptions,
246
264
): Promise<void> {
247
247
-
const rkey = parseRkey(atUri)
248
248
-
const record = await buildNoteRecord(agent, post, options)
265
265
+
const rkey = parseRkey(atUri);
266
266
+
const record = await buildNoteRecord(agent, post, options);
249
267
250
250
-
await agent.com.atproto.repo.putRecord({
251
251
-
repo: agent.did!,
252
252
-
collection: LEXICON,
253
253
-
rkey: rkey!,
254
254
-
record,
255
255
-
validate: false,
256
256
-
})
268
268
+
await agent.com.atproto.repo.putRecord({
269
269
+
repo: agent.did!,
270
270
+
collection: LEXICON,
271
271
+
rkey: rkey!,
272
272
+
record,
273
273
+
validate: false,
274
274
+
});
257
275
}
258
276
259
277
export function findPostsWithStaleLinks(
260
260
-
allPosts: BlogPost[],
261
261
-
newSlugs: string[],
262
262
-
excludeFilePaths: Set<string>,
278
278
+
allPosts: BlogPost[],
279
279
+
newSlugs: string[],
280
280
+
excludeFilePaths: Set<string>,
263
281
): BlogPost[] {
264
264
-
const linkRegex = /(?<![!@])\[([^\]]+)\]\(([^)]+)\)/g
282
282
+
const linkRegex = /(?<![!@])\[([^\]]+)\]\(([^)]+)\)/g;
265
283
266
266
-
return allPosts.filter((post) => {
267
267
-
if (excludeFilePaths.has(post.filePath)) return false
268
268
-
if (!post.frontmatter.atUri) return false
269
269
-
if (post.frontmatter.draft) return false
284
284
+
return allPosts.filter((post) => {
285
285
+
if (excludeFilePaths.has(post.filePath)) return false;
286
286
+
if (!post.frontmatter.atUri) return false;
287
287
+
if (post.frontmatter.draft) return false;
270
288
271
271
-
const matches = [...post.content.matchAll(linkRegex)]
272
272
-
return matches.some((match) => {
273
273
-
const url = match[2]!
274
274
-
if (!isLocalPath(url)) return false
289
289
+
const matches = [...post.content.matchAll(linkRegex)];
290
290
+
return matches.some((match) => {
291
291
+
const url = match[2]!;
292
292
+
if (!isLocalPath(url)) return false;
275
293
276
276
-
const normalized = url
277
277
-
.replace(/^\.?\/?/, "")
278
278
-
.replace(/\/?$/, "")
279
279
-
.replace(/\.mdx?$/, "")
280
280
-
.replace(/\/index$/, "")
294
294
+
const normalized = url
295
295
+
.replace(/^\.?\/?/, "")
296
296
+
.replace(/\/?$/, "")
297
297
+
.replace(/\.mdx?$/, "")
298
298
+
.replace(/\/index$/, "");
281
299
282
282
-
return newSlugs.some(
283
283
-
(slug) =>
284
284
-
slug === normalized ||
285
285
-
slug.endsWith(`/${normalized}`) ||
286
286
-
normalized.endsWith(`/${slug}`),
287
287
-
)
288
288
-
})
289
289
-
})
300
300
+
return newSlugs.some(
301
301
+
(slug) =>
302
302
+
slug === normalized ||
303
303
+
slug.endsWith(`/${normalized}`) ||
304
304
+
normalized.endsWith(`/${slug}`),
305
305
+
);
306
306
+
});
307
307
+
});
290
308
}
+19
-9
packages/cli/src/lib/atproto.ts
···
248
248
const pathPrefix = config.pathPrefix || "/posts";
249
249
const postPath = `${pathPrefix}/${post.slug}`;
250
250
const publishDate = new Date(post.frontmatter.publishDate);
251
251
-
const trimmedContent = post.content.trim()
251
251
+
const trimmedContent = post.content.trim();
252
252
const textContent = getTextContent(post, config.textContentField);
253
253
-
const titleMatch = trimmedContent.match(/^# (.+)$/m)
254
254
-
const title = titleMatch ? titleMatch[1] : post.frontmatter.title
253
253
+
const titleMatch = trimmedContent.match(/^# (.+)$/m);
254
254
+
const title = titleMatch ? titleMatch[1] : post.frontmatter.title;
255
255
256
256
const record: Record<string, unknown> = {
257
257
$type: "site.standard.document",
···
302
302
303
303
const pathPrefix = config.pathPrefix || "/posts";
304
304
const postPath = `${pathPrefix}/${post.slug}`;
305
305
-
305
305
+
306
306
const publishDate = new Date(post.frontmatter.publishDate);
307
307
-
const trimmedContent = post.content.trim()
307
307
+
const trimmedContent = post.content.trim();
308
308
const textContent = getTextContent(post, config.textContentField);
309
309
-
const titleMatch = trimmedContent.match(/^# (.+)$/m)
310
310
-
const title = titleMatch ? titleMatch[1] : post.frontmatter.title
309
309
+
const titleMatch = trimmedContent.match(/^# (.+)$/m);
310
310
+
const title = titleMatch ? titleMatch[1] : post.frontmatter.title;
311
311
312
312
const record: Record<string, unknown> = {
313
313
$type: "site.standard.document",
···
385
385
limit: 100,
386
386
cursor,
387
387
});
388
388
-
388
388
+
389
389
for (const record of response.data.records) {
390
390
-
if (!isDocumentRecord(record.value)) {
390
390
+
if (!isDocumentRecord(record.value)) {
391
391
continue;
392
392
}
393
393
···
542
542
collection: parsed.collection,
543
543
rkey: parsed.rkey,
544
544
record,
545
545
+
});
546
546
+
}
547
547
+
548
548
+
export async function deleteRecord(agent: Agent, atUri: string): Promise<void> {
549
549
+
const parsed = parseAtUri(atUri);
550
550
+
if (!parsed) throw new Error(`Invalid atUri format: ${atUri}`);
551
551
+
await agent.com.atproto.repo.deleteRecord({
552
552
+
repo: parsed.did,
553
553
+
collection: parsed.collection,
554
554
+
rkey: parsed.rkey,
545
555
});
546
556
}
547
557
+26
-7
packages/cli/src/lib/markdown.test.ts
···
239
239
});
240
240
241
241
test("falls back to filepath when slugField not found in frontmatter", () => {
242
242
-
const slug = getSlugFromOptions("blog/my-post.md", {}, { slugField: "slug" });
242
242
+
const slug = getSlugFromOptions(
243
243
+
"blog/my-post.md",
244
244
+
{},
245
245
+
{ slugField: "slug" },
246
246
+
);
243
247
expect(slug).toBe("blog/my-post");
244
248
});
245
249
···
320
324
---
321
325
Body`;
322
326
323
323
-
const result = updateFrontmatterWithAtUri(content, "at://did:plc:abc/post/123");
327
327
+
const result = updateFrontmatterWithAtUri(
328
328
+
content,
329
329
+
"at://did:plc:abc/post/123",
330
330
+
);
324
331
expect(result).toContain('atUri: "at://did:plc:abc/post/123"');
325
332
expect(result).toContain("title: My Post");
326
333
});
···
331
338
+++
332
339
Body`;
333
340
334
334
-
const result = updateFrontmatterWithAtUri(content, "at://did:plc:abc/post/123");
341
341
+
const result = updateFrontmatterWithAtUri(
342
342
+
content,
343
343
+
"at://did:plc:abc/post/123",
344
344
+
);
335
345
expect(result).toContain('atUri = "at://did:plc:abc/post/123"');
336
346
});
337
347
338
348
test("creates frontmatter with atUri when none exists", () => {
339
349
const content = "# My Post\n\nSome body text";
340
350
341
341
-
const result = updateFrontmatterWithAtUri(content, "at://did:plc:abc/post/123");
351
351
+
const result = updateFrontmatterWithAtUri(
352
352
+
content,
353
353
+
"at://did:plc:abc/post/123",
354
354
+
);
342
355
expect(result).toContain('atUri: "at://did:plc:abc/post/123"');
343
356
expect(result).toContain("---");
344
357
expect(result).toContain("# My Post\n\nSome body text");
···
351
364
---
352
365
Body`;
353
366
354
354
-
const result = updateFrontmatterWithAtUri(content, "at://did:plc:new/post/999");
367
367
+
const result = updateFrontmatterWithAtUri(
368
368
+
content,
369
369
+
"at://did:plc:new/post/999",
370
370
+
);
355
371
expect(result).toContain('atUri: "at://did:plc:new/post/999"');
356
372
expect(result).not.toContain("old");
357
373
});
···
363
379
+++
364
380
Body`;
365
381
366
366
-
const result = updateFrontmatterWithAtUri(content, "at://did:plc:new/post/999");
382
382
+
const result = updateFrontmatterWithAtUri(
383
383
+
content,
384
384
+
"at://did:plc:new/post/999",
385
385
+
);
367
386
expect(result).toContain('atUri = "at://did:plc:new/post/999"');
368
387
expect(result).not.toContain("old");
369
388
});
···
436
455
};
437
456
expect(getTextContent(post)).toBe("Heading\n\nParagraph");
438
457
});
439
439
-
});
458
458
+
});
+39
-14
packages/cli/src/lib/markdown.ts
···
21
21
const match = content.match(frontmatterRegex);
22
22
23
23
if (!match) {
24
24
-
const [, titleMatch] = content.trim().match(/^# (.+)$/m) || []
25
25
-
const title = titleMatch ?? ""
26
26
-
const [publishDate] = new Date().toISOString().split("T")
24
24
+
const [, titleMatch] = content.trim().match(/^# (.+)$/m) || [];
25
25
+
const title = titleMatch ?? "";
26
26
+
const [publishDate] = new Date().toISOString().split("T");
27
27
28
28
-
return {
29
29
-
frontmatter: {
30
30
-
title,
31
31
-
publishDate: publishDate ?? ""
32
32
-
},
33
33
-
body: content,
34
34
-
rawFrontmatter: {
35
35
-
title:
36
36
-
publishDate
37
37
-
}
38
38
-
}
28
28
+
return {
29
29
+
frontmatter: {
30
30
+
title,
31
31
+
publishDate: publishDate ?? "",
32
32
+
},
33
33
+
body: content,
34
34
+
rawFrontmatter: {
35
35
+
title: publishDate,
36
36
+
},
37
37
+
};
39
38
}
40
39
41
40
const delimiter = match[1];
···
397
396
const afterEnd = rawContent.slice(frontmatterEndIndex);
398
397
399
398
return `${beforeEnd}${atUriEntry}\n${afterEnd}`;
399
399
+
}
400
400
+
401
401
+
export function removeFrontmatterAtUri(rawContent: string): string {
402
402
+
const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n/;
403
403
+
const match = rawContent.match(frontmatterRegex);
404
404
+
if (!match) return rawContent;
405
405
+
406
406
+
const delimiter = match[1];
407
407
+
const frontmatterStr = match[2] ?? "";
408
408
+
409
409
+
// Remove the atUri line
410
410
+
const lines = frontmatterStr
411
411
+
.split("\n")
412
412
+
.filter((line) => !line.match(/^\s*atUri\s*[=:]\s*/));
413
413
+
414
414
+
// Check if remaining frontmatter has any non-empty lines
415
415
+
const hasContent = lines.some((line) => line.trim() !== "");
416
416
+
417
417
+
const afterFrontmatter = rawContent.slice(match[0].length);
418
418
+
419
419
+
if (!hasContent) {
420
420
+
// Remove entire frontmatter block, trim leading newlines
421
421
+
return afterFrontmatter.replace(/^\n+/, "");
422
422
+
}
423
423
+
424
424
+
return `${delimiter}\n${lines.join("\n")}\n${delimiter}\n${afterFrontmatter}`;
400
425
}
401
426
402
427
export function stripMarkdownForText(markdown: string): string {
+3
-3
packages/cli/src/lib/types.ts
···
85
85
86
86
export interface PostFrontmatter {
87
87
title: string;
88
88
-
theme?: string
89
89
-
fontFamily?: string
90
90
-
fontSize?: number
88
88
+
theme?: string;
89
89
+
fontFamily?: string;
90
90
+
fontSize?: number;
91
91
description?: string;
92
92
publishDate: string;
93
93
tags?: string[];
+10
packages/cli/src/lib/utils.ts
···
1
1
+
import * as fs from "node:fs/promises";
2
2
+
3
3
+
export async function fileExists(filePath: string): Promise<boolean> {
4
4
+
try {
5
5
+
await fs.access(filePath);
6
6
+
return true;
7
7
+
} catch {
8
8
+
return false;
9
9
+
}
10
10
+
}