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