@ewanc26/svelte-standard-site 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +108 -0
- package/dist/__tests__/content.test.d.ts +4 -0
- package/dist/__tests__/content.test.js +128 -0
- package/dist/client.d.ts +71 -0
- package/dist/client.js +307 -0
- package/dist/components/Comments.svelte +277 -0
- package/dist/components/Comments.svelte.d.ts +17 -0
- package/dist/components/DocumentCard.svelte +95 -0
- package/dist/components/DocumentCard.svelte.d.ts +11 -0
- package/dist/components/PublicationCard.svelte +54 -0
- package/dist/components/PublicationCard.svelte.d.ts +9 -0
- package/dist/components/StandardSiteLayout.svelte +102 -0
- package/dist/components/StandardSiteLayout.svelte.d.ts +18 -0
- package/dist/components/ThemeToggle.svelte +55 -0
- package/dist/components/ThemeToggle.svelte.d.ts +6 -0
- package/dist/components/common/DateDisplay.svelte +38 -0
- package/dist/components/common/DateDisplay.svelte.d.ts +11 -0
- package/dist/components/common/TagList.svelte +31 -0
- package/dist/components/common/TagList.svelte.d.ts +8 -0
- package/dist/components/common/ThemedCard.svelte +65 -0
- package/dist/components/common/ThemedCard.svelte.d.ts +11 -0
- package/dist/components/common/ThemedContainer.svelte +55 -0
- package/dist/components/common/ThemedContainer.svelte.d.ts +11 -0
- package/dist/components/common/ThemedText.svelte +75 -0
- package/dist/components/common/ThemedText.svelte.d.ts +11 -0
- package/dist/components/document/BlockRenderer.svelte +67 -0
- package/dist/components/document/BlockRenderer.svelte.d.ts +9 -0
- package/dist/components/document/CanvasRenderer.svelte +41 -0
- package/dist/components/document/CanvasRenderer.svelte.d.ts +22 -0
- package/dist/components/document/DocumentRenderer.svelte +68 -0
- package/dist/components/document/DocumentRenderer.svelte.d.ts +17 -0
- package/dist/components/document/InlineMath.svelte +41 -0
- package/dist/components/document/InlineMath.svelte.d.ts +7 -0
- package/dist/components/document/LeafletContentRenderer.svelte +64 -0
- package/dist/components/document/LeafletContentRenderer.svelte.d.ts +36 -0
- package/dist/components/document/LinearDocumentRenderer.svelte +45 -0
- package/dist/components/document/LinearDocumentRenderer.svelte.d.ts +18 -0
- package/dist/components/document/MarkdownRenderer.svelte +62 -0
- package/dist/components/document/MarkdownRenderer.svelte.d.ts +10 -0
- package/dist/components/document/RichText.svelte +272 -0
- package/dist/components/document/RichText.svelte.d.ts +18 -0
- package/dist/components/document/blocks/BlockquoteBlock.svelte +29 -0
- package/dist/components/document/blocks/BlockquoteBlock.svelte.d.ts +10 -0
- package/dist/components/document/blocks/BskyPostBlock.svelte +202 -0
- package/dist/components/document/blocks/BskyPostBlock.svelte.d.ts +13 -0
- package/dist/components/document/blocks/ButtonBlock.svelte +24 -0
- package/dist/components/document/blocks/ButtonBlock.svelte.d.ts +10 -0
- package/dist/components/document/blocks/CodeBlock.svelte +68 -0
- package/dist/components/document/blocks/CodeBlock.svelte.d.ts +12 -0
- package/dist/components/document/blocks/HeaderBlock.svelte +56 -0
- package/dist/components/document/blocks/HeaderBlock.svelte.d.ts +11 -0
- package/dist/components/document/blocks/HorizontalRuleBlock.svelte +14 -0
- package/dist/components/document/blocks/HorizontalRuleBlock.svelte.d.ts +6 -0
- package/dist/components/document/blocks/IframeBlock.svelte +32 -0
- package/dist/components/document/blocks/IframeBlock.svelte.d.ts +10 -0
- package/dist/components/document/blocks/ImageBlock.svelte +55 -0
- package/dist/components/document/blocks/ImageBlock.svelte.d.ts +25 -0
- package/dist/components/document/blocks/MathBlock.svelte +34 -0
- package/dist/components/document/blocks/MathBlock.svelte.d.ts +10 -0
- package/dist/components/document/blocks/PageBlock.svelte +66 -0
- package/dist/components/document/blocks/PageBlock.svelte.d.ts +10 -0
- package/dist/components/document/blocks/PollBlock.svelte +122 -0
- package/dist/components/document/blocks/PollBlock.svelte.d.ts +27 -0
- package/dist/components/document/blocks/TextBlock.svelte +26 -0
- package/dist/components/document/blocks/TextBlock.svelte.d.ts +11 -0
- package/dist/components/document/blocks/UnorderedListBlock.svelte +71 -0
- package/dist/components/document/blocks/UnorderedListBlock.svelte.d.ts +9 -0
- package/dist/components/document/blocks/WebsiteBlock.svelte +81 -0
- package/dist/components/document/blocks/WebsiteBlock.svelte.d.ts +21 -0
- package/dist/components/index.d.ts +11 -0
- package/dist/components/index.js +13 -0
- package/dist/config/env.d.ts +11 -0
- package/dist/config/env.js +26 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +23 -0
- package/dist/publisher.d.ts +193 -0
- package/dist/publisher.js +349 -0
- package/dist/schemas.d.ts +626 -0
- package/dist/schemas.js +113 -0
- package/dist/stores/index.d.ts +1 -0
- package/dist/stores/index.js +1 -0
- package/dist/stores/theme.d.ts +11 -0
- package/dist/stores/theme.js +67 -0
- package/dist/styles/base.css +188 -0
- package/dist/styles/themes.css +5 -0
- package/dist/types.d.ts +106 -0
- package/dist/types.js +4 -0
- package/dist/utils/agents.d.ts +35 -0
- package/dist/utils/agents.js +96 -0
- package/dist/utils/at-uri.d.ts +50 -0
- package/dist/utils/at-uri.js +71 -0
- package/dist/utils/cache.d.ts +14 -0
- package/dist/utils/cache.js +33 -0
- package/dist/utils/comments.d.ts +61 -0
- package/dist/utils/comments.js +159 -0
- package/dist/utils/content.d.ts +94 -0
- package/dist/utils/content.js +178 -0
- package/dist/utils/document.d.ts +23 -0
- package/dist/utils/document.js +33 -0
- package/dist/utils/theme-helpers.d.ts +34 -0
- package/dist/utils/theme-helpers.js +63 -0
- package/dist/utils/theme.d.ts +18 -0
- package/dist/utils/theme.js +24 -0
- package/dist/utils/verification.d.ts +129 -0
- package/dist/utils/verification.js +157 -0
- package/package.json +139 -0
- package/src/lib/__tests__/content.test.ts +155 -0
- package/src/lib/client.ts +368 -0
- package/src/lib/components/Comments.svelte +277 -0
- package/src/lib/components/DocumentCard.svelte +95 -0
- package/src/lib/components/PublicationCard.svelte +54 -0
- package/src/lib/components/StandardSiteLayout.svelte +102 -0
- package/src/lib/components/ThemeToggle.svelte +55 -0
- package/src/lib/components/common/DateDisplay.svelte +38 -0
- package/src/lib/components/common/TagList.svelte +31 -0
- package/src/lib/components/common/ThemedCard.svelte +65 -0
- package/src/lib/components/common/ThemedContainer.svelte +55 -0
- package/src/lib/components/common/ThemedText.svelte +75 -0
- package/src/lib/components/document/BlockRenderer.svelte +67 -0
- package/src/lib/components/document/CanvasRenderer.svelte +41 -0
- package/src/lib/components/document/DocumentRenderer.svelte +68 -0
- package/src/lib/components/document/InlineMath.svelte +41 -0
- package/src/lib/components/document/LeafletContentRenderer.svelte +64 -0
- package/src/lib/components/document/LinearDocumentRenderer.svelte +45 -0
- package/src/lib/components/document/MarkdownRenderer.svelte +62 -0
- package/src/lib/components/document/RichText.svelte +272 -0
- package/src/lib/components/document/blocks/BlockquoteBlock.svelte +29 -0
- package/src/lib/components/document/blocks/BskyPostBlock.svelte +202 -0
- package/src/lib/components/document/blocks/ButtonBlock.svelte +24 -0
- package/src/lib/components/document/blocks/CodeBlock.svelte +68 -0
- package/src/lib/components/document/blocks/HeaderBlock.svelte +56 -0
- package/src/lib/components/document/blocks/HorizontalRuleBlock.svelte +14 -0
- package/src/lib/components/document/blocks/IframeBlock.svelte +32 -0
- package/src/lib/components/document/blocks/ImageBlock.svelte +55 -0
- package/src/lib/components/document/blocks/MathBlock.svelte +34 -0
- package/src/lib/components/document/blocks/PageBlock.svelte +66 -0
- package/src/lib/components/document/blocks/PollBlock.svelte +122 -0
- package/src/lib/components/document/blocks/TextBlock.svelte +26 -0
- package/src/lib/components/document/blocks/UnorderedListBlock.svelte +71 -0
- package/src/lib/components/document/blocks/WebsiteBlock.svelte +81 -0
- package/src/lib/components/index.ts +15 -0
- package/src/lib/config/env.ts +31 -0
- package/src/lib/index.ts +104 -0
- package/src/lib/publisher.ts +489 -0
- package/src/lib/schemas.ts +137 -0
- package/src/lib/stores/index.ts +1 -0
- package/src/lib/stores/theme.ts +80 -0
- package/src/lib/styles/base.css +188 -0
- package/src/lib/styles/themes.css +5 -0
- package/src/lib/types.ts +116 -0
- package/src/lib/utils/agents.ts +124 -0
- package/src/lib/utils/at-uri.ts +89 -0
- package/src/lib/utils/cache.ts +46 -0
- package/src/lib/utils/comments.ts +217 -0
- package/src/lib/utils/content.ts +234 -0
- package/src/lib/utils/document.ts +41 -0
- package/src/lib/utils/theme-helpers.ts +87 -0
- package/src/lib/utils/theme.ts +33 -0
- package/src/lib/utils/verification.ts +180 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AT URI parsing and conversion utilities
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Parse an AT URI into its components
|
|
6
|
+
* @param atUri - AT URI in format: at://did:plc:xxx/collection/rkey
|
|
7
|
+
* @returns Parsed components or null if invalid
|
|
8
|
+
*/
|
|
9
|
+
export function parseAtUri(atUri) {
|
|
10
|
+
if (!atUri.startsWith('at://'))
|
|
11
|
+
return null;
|
|
12
|
+
const withoutProtocol = atUri.slice(5); // Remove "at://"
|
|
13
|
+
const parts = withoutProtocol.split('/');
|
|
14
|
+
if (parts.length !== 3)
|
|
15
|
+
return null;
|
|
16
|
+
const [did, collection, rkey] = parts;
|
|
17
|
+
if (!did.startsWith('did:') || !collection || !rkey)
|
|
18
|
+
return null;
|
|
19
|
+
return { did, collection, rkey };
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Convert AT URI to HTTPS URL for com.atproto.repo.getRecord
|
|
23
|
+
* @param atUri - AT URI in format: at://did:plc:xxx/collection/rkey
|
|
24
|
+
* @param pdsEndpoint - PDS endpoint (e.g., "https://cortinarius.us-west.host.bsky.network")
|
|
25
|
+
* @returns HTTPS URL for getRecord XRPC call
|
|
26
|
+
*/
|
|
27
|
+
export function atUriToHttps(atUri, pdsEndpoint) {
|
|
28
|
+
const parsed = parseAtUri(atUri);
|
|
29
|
+
if (!parsed)
|
|
30
|
+
return null;
|
|
31
|
+
// Ensure PDS endpoint doesn't have trailing slash
|
|
32
|
+
const pds = pdsEndpoint.replace(/\/$/, '');
|
|
33
|
+
return `${pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(parsed.did)}&collection=${encodeURIComponent(parsed.collection)}&rkey=${encodeURIComponent(parsed.rkey)}`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Construct an AT URI from components
|
|
37
|
+
* @param did - DID of the repository
|
|
38
|
+
* @param collection - Collection name
|
|
39
|
+
* @param rkey - Record key
|
|
40
|
+
* @returns AT URI string
|
|
41
|
+
*/
|
|
42
|
+
export function buildAtUri(did, collection, rkey) {
|
|
43
|
+
return `at://${did}/${collection}/${rkey}`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Extract rkey from an AT URI
|
|
47
|
+
* @param atUri - AT URI
|
|
48
|
+
* @returns rkey or null if invalid
|
|
49
|
+
*/
|
|
50
|
+
export function extractRkey(atUri) {
|
|
51
|
+
const parsed = parseAtUri(atUri);
|
|
52
|
+
return parsed?.rkey ?? null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Validate if string is a valid AT URI
|
|
56
|
+
* @param uri - String to validate
|
|
57
|
+
* @returns true if valid AT URI
|
|
58
|
+
*/
|
|
59
|
+
export function isAtUri(uri) {
|
|
60
|
+
return parseAtUri(uri) !== null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Convert DID to PDS hostname format
|
|
64
|
+
* @param did - DID to convert
|
|
65
|
+
* @returns PDS hostname or null if unable to derive
|
|
66
|
+
*/
|
|
67
|
+
export function didToPdsHostname(did) {
|
|
68
|
+
// This is a simplified version - real implementation should use DID resolution
|
|
69
|
+
// For now, we'll return null and rely on explicit PDS configuration
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple in-memory cache with TTL support
|
|
3
|
+
*/
|
|
4
|
+
declare class Cache {
|
|
5
|
+
private store;
|
|
6
|
+
private defaultTTL;
|
|
7
|
+
get<T>(key: string): T | null;
|
|
8
|
+
set<T>(key: string, value: T, ttl?: number): void;
|
|
9
|
+
delete(key: string): void;
|
|
10
|
+
clear(): void;
|
|
11
|
+
setDefaultTTL(ttl: number): void;
|
|
12
|
+
}
|
|
13
|
+
export declare const cache: Cache;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple in-memory cache with TTL support
|
|
3
|
+
*/
|
|
4
|
+
class Cache {
|
|
5
|
+
store = new Map();
|
|
6
|
+
defaultTTL = 5 * 60 * 1000; // 5 minutes
|
|
7
|
+
get(key) {
|
|
8
|
+
const entry = this.store.get(key);
|
|
9
|
+
if (!entry)
|
|
10
|
+
return null;
|
|
11
|
+
if (Date.now() > entry.expires) {
|
|
12
|
+
this.store.delete(key);
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
return entry.value;
|
|
16
|
+
}
|
|
17
|
+
set(key, value, ttl) {
|
|
18
|
+
this.store.set(key, {
|
|
19
|
+
value,
|
|
20
|
+
expires: Date.now() + (ttl ?? this.defaultTTL)
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
delete(key) {
|
|
24
|
+
this.store.delete(key);
|
|
25
|
+
}
|
|
26
|
+
clear() {
|
|
27
|
+
this.store.clear();
|
|
28
|
+
}
|
|
29
|
+
setDefaultTTL(ttl) {
|
|
30
|
+
this.defaultTTL = ttl;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export const cache = new Cache();
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comments utilities for fetching Bluesky replies
|
|
3
|
+
*
|
|
4
|
+
* Fetches threaded replies from Bluesky to display as comments on your blog.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { fetchComments } from 'svelte-standard-site/comments';
|
|
9
|
+
*
|
|
10
|
+
* const comments = await fetchComments({
|
|
11
|
+
* bskyPostUri: 'at://did:plc:xxx/app.bsky.feed.post/abc123',
|
|
12
|
+
* canonicalUrl: 'https://yourblog.com/posts/my-post',
|
|
13
|
+
* maxDepth: 3,
|
|
14
|
+
* });
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export interface CommentAuthor {
|
|
18
|
+
did: string;
|
|
19
|
+
handle: string;
|
|
20
|
+
displayName?: string;
|
|
21
|
+
avatar?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface Comment {
|
|
24
|
+
uri: string;
|
|
25
|
+
cid: string;
|
|
26
|
+
author: CommentAuthor;
|
|
27
|
+
text: string;
|
|
28
|
+
createdAt: string;
|
|
29
|
+
likeCount: number;
|
|
30
|
+
replyCount: number;
|
|
31
|
+
replies?: Comment[];
|
|
32
|
+
depth: number;
|
|
33
|
+
}
|
|
34
|
+
export interface FetchCommentsOptions {
|
|
35
|
+
/** AT-URI of the announcement post (e.g., at://did:plc:xxx/app.bsky.feed.post/abc123) */
|
|
36
|
+
bskyPostUri: string;
|
|
37
|
+
/** Optional canonical URL to search for mentions */
|
|
38
|
+
canonicalUrl?: string;
|
|
39
|
+
/** Maximum depth for nested replies (default: 3) */
|
|
40
|
+
maxDepth?: number;
|
|
41
|
+
/** Optional fetch function for SSR */
|
|
42
|
+
fetchFn?: typeof fetch;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Fetch comments for a blog post
|
|
46
|
+
*
|
|
47
|
+
* @param options - Configuration options
|
|
48
|
+
* @returns Array of top-level comments with nested replies
|
|
49
|
+
*/
|
|
50
|
+
export declare function fetchComments(options: FetchCommentsOptions): Promise<Comment[]>;
|
|
51
|
+
/**
|
|
52
|
+
* Search for mentions of a URL and fetch those threads as comments
|
|
53
|
+
*
|
|
54
|
+
* This is useful if people share your blog post on Bluesky without
|
|
55
|
+
* replying to a specific announcement post.
|
|
56
|
+
*/
|
|
57
|
+
export declare function fetchMentionComments(canonicalUrl: string, maxDepth?: number): Promise<Comment[]>;
|
|
58
|
+
/**
|
|
59
|
+
* Format a relative time string (e.g., "2 hours ago")
|
|
60
|
+
*/
|
|
61
|
+
export declare function formatRelativeTime(dateString: string): string;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comments utilities for fetching Bluesky replies
|
|
3
|
+
*
|
|
4
|
+
* Fetches threaded replies from Bluesky to display as comments on your blog.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { fetchComments } from 'svelte-standard-site/comments';
|
|
9
|
+
*
|
|
10
|
+
* const comments = await fetchComments({
|
|
11
|
+
* bskyPostUri: 'at://did:plc:xxx/app.bsky.feed.post/abc123',
|
|
12
|
+
* canonicalUrl: 'https://yourblog.com/posts/my-post',
|
|
13
|
+
* maxDepth: 3,
|
|
14
|
+
* });
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
import { AtpAgent } from '@atproto/api';
|
|
18
|
+
/**
|
|
19
|
+
* Parse an AT-URI to extract components
|
|
20
|
+
*/
|
|
21
|
+
function parseAtUri(uri) {
|
|
22
|
+
const match = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
|
|
23
|
+
if (!match)
|
|
24
|
+
return null;
|
|
25
|
+
return {
|
|
26
|
+
did: match[1],
|
|
27
|
+
collection: match[2],
|
|
28
|
+
rkey: match[3]
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Fetch a single thread of replies
|
|
33
|
+
*/
|
|
34
|
+
async function fetchThread(agent, uri, maxDepth, currentDepth = 0) {
|
|
35
|
+
try {
|
|
36
|
+
const response = await agent.getPostThread({
|
|
37
|
+
uri,
|
|
38
|
+
depth: maxDepth - currentDepth,
|
|
39
|
+
parentHeight: 0
|
|
40
|
+
});
|
|
41
|
+
const thread = response.data.thread;
|
|
42
|
+
if (thread.$type !== 'app.bsky.feed.defs#threadViewPost') {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const post = thread.post;
|
|
46
|
+
// Build comment object
|
|
47
|
+
const comment = {
|
|
48
|
+
uri: post.uri,
|
|
49
|
+
cid: post.cid,
|
|
50
|
+
author: {
|
|
51
|
+
did: post.author.did,
|
|
52
|
+
handle: post.author.handle,
|
|
53
|
+
displayName: post.author.displayName,
|
|
54
|
+
avatar: post.author.avatar
|
|
55
|
+
},
|
|
56
|
+
text: post.record?.text || '',
|
|
57
|
+
createdAt: post.indexedAt,
|
|
58
|
+
likeCount: post.likeCount || 0,
|
|
59
|
+
replyCount: post.replyCount || 0,
|
|
60
|
+
depth: currentDepth,
|
|
61
|
+
replies: []
|
|
62
|
+
};
|
|
63
|
+
// Process replies if within depth limit
|
|
64
|
+
if (thread.replies && currentDepth < maxDepth) {
|
|
65
|
+
for (const reply of thread.replies) {
|
|
66
|
+
if (reply.$type === 'app.bsky.feed.defs#threadViewPost') {
|
|
67
|
+
const replyComment = await fetchThread(agent, reply.post.uri, maxDepth, currentDepth + 1);
|
|
68
|
+
if (replyComment) {
|
|
69
|
+
comment.replies.push(replyComment);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return comment;
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
console.error(`Failed to fetch thread for ${uri}:`, error);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Fetch comments for a blog post
|
|
83
|
+
*
|
|
84
|
+
* @param options - Configuration options
|
|
85
|
+
* @returns Array of top-level comments with nested replies
|
|
86
|
+
*/
|
|
87
|
+
export async function fetchComments(options) {
|
|
88
|
+
const { bskyPostUri, canonicalUrl, maxDepth = 3 } = options;
|
|
89
|
+
// Parse the post URI
|
|
90
|
+
const parsed = parseAtUri(bskyPostUri);
|
|
91
|
+
if (!parsed) {
|
|
92
|
+
console.error('Invalid AT-URI:', bskyPostUri);
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
// Create agent
|
|
97
|
+
const agent = new AtpAgent({ service: 'https://public.api.bsky.app' });
|
|
98
|
+
// Fetch the main thread
|
|
99
|
+
const mainComment = await fetchThread(agent, bskyPostUri, maxDepth, 0);
|
|
100
|
+
if (!mainComment || !mainComment.replies) {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
// Return only the replies (not the original post)
|
|
104
|
+
return mainComment.replies;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
console.error('Failed to fetch comments:', error);
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Search for mentions of a URL and fetch those threads as comments
|
|
113
|
+
*
|
|
114
|
+
* This is useful if people share your blog post on Bluesky without
|
|
115
|
+
* replying to a specific announcement post.
|
|
116
|
+
*/
|
|
117
|
+
export async function fetchMentionComments(canonicalUrl, maxDepth = 3) {
|
|
118
|
+
try {
|
|
119
|
+
const agent = new AtpAgent({ service: 'https://public.api.bsky.app' });
|
|
120
|
+
// Search for posts mentioning the URL
|
|
121
|
+
const searchResponse = await agent.app.bsky.feed.searchPosts({
|
|
122
|
+
q: canonicalUrl,
|
|
123
|
+
limit: 25
|
|
124
|
+
});
|
|
125
|
+
const comments = [];
|
|
126
|
+
for (const post of searchResponse.data.posts) {
|
|
127
|
+
const comment = await fetchThread(agent, post.uri, maxDepth, 0);
|
|
128
|
+
if (comment) {
|
|
129
|
+
comments.push(comment);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return comments;
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
console.error('Failed to fetch mention comments:', error);
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Format a relative time string (e.g., "2 hours ago")
|
|
141
|
+
*/
|
|
142
|
+
export function formatRelativeTime(dateString) {
|
|
143
|
+
const date = new Date(dateString);
|
|
144
|
+
const now = new Date();
|
|
145
|
+
const diffMs = now.getTime() - date.getTime();
|
|
146
|
+
const diffSecs = Math.floor(diffMs / 1000);
|
|
147
|
+
const diffMins = Math.floor(diffSecs / 60);
|
|
148
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
149
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
150
|
+
if (diffSecs < 60)
|
|
151
|
+
return 'just now';
|
|
152
|
+
if (diffMins < 60)
|
|
153
|
+
return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
|
|
154
|
+
if (diffHours < 24)
|
|
155
|
+
return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
|
156
|
+
if (diffDays < 7)
|
|
157
|
+
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
|
158
|
+
return date.toLocaleDateString();
|
|
159
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content transformation utilities for standard.site
|
|
3
|
+
*
|
|
4
|
+
* Converts blog post content into formats suitable for ATProto:
|
|
5
|
+
* - Strips markdown to plain text for `textContent` (search/indexing)
|
|
6
|
+
* - Converts custom sidenotes to standard markdown
|
|
7
|
+
* - Resolves relative links to absolute URLs
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { transformContent, stripToPlainText } from 'svelte-standard-site/content';
|
|
12
|
+
*
|
|
13
|
+
* const result = transformContent(markdown, {
|
|
14
|
+
* baseUrl: 'https://yourblog.com',
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* // result.markdown - cleaned markdown for ATProto
|
|
18
|
+
* // result.textContent - plain text for search
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export interface TransformOptions {
|
|
22
|
+
/** Base URL of your site (e.g., 'https://yourblog.com') */
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
/** Optional path to the current post (e.g., '/blog/my-post') */
|
|
25
|
+
postPath?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface TransformResult {
|
|
28
|
+
/** Transformed markdown suitable for ATProto */
|
|
29
|
+
markdown: string;
|
|
30
|
+
/** Plain text version for textContent field */
|
|
31
|
+
textContent: string;
|
|
32
|
+
/** Word count */
|
|
33
|
+
wordCount: number;
|
|
34
|
+
/** Estimated reading time in minutes */
|
|
35
|
+
readingTime: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Convert HTML sidenotes to markdown blockquotes
|
|
39
|
+
*
|
|
40
|
+
* Transforms:
|
|
41
|
+
* ```html
|
|
42
|
+
* <div class="sidenote sidenote--tip">
|
|
43
|
+
* <span class="sidenote-label">Tip</span>
|
|
44
|
+
* <p>Content here</p>
|
|
45
|
+
* </div>
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* Into:
|
|
49
|
+
* ```markdown
|
|
50
|
+
* > **Tip:** Content here
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export declare function convertSidenotes(markdown: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* Convert HTML sidenotes (multi-paragraph) to markdown
|
|
56
|
+
* Handles more complex sidenote structures
|
|
57
|
+
*/
|
|
58
|
+
export declare function convertComplexSidenotes(markdown: string): string;
|
|
59
|
+
/**
|
|
60
|
+
* Resolve relative URLs to absolute URLs
|
|
61
|
+
*
|
|
62
|
+
* Converts:
|
|
63
|
+
* - `[Link](/page)` → `[Link](https://example.com/page)`
|
|
64
|
+
* - `` → ``
|
|
65
|
+
*/
|
|
66
|
+
export declare function resolveRelativeLinks(markdown: string, baseUrl: string): string;
|
|
67
|
+
/**
|
|
68
|
+
* Strip markdown to plain text for indexing/search
|
|
69
|
+
*
|
|
70
|
+
* Removes:
|
|
71
|
+
* - Markdown formatting (**, *, #, etc.)
|
|
72
|
+
* - Links (keeps link text)
|
|
73
|
+
* - Images
|
|
74
|
+
* - Code blocks
|
|
75
|
+
* - HTML tags
|
|
76
|
+
*/
|
|
77
|
+
export declare function stripToPlainText(markdown: string): string;
|
|
78
|
+
/**
|
|
79
|
+
* Calculate word count from text
|
|
80
|
+
*/
|
|
81
|
+
export declare function countWords(text: string): number;
|
|
82
|
+
/**
|
|
83
|
+
* Calculate reading time in minutes (assuming 200 words per minute)
|
|
84
|
+
*/
|
|
85
|
+
export declare function calculateReadingTime(wordCount: number, wordsPerMinute?: number): number;
|
|
86
|
+
/**
|
|
87
|
+
* Full content transformation pipeline
|
|
88
|
+
*
|
|
89
|
+
* Takes raw markdown (with HTML sidenotes) and produces:
|
|
90
|
+
* - Clean markdown suitable for ATProto platforms
|
|
91
|
+
* - Plain text for search/indexing
|
|
92
|
+
* - Metadata (word count, reading time)
|
|
93
|
+
*/
|
|
94
|
+
export declare function transformContent(rawMarkdown: string, options: TransformOptions): TransformResult;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content transformation utilities for standard.site
|
|
3
|
+
*
|
|
4
|
+
* Converts blog post content into formats suitable for ATProto:
|
|
5
|
+
* - Strips markdown to plain text for `textContent` (search/indexing)
|
|
6
|
+
* - Converts custom sidenotes to standard markdown
|
|
7
|
+
* - Resolves relative links to absolute URLs
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { transformContent, stripToPlainText } from 'svelte-standard-site/content';
|
|
12
|
+
*
|
|
13
|
+
* const result = transformContent(markdown, {
|
|
14
|
+
* baseUrl: 'https://yourblog.com',
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* // result.markdown - cleaned markdown for ATProto
|
|
18
|
+
* // result.textContent - plain text for search
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Convert HTML sidenotes to markdown blockquotes
|
|
23
|
+
*
|
|
24
|
+
* Transforms:
|
|
25
|
+
* ```html
|
|
26
|
+
* <div class="sidenote sidenote--tip">
|
|
27
|
+
* <span class="sidenote-label">Tip</span>
|
|
28
|
+
* <p>Content here</p>
|
|
29
|
+
* </div>
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* Into:
|
|
33
|
+
* ```markdown
|
|
34
|
+
* > **Tip:** Content here
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function convertSidenotes(markdown) {
|
|
38
|
+
// Match sidenote divs with various formats
|
|
39
|
+
const sidenoteRegex = /<div\s+class="sidenote(?:\s+sidenote--(warning|tip))?">\s*<span\s+class="sidenote-label">([^<]+)<\/span>\s*<p>([^<]+)<\/p>\s*<\/div>/gi;
|
|
40
|
+
return markdown.replace(sidenoteRegex, (_, type, label, content) => {
|
|
41
|
+
// Clean up the content
|
|
42
|
+
const cleanContent = content.trim();
|
|
43
|
+
const cleanLabel = label.trim();
|
|
44
|
+
// Convert to blockquote with label
|
|
45
|
+
return `\n> **${cleanLabel}:** ${cleanContent}\n`;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Convert HTML sidenotes (multi-paragraph) to markdown
|
|
50
|
+
* Handles more complex sidenote structures
|
|
51
|
+
*/
|
|
52
|
+
export function convertComplexSidenotes(markdown) {
|
|
53
|
+
// First pass: simple sidenotes
|
|
54
|
+
let result = convertSidenotes(markdown);
|
|
55
|
+
// Second pass: sidenotes with multiple paragraphs or nested content
|
|
56
|
+
const complexSidenoteRegex = /<div\s+class="sidenote[^"]*">([\s\S]*?)<\/div>/gi;
|
|
57
|
+
result = result.replace(complexSidenoteRegex, (match, innerContent) => {
|
|
58
|
+
// Extract label if present
|
|
59
|
+
const labelMatch = innerContent.match(/<span\s+class="sidenote-label">([^<]+)<\/span>/i);
|
|
60
|
+
const label = labelMatch ? labelMatch[1].trim() : 'Note';
|
|
61
|
+
// Remove the label span
|
|
62
|
+
let content = innerContent.replace(/<span\s+class="sidenote-label">[^<]+<\/span>/gi, '');
|
|
63
|
+
// Convert remaining HTML to plain text with basic formatting
|
|
64
|
+
content = content
|
|
65
|
+
.replace(/<p>/gi, '')
|
|
66
|
+
.replace(/<\/p>/gi, '\n')
|
|
67
|
+
.replace(/<strong>/gi, '**')
|
|
68
|
+
.replace(/<\/strong>/gi, '**')
|
|
69
|
+
.replace(/<em>/gi, '*')
|
|
70
|
+
.replace(/<\/em>/gi, '*')
|
|
71
|
+
.replace(/<a\s+href="([^"]+)"[^>]*>([^<]+)<\/a>/gi, '[$2]($1)')
|
|
72
|
+
.replace(/<[^>]+>/g, '') // Remove any remaining HTML tags
|
|
73
|
+
.trim();
|
|
74
|
+
// Format as blockquote
|
|
75
|
+
const lines = content.split('\n').filter((line) => line.trim());
|
|
76
|
+
const quotedLines = lines.map((line, i) => i === 0 ? `> **${label}:** ${line}` : `> ${line}`);
|
|
77
|
+
return '\n' + quotedLines.join('\n') + '\n';
|
|
78
|
+
});
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Resolve relative URLs to absolute URLs
|
|
83
|
+
*
|
|
84
|
+
* Converts:
|
|
85
|
+
* - `[Link](/page)` → `[Link](https://example.com/page)`
|
|
86
|
+
* - `` → ``
|
|
87
|
+
*/
|
|
88
|
+
export function resolveRelativeLinks(markdown, baseUrl) {
|
|
89
|
+
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
|
|
90
|
+
// Match markdown links and images with relative URLs
|
|
91
|
+
// [text](/path) or 
|
|
92
|
+
const linkRegex = /(!?\[[^\]]*\])\((?!https?:\/\/|mailto:|#)([^)]+)\)/g;
|
|
93
|
+
return markdown.replace(linkRegex, (_, prefix, path) => {
|
|
94
|
+
// Ensure path starts with /
|
|
95
|
+
const absolutePath = path.startsWith('/') ? path : `/${path}`;
|
|
96
|
+
return `${prefix}(${cleanBaseUrl}${absolutePath})`;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Strip markdown to plain text for indexing/search
|
|
101
|
+
*
|
|
102
|
+
* Removes:
|
|
103
|
+
* - Markdown formatting (**, *, #, etc.)
|
|
104
|
+
* - Links (keeps link text)
|
|
105
|
+
* - Images
|
|
106
|
+
* - Code blocks
|
|
107
|
+
* - HTML tags
|
|
108
|
+
*/
|
|
109
|
+
export function stripToPlainText(markdown) {
|
|
110
|
+
let text = markdown;
|
|
111
|
+
// Remove code blocks first (preserve nothing)
|
|
112
|
+
text = text.replace(/```[\s\S]*?```/g, '');
|
|
113
|
+
text = text.replace(/`[^`]+`/g, '');
|
|
114
|
+
// Remove images
|
|
115
|
+
text = text.replace(/!\[[^\]]*\]\([^)]+\)/g, '');
|
|
116
|
+
// Convert links to just their text
|
|
117
|
+
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
|
|
118
|
+
// Remove HTML tags
|
|
119
|
+
text = text.replace(/<[^>]+>/g, '');
|
|
120
|
+
// Remove heading markers
|
|
121
|
+
text = text.replace(/^#{1,6}\s+/gm, '');
|
|
122
|
+
// Remove bold/italic markers
|
|
123
|
+
text = text.replace(/\*\*([^*]+)\*\*/g, '$1');
|
|
124
|
+
text = text.replace(/\*([^*]+)\*/g, '$1');
|
|
125
|
+
text = text.replace(/__([^_]+)__/g, '$1');
|
|
126
|
+
text = text.replace(/_([^_]+)_/g, '$1');
|
|
127
|
+
// Remove blockquote markers
|
|
128
|
+
text = text.replace(/^>\s*/gm, '');
|
|
129
|
+
// Remove list markers
|
|
130
|
+
text = text.replace(/^[\s]*[-*+]\s+/gm, '');
|
|
131
|
+
text = text.replace(/^[\s]*\d+\.\s+/gm, '');
|
|
132
|
+
// Remove horizontal rules
|
|
133
|
+
text = text.replace(/^[-*_]{3,}$/gm, '');
|
|
134
|
+
// Collapse multiple newlines
|
|
135
|
+
text = text.replace(/\n{3,}/g, '\n\n');
|
|
136
|
+
// Trim whitespace
|
|
137
|
+
text = text.trim();
|
|
138
|
+
return text;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Calculate word count from text
|
|
142
|
+
*/
|
|
143
|
+
export function countWords(text) {
|
|
144
|
+
return text.split(/\s+/).filter((word) => word.length > 0).length;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Calculate reading time in minutes (assuming 200 words per minute)
|
|
148
|
+
*/
|
|
149
|
+
export function calculateReadingTime(wordCount, wordsPerMinute = 200) {
|
|
150
|
+
return Math.max(1, Math.ceil(wordCount / wordsPerMinute));
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Full content transformation pipeline
|
|
154
|
+
*
|
|
155
|
+
* Takes raw markdown (with HTML sidenotes) and produces:
|
|
156
|
+
* - Clean markdown suitable for ATProto platforms
|
|
157
|
+
* - Plain text for search/indexing
|
|
158
|
+
* - Metadata (word count, reading time)
|
|
159
|
+
*/
|
|
160
|
+
export function transformContent(rawMarkdown, options) {
|
|
161
|
+
// Step 1: Convert sidenotes from HTML to markdown blockquotes
|
|
162
|
+
let markdown = convertComplexSidenotes(rawMarkdown);
|
|
163
|
+
// Step 2: Resolve relative links to absolute URLs
|
|
164
|
+
markdown = resolveRelativeLinks(markdown, options.baseUrl);
|
|
165
|
+
// Step 3: Clean up extra whitespace
|
|
166
|
+
markdown = markdown.replace(/\n{3,}/g, '\n\n').trim();
|
|
167
|
+
// Step 4: Generate plain text version
|
|
168
|
+
const textContent = stripToPlainText(markdown);
|
|
169
|
+
// Step 5: Calculate metadata
|
|
170
|
+
const wordCount = countWords(textContent);
|
|
171
|
+
const readingTime = calculateReadingTime(wordCount);
|
|
172
|
+
return {
|
|
173
|
+
markdown,
|
|
174
|
+
textContent,
|
|
175
|
+
wordCount,
|
|
176
|
+
readingTime
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract rkey from AT URI
|
|
3
|
+
*/
|
|
4
|
+
export declare function extractRkey(uri: string): string | null;
|
|
5
|
+
/**
|
|
6
|
+
* Get document slug (uses path if available, otherwise rkey)
|
|
7
|
+
*/
|
|
8
|
+
export declare function getDocumentSlug(document: {
|
|
9
|
+
uri: string;
|
|
10
|
+
value: {
|
|
11
|
+
path?: string;
|
|
12
|
+
};
|
|
13
|
+
}): string;
|
|
14
|
+
/**
|
|
15
|
+
* Get document canonical URL
|
|
16
|
+
*/
|
|
17
|
+
export declare function getDocumentUrl(document: {
|
|
18
|
+
value: {
|
|
19
|
+
site: string;
|
|
20
|
+
path?: string;
|
|
21
|
+
};
|
|
22
|
+
uri: string;
|
|
23
|
+
}): string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract rkey from AT URI
|
|
3
|
+
*/
|
|
4
|
+
export function extractRkey(uri) {
|
|
5
|
+
const match = uri.match(/at:\/\/[^\/]+\/[^\/]+\/(.+)$/);
|
|
6
|
+
return match ? match[1] : null;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Get document slug (uses path if available, otherwise rkey)
|
|
10
|
+
*/
|
|
11
|
+
export function getDocumentSlug(document) {
|
|
12
|
+
if (document.value.path) {
|
|
13
|
+
// Remove leading slash if present
|
|
14
|
+
return document.value.path.replace(/^\//, '');
|
|
15
|
+
}
|
|
16
|
+
return extractRkey(document.uri) || '';
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Get document canonical URL
|
|
20
|
+
*/
|
|
21
|
+
export function getDocumentUrl(document) {
|
|
22
|
+
const { site, path } = document.value;
|
|
23
|
+
// If site is an AT URI, we'll use our local route
|
|
24
|
+
if (site.startsWith('at://')) {
|
|
25
|
+
return `/documents/${getDocumentSlug(document)}`;
|
|
26
|
+
}
|
|
27
|
+
// If site is HTTPS and we have a path, combine them
|
|
28
|
+
if (path) {
|
|
29
|
+
return `${site.replace(/\/$/, '')}/${path.replace(/^\//, '')}`;
|
|
30
|
+
}
|
|
31
|
+
// Fallback to local route
|
|
32
|
+
return `/documents/${extractRkey(document.uri)}`;
|
|
33
|
+
}
|