tangled
alpha
login
or
join now
apoena.dev
/
sequoia
forked from
stevedylan.dev/sequoia
A CLI for publishing standard.site documents to ATProto
0
fork
atom
overview
issues
pulls
pipelines
fix: links from new notes
Julien Calixte
1 day ago
5b313d8b
4f7c3a62
0/1
lint.yml
failed
4s
+203
-8
3 changed files
expand all
collapse all
unified
split
packages
cli
src
commands
publish.ts
extensions
litenote.test.ts
litenote.ts
+58
-3
packages/cli/src/commands/publish.ts
···
25
25
} from "../lib/markdown";
26
26
import type { BlogPost, BlobObject, StrongRef } from "../lib/types";
27
27
import { exitOnCancel } from "../lib/prompts";
28
28
-
import { createNote, updateNote, type NoteOptions } from "../extensions/litenote"
28
28
+
import { createNote, updateNote, findPostsWithStaleLinks, type NoteOptions } from "../extensions/litenote"
29
29
30
30
export const publishCommand = command({
31
31
name: "publish",
···
271
271
allPosts: posts,
272
272
};
273
273
274
274
+
// Pass 1: Create/update document records and collect note queue
275
275
+
const noteQueue: Array<{
276
276
+
post: BlogPost;
277
277
+
action: "create" | "update";
278
278
+
atUri: string;
279
279
+
}> = [];
280
280
+
274
281
for (const { post, action } of postsToPublish) {
275
282
s.start(`Publishing: ${post.frontmatter.title}`);
276
283
···
306
313
307
314
if (action === "create") {
308
315
atUri = await createDocument(agent, post, config, coverImage);
309
309
-
await createNote(agent, post, atUri, context)
316
316
+
post.frontmatter.atUri = atUri;
310
317
s.stop(`Created: ${atUri}`);
311
318
312
319
// Update frontmatter with atUri
···
323
330
} else {
324
331
atUri = post.frontmatter.atUri!;
325
332
await updateDocument(agent, post, atUri, config, coverImage);
326
326
-
await updateNote(agent, post, atUri, context)
327
333
s.stop(`Updated: ${atUri}`);
328
334
329
335
// For updates, rawContent already has atUri
···
381
387
slug: post.slug,
382
388
bskyPostRef,
383
389
};
390
390
+
391
391
+
noteQueue.push({ post, action, atUri });
384
392
} catch (error) {
385
393
const errorMessage =
386
394
error instanceof Error ? error.message : String(error);
387
395
s.stop(`Error publishing "${path.basename(post.filePath)}"`);
388
396
log.error(` ${errorMessage}`);
389
397
errorCount++;
398
398
+
}
399
399
+
}
400
400
+
401
401
+
// Pass 2: Create/update litenote notes (atUris are now available for link resolution)
402
402
+
for (const { post, action, atUri } of noteQueue) {
403
403
+
try {
404
404
+
if (action === "create") {
405
405
+
await createNote(agent, post, atUri, context);
406
406
+
} else {
407
407
+
await updateNote(agent, post, atUri, context);
408
408
+
}
409
409
+
} catch (error) {
410
410
+
log.warn(
411
411
+
`Failed to create note for "${post.frontmatter.title}": ${error instanceof Error ? error.message : String(error)}`,
412
412
+
);
413
413
+
}
414
414
+
}
415
415
+
416
416
+
// Re-process already-published posts with stale links to newly created posts
417
417
+
const newlyCreatedSlugs = noteQueue
418
418
+
.filter((r) => r.action === "create")
419
419
+
.map((r) => r.post.slug);
420
420
+
421
421
+
if (newlyCreatedSlugs.length > 0) {
422
422
+
const batchFilePaths = new Set(noteQueue.map((r) => r.post.filePath));
423
423
+
const stalePosts = findPostsWithStaleLinks(
424
424
+
posts,
425
425
+
newlyCreatedSlugs,
426
426
+
batchFilePaths,
427
427
+
);
428
428
+
429
429
+
for (const stalePost of stalePosts) {
430
430
+
try {
431
431
+
s.start(`Updating links in: ${stalePost.frontmatter.title}`);
432
432
+
await updateNote(
433
433
+
agent,
434
434
+
stalePost,
435
435
+
stalePost.frontmatter.atUri!,
436
436
+
context,
437
437
+
);
438
438
+
s.stop(`Updated links: ${stalePost.frontmatter.title}`);
439
439
+
} catch (error) {
440
440
+
s.stop(`Failed to update links: ${stalePost.frontmatter.title}`);
441
441
+
log.warn(
442
442
+
` ${error instanceof Error ? error.message : String(error)}`,
443
443
+
);
444
444
+
}
390
445
}
391
446
}
392
447
+111
-4
packages/cli/src/extensions/litenote.test.ts
···
1
1
import { describe, expect, test } from "bun:test";
2
2
-
import { resolveInternalLinks } from "./litenote";
2
2
+
import { resolveInternalLinks, findPostsWithStaleLinks } from "./litenote";
3
3
import type { BlogPost } from "../lib/types";
4
4
5
5
-
function makePost(slug: string, atUri?: string): BlogPost {
5
5
+
function makePost(
6
6
+
slug: string,
7
7
+
atUri?: string,
8
8
+
options?: { content?: string; draft?: boolean; filePath?: string },
9
9
+
): BlogPost {
6
10
return {
7
7
-
filePath: `content/${slug}.md`,
11
11
+
filePath: options?.filePath ?? `content/${slug}.md`,
8
12
slug,
9
13
frontmatter: {
10
14
title: slug,
11
15
publishDate: "2024-01-01",
12
16
atUri,
17
17
+
draft: options?.draft,
13
18
},
14
14
-
content: "",
19
19
+
content: options?.content ?? "",
15
20
rawContent: "",
16
21
rawFrontmatter: {},
17
22
};
···
129
134
);
130
135
});
131
136
});
137
137
+
138
138
+
describe("findPostsWithStaleLinks", () => {
139
139
+
test("finds published post containing link to a newly created slug", () => {
140
140
+
const posts = [
141
141
+
makePost("post-a", "at://did:plc:abc/site.standard.document/a1", {
142
142
+
content: "Check out [post B](./post-b)",
143
143
+
}),
144
144
+
];
145
145
+
const result = findPostsWithStaleLinks(posts, ["post-b"], new Set());
146
146
+
expect(result).toHaveLength(1);
147
147
+
expect(result[0]!.slug).toBe("post-a");
148
148
+
});
149
149
+
150
150
+
test("excludes posts in the exclude set (current batch)", () => {
151
151
+
const posts = [
152
152
+
makePost("post-a", "at://did:plc:abc/site.standard.document/a1", {
153
153
+
content: "Check out [post B](./post-b)",
154
154
+
}),
155
155
+
];
156
156
+
const result = findPostsWithStaleLinks(
157
157
+
posts,
158
158
+
["post-b"],
159
159
+
new Set(["content/post-a.md"]),
160
160
+
);
161
161
+
expect(result).toHaveLength(0);
162
162
+
});
163
163
+
164
164
+
test("excludes unpublished posts (no atUri)", () => {
165
165
+
const posts = [
166
166
+
makePost("post-a", undefined, {
167
167
+
content: "Check out [post B](./post-b)",
168
168
+
}),
169
169
+
];
170
170
+
const result = findPostsWithStaleLinks(posts, ["post-b"], new Set());
171
171
+
expect(result).toHaveLength(0);
172
172
+
});
173
173
+
174
174
+
test("excludes drafts", () => {
175
175
+
const posts = [
176
176
+
makePost("post-a", "at://did:plc:abc/site.standard.document/a1", {
177
177
+
content: "Check out [post B](./post-b)",
178
178
+
draft: true,
179
179
+
}),
180
180
+
];
181
181
+
const result = findPostsWithStaleLinks(posts, ["post-b"], new Set());
182
182
+
expect(result).toHaveLength(0);
183
183
+
});
184
184
+
185
185
+
test("ignores external links", () => {
186
186
+
const posts = [
187
187
+
makePost("post-a", "at://did:plc:abc/site.standard.document/a1", {
188
188
+
content: "Check out [post B](https://example.com/post-b)",
189
189
+
}),
190
190
+
];
191
191
+
const result = findPostsWithStaleLinks(posts, ["post-b"], new Set());
192
192
+
expect(result).toHaveLength(0);
193
193
+
});
194
194
+
195
195
+
test("ignores image embeds", () => {
196
196
+
const posts = [
197
197
+
makePost("post-a", "at://did:plc:abc/site.standard.document/a1", {
198
198
+
content: "",
199
199
+
}),
200
200
+
];
201
201
+
const result = findPostsWithStaleLinks(posts, ["post-b"], new Set());
202
202
+
expect(result).toHaveLength(0);
203
203
+
});
204
204
+
205
205
+
test("ignores @mention links", () => {
206
206
+
const posts = [
207
207
+
makePost("post-a", "at://did:plc:abc/site.standard.document/a1", {
208
208
+
content: "@[post B](./post-b)",
209
209
+
}),
210
210
+
];
211
211
+
const result = findPostsWithStaleLinks(posts, ["post-b"], new Set());
212
212
+
expect(result).toHaveLength(0);
213
213
+
});
214
214
+
215
215
+
test("handles nested slug matching", () => {
216
216
+
const posts = [
217
217
+
makePost("post-a", "at://did:plc:abc/site.standard.document/a1", {
218
218
+
content: "Check out [post](my-post)",
219
219
+
}),
220
220
+
];
221
221
+
const result = findPostsWithStaleLinks(
222
222
+
posts,
223
223
+
["blog/my-post"],
224
224
+
new Set(),
225
225
+
);
226
226
+
expect(result).toHaveLength(1);
227
227
+
});
228
228
+
229
229
+
test("does not match posts without matching links", () => {
230
230
+
const posts = [
231
231
+
makePost("post-a", "at://did:plc:abc/site.standard.document/a1", {
232
232
+
content: "Check out [post C](./post-c)",
233
233
+
}),
234
234
+
];
235
235
+
const result = findPostsWithStaleLinks(posts, ["post-b"], new Set());
236
236
+
expect(result).toHaveLength(0);
237
237
+
});
238
238
+
});
+34
-1
packages/cli/src/extensions/litenote.ts
···
27
27
}
28
28
}
29
29
30
30
-
function isLocalPath(url: string): boolean {
30
30
+
export function isLocalPath(url: string): boolean {
31
31
return (
32
32
!url.startsWith("http://") &&
33
33
!url.startsWith("https://") &&
···
250
250
validate: false,
251
251
})
252
252
}
253
253
+
254
254
+
export function findPostsWithStaleLinks(
255
255
+
allPosts: BlogPost[],
256
256
+
newSlugs: string[],
257
257
+
excludeFilePaths: Set<string>,
258
258
+
): BlogPost[] {
259
259
+
const linkRegex = /(?<![!@])\[([^\]]+)\]\(([^)]+)\)/g
260
260
+
261
261
+
return allPosts.filter((post) => {
262
262
+
if (excludeFilePaths.has(post.filePath)) return false
263
263
+
if (!post.frontmatter.atUri) return false
264
264
+
if (post.frontmatter.draft) return false
265
265
+
266
266
+
const matches = [...post.content.matchAll(linkRegex)]
267
267
+
return matches.some((match) => {
268
268
+
const url = match[2]!
269
269
+
if (!isLocalPath(url)) return false
270
270
+
271
271
+
const normalized = url
272
272
+
.replace(/^\.?\/?/, "")
273
273
+
.replace(/\/?$/, "")
274
274
+
.replace(/\.mdx?$/, "")
275
275
+
.replace(/\/index$/, "")
276
276
+
277
277
+
return newSlugs.some(
278
278
+
(slug) =>
279
279
+
slug === normalized ||
280
280
+
slug.endsWith(`/${normalized}`) ||
281
281
+
normalized.endsWith(`/${slug}`),
282
282
+
)
283
283
+
})
284
284
+
})
285
285
+
}