+43
CHANGELOG.md
+43
CHANGELOG.md
···
1
1
# leaflet-loader-astro
2
2
3
+
## 1.3.0
4
+
5
+
### Minor Changes
6
+
7
+
- eb3bc4b: Add iframe block
8
+
9
+
## 1.2.0
10
+
11
+
### Minor Changes
12
+
13
+
- f920153: Add support for blockquotes
14
+
15
+
### Patch Changes
16
+
17
+
- 8922bb1: Add JSDoc comments for available loader options for leafletStaticLoader and leafletLiveLoader
18
+
19
+
## 1.1.0
20
+
21
+
### Minor Changes
22
+
23
+
- 6d70cc6: Added support for these leaflet blocks:
24
+
25
+
- ul/li
26
+
- math
27
+
- code
28
+
- img
29
+
- hr
30
+
31
+
the only remaining block to implement is "website", though I haven't thought of a good way to output that yet. stay tuned for a further release
32
+
33
+
- 5524ce5: Added the ability to use a handle or did when specifying a repo for leafletStaticLoader and leafletLiveLoader
34
+
35
+
```ts
36
+
import { defineLiveCollection } from "astro:content";
37
+
import { leafletLiveLoader } from "leaflet-loader-astro";
38
+
39
+
const documents = defineLiveCollection({
40
+
loader: leafletLiveLoader({ repo: "dane.computer" }), // or repo: did:plc:qttsv4e7pu2jl3ilanfgc3zn, both work!
41
+
});
42
+
43
+
export const collections = { documents };
44
+
```
45
+
3
46
## 1.0.0
4
47
5
48
### Major Changes
+60
-7
README.md
+60
-7
README.md
···
10
10
## Installation
11
11
12
12
```bash
13
-
npm install leaflet-loader-astro
13
+
npm install @nulfrost/leaflet-loader-astro
14
14
```
15
15
16
16
## Usage
17
17
18
-
### Build-time loader: leafletStaticLoader (recommended)
18
+
<details>
19
+
<summary>Build-time loader: leafletStaticLoader **(recommended)**</summary>
19
20
20
21
```ts
21
22
// src/content.config.ts
22
23
import { defineCollection, z } from "astro:content";
23
-
import { leafletStaticLoader } from "leaflet-loader-astro";
24
+
import { leafletStaticLoader } from "@nulfrost/leaflet-loader-astro";
24
25
25
26
const documents = defineCollection({
26
-
loader: leafletStaticLoader({ repo: "did:plc:qttsv4e7pu2jl3ilanfgc3zn" }),
27
+
loader: leafletStaticLoader({ repo: "did:plc:qttsv4e7pu2jl3ilanfgc3zn" }), // or repo: dane.is.extraordinarily.cool
27
28
});
28
29
29
30
export const collections = { documents };
···
81
82
82
83
<Content />
83
84
```
85
+
</details>
84
86
85
-
### Live loader: leafletLiveLoader
87
+
<details>
88
+
<summary>Live loader: leafletLiveLoader</summary>
86
89
87
90
```ts
88
91
// astro.config.mjs
···
101
104
```ts
102
105
// src/live.config.ts
103
106
import { defineLiveCollection, z } from "astro:content";
104
-
import { leafletLiveLoader } from "leaflet-loader-astro";
107
+
import { leafletLiveLoader } from "@nulfrost/leaflet-loader-astro";
105
108
106
109
const documents = defineLiveCollection({
107
-
loader: leafletLiveLoader({ repo: "did:plc:qttsv4e7pu2jl3ilanfgc3zn" }),
110
+
loader: leafletLiveLoader({ repo: "did:plc:qttsv4e7pu2jl3ilanfgc3zn" }), // or repo: dane.is.extraordinarily.cool
108
111
});
109
112
110
113
export const collections = { documents };
···
158
161
159
162
<Content />
160
163
```
164
+
165
+
</details>
166
+
167
+
## Loader Options
168
+
169
+
### Static Loader
170
+
171
+
```ts
172
+
leafletStaticLoader()
173
+
```
174
+
175
+
`repo`: This can be either your DID (did:plc:qttsv4e7pu2jl3ilanfgc3zn) or your handle (dane.is.extraordinarily.cool)
176
+
177
+
`limit`: How many leaflet documents to return when calling `getCollection`. The default is 50 and the range is from 1 to 100.
178
+
179
+
`reverse`: Whether or not to return the leaflet documents in reverse order. By default this is false.
180
+
181
+
### Live Loader
182
+
183
+
```ts
184
+
leafletLiveLoader()
185
+
```
186
+
187
+
`repo`: This can be either your DID (did:plc:qttsv4e7pu2jl3ilanfgc3zn) or your handle (dane.is.extraordinarily.cool)
188
+
189
+
> [!NOTE]
190
+
> `getLiveCollection` supports a second argument where you can add additional filters, similar to the options you have access to for `leafletStaticLoader`
191
+
192
+
```ts
193
+
getLiveCollection()
194
+
```
195
+
196
+
`limit`: How many leaflet documents to return when calling `getCollection`. The default is 50 and the range is from 1 to 100.
197
+
198
+
`reverse`: Whether or not to return the leaflet documents in reverse order. By default this is false.
199
+
200
+
## Supported Leaflet Blocks
201
+
202
+
- [ ] Bluesky post
203
+
- [x] Iframe
204
+
- [x] Horizontal Rule
205
+
- [x] Unordered List
206
+
- [x] Math
207
+
- [x] Code
208
+
- [ ] Website
209
+
- [x] Image
210
+
- [x] Blockquote
211
+
- [x] Text
212
+
- [x] Header
213
+
- [x] List Item
161
214
162
215
## License
163
216
+1
-1
lex.config.js
+1
-1
lex.config.js
+22
lexicons/pub/leaflet/blocks/blockquote.json
+22
lexicons/pub/leaflet/blocks/blockquote.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "pub.leaflet.blocks.blockquote",
4
+
"defs": {
5
+
"main": {
6
+
"type": "object",
7
+
"required": ["plaintext"],
8
+
"properties": {
9
+
"plaintext": {
10
+
"type": "string"
11
+
},
12
+
"facets": {
13
+
"type": "array",
14
+
"items": {
15
+
"type": "ref",
16
+
"ref": "pub.leaflet.richtext.facet"
17
+
}
18
+
}
19
+
}
20
+
}
21
+
}
22
+
}
+11
lexicons/pub/leaflet/blocks/horizontalRule.json
+11
lexicons/pub/leaflet/blocks/horizontalRule.json
+21
lexicons/pub/leaflet/blocks/iframe.json
+21
lexicons/pub/leaflet/blocks/iframe.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "pub.leaflet.blocks.iframe",
4
+
"defs": {
5
+
"main": {
6
+
"type": "object",
7
+
"required": ["url"],
8
+
"properties": {
9
+
"url": {
10
+
"type": "string",
11
+
"format": "uri"
12
+
},
13
+
"height": {
14
+
"type": "integer",
15
+
"minimum": 16,
16
+
"maximum": 1600
17
+
}
18
+
}
19
+
}
20
+
}
21
+
}
+24
-19
lib/leaflet-live-loader.ts
+24
-19
lib/leaflet-live-loader.ts
···
1
-
import { Agent } from "@atproto/api";
2
-
import { isDid } from "@atproto/did";
1
+
import { Client, simpleFetchHandler } from "@atcute/client";
2
+
import { isHandle } from "@atcute/lexicons/syntax";
3
3
import type { LiveLoader } from "astro/loaders";
4
4
import type {
5
5
CollectionFilter,
···
11
11
import {
12
12
getLeafletDocuments,
13
13
getSingleLeafletDocument,
14
+
isPlcDid,
14
15
leafletBlocksToHTML,
15
16
leafletDocumentRecordToView,
16
17
LiveLoaderError,
···
35
36
);
36
37
}
37
38
38
-
// not a valid did
39
-
if (!isDid(repo)) {
40
-
throw new LiveLoaderError("invalid did", "INVALID_DID");
39
+
// not a valid handle, check if valid did
40
+
if (!isHandle(repo)) {
41
+
// not a valid handle or did, throw
42
+
if (!isPlcDid(repo)) {
43
+
throw new LiveLoaderError(
44
+
"invalid handle or did",
45
+
"INVALID_HANDLE_OR_DID",
46
+
);
47
+
}
41
48
}
42
49
43
50
return {
44
51
name: "leaflet-loader-astro",
45
52
loadCollection: async ({ filter }) => {
46
53
try {
47
-
const pds_url = await resolveMiniDoc(repo);
48
-
const agent = new Agent({ service: pds_url });
54
+
const { pds, did } = await resolveMiniDoc(repo);
55
+
const handler = simpleFetchHandler({ service: pds });
56
+
const rpc = new Client({ handler });
49
57
50
58
const { documents } = await getLeafletDocuments({
51
-
agent,
59
+
rpc,
52
60
repo,
53
61
reverse: filter?.reverse,
54
62
cursor: filter?.cursor,
···
67
75
}),
68
76
rendered: {
69
77
html: leafletBlocksToHTML({
70
-
id,
71
-
uri: document.uri,
72
-
cid: document.cid,
73
-
value: document.value as unknown as LeafletDocumentRecord,
78
+
record: document.value as unknown as LeafletDocumentRecord,
79
+
did,
74
80
}),
75
81
},
76
82
};
···
95
101
};
96
102
}
97
103
try {
98
-
const pds_url = await resolveMiniDoc(repo);
99
-
const agent = new Agent({ service: pds_url });
104
+
const { pds, did } = await resolveMiniDoc(repo);
105
+
const handler = simpleFetchHandler({ service: pds });
106
+
const rpc = new Client({ handler });
100
107
const document = await getSingleLeafletDocument({
101
-
agent,
108
+
rpc,
102
109
id: filter.id,
103
110
repo,
104
111
});
···
114
121
}),
115
122
rendered: {
116
123
html: leafletBlocksToHTML({
117
-
id: filter.id,
118
-
uri: document.uri,
119
-
cid,
120
-
value: document.value as unknown as LeafletDocumentRecord,
124
+
record: document.value as unknown as LeafletDocumentRecord,
125
+
did,
121
126
}),
122
127
},
123
128
};
+24
-17
lib/leaftlet-static-loader.ts
+24
-17
lib/leaftlet-static-loader.ts
···
1
-
import { Agent } from "@atproto/api";
2
-
import { isDid } from "@atproto/did";
1
+
import { Client, simpleFetchHandler } from "@atcute/client";
2
+
import { isHandle } from "@atcute/lexicons/syntax";
3
3
import type { Loader, LoaderContext } from "astro/loaders";
4
4
import { LeafletDocumentSchema } from "schema.js";
5
5
import type {
···
7
7
StaticLeafletLoaderOptions,
8
8
} from "types.js";
9
9
import {
10
+
getLeafletDocuments,
11
+
isPlcDid,
12
+
leafletBlocksToHTML,
13
+
leafletDocumentRecordToView,
10
14
LiveLoaderError,
11
15
resolveMiniDoc,
12
-
getLeafletDocuments,
13
16
uriToRkey,
14
-
leafletDocumentRecordToView,
15
-
leafletBlocksToHTML,
16
17
} from "utils.js";
17
18
18
19
export function leafletStaticLoader(
19
20
options: StaticLeafletLoaderOptions,
20
21
): Loader {
21
-
const { repo, limit } = options;
22
+
const { repo, limit, reverse } = options;
22
23
23
24
if (!repo || typeof repo !== "string") {
24
25
throw new LiveLoaderError(
···
27
28
);
28
29
}
29
30
30
-
// not a valid did
31
-
if (!isDid(repo)) {
32
-
throw new LiveLoaderError("invalid did", "INVALID_DID");
31
+
// not a valid handle, check if valid did
32
+
if (!isHandle(repo)) {
33
+
// not a valid handle or did, throw
34
+
if (!isPlcDid(repo)) {
35
+
throw new LiveLoaderError(
36
+
"invalid handle or did",
37
+
"INVALID_HANDLE_OR_DID",
38
+
);
39
+
}
33
40
}
34
41
35
42
return {
···
43
50
}: LoaderContext) => {
44
51
try {
45
52
logger.info("fetching latest leaflet documents");
46
-
const pds_url = await resolveMiniDoc(repo);
47
-
const agent = new Agent({ service: pds_url });
53
+
const { pds, did } = await resolveMiniDoc(repo);
54
+
const handler = simpleFetchHandler({ service: pds });
55
+
const rpc = new Client({ handler });
48
56
49
57
let cursor: string | undefined;
50
58
let count = 0;
···
52
60
fetching: do {
53
61
const { documents, cursor: documentsCursor } =
54
62
await getLeafletDocuments({
55
-
agent,
63
+
rpc,
56
64
repo,
57
65
cursor,
58
-
limit: 100,
66
+
reverse,
67
+
limit: 50,
59
68
});
60
69
for (const document of documents) {
61
70
if (limit && count >= limit) {
···
83
92
digest,
84
93
rendered: {
85
94
html: leafletBlocksToHTML({
86
-
id,
87
-
uri: document.uri,
88
-
cid: document.cid,
89
-
value: document.value as unknown as LeafletDocumentRecord,
95
+
record: document.value as unknown as LeafletDocumentRecord,
96
+
did,
90
97
}),
91
98
},
92
99
});
+3
lib/lexicons/index.ts
+3
lib/lexicons/index.ts
···
1
1
export * as ComAtprotoRepoStrongRef from "./types/com/atproto/repo/strongRef.js";
2
+
export * as PubLeafletBlocksBlockquote from "./types/pub/leaflet/blocks/blockquote.js";
2
3
export * as PubLeafletBlocksCode from "./types/pub/leaflet/blocks/code.js";
3
4
export * as PubLeafletBlocksHeader from "./types/pub/leaflet/blocks/header.js";
5
+
export * as PubLeafletBlocksHorizontalRule from "./types/pub/leaflet/blocks/horizontalRule.js";
6
+
export * as PubLeafletBlocksIframe from "./types/pub/leaflet/blocks/iframe.js";
4
7
export * as PubLeafletBlocksImage from "./types/pub/leaflet/blocks/image.js";
5
8
export * as PubLeafletBlocksMath from "./types/pub/leaflet/blocks/math.js";
6
9
export * as PubLeafletBlocksText from "./types/pub/leaflet/blocks/text.js";
+23
lib/lexicons/types/pub/leaflet/blocks/blockquote.ts
+23
lib/lexicons/types/pub/leaflet/blocks/blockquote.ts
···
1
+
import type {} from "@atcute/lexicons";
2
+
import * as v from "@atcute/lexicons/validations";
3
+
import * as PubLeafletRichtextFacet from "../richtext/facet.js";
4
+
5
+
const _mainSchema = /*#__PURE__*/ v.object({
6
+
$type: /*#__PURE__*/ v.optional(
7
+
/*#__PURE__*/ v.literal("pub.leaflet.blocks.blockquote"),
8
+
),
9
+
get facets() {
10
+
return /*#__PURE__*/ v.optional(
11
+
/*#__PURE__*/ v.array(PubLeafletRichtextFacet.mainSchema),
12
+
);
13
+
},
14
+
plaintext: /*#__PURE__*/ v.string(),
15
+
});
16
+
17
+
type main$schematype = typeof _mainSchema;
18
+
19
+
export interface mainSchema extends main$schematype {}
20
+
21
+
export const mainSchema = _mainSchema as mainSchema;
22
+
23
+
export interface Main extends v.InferInput<typeof mainSchema> {}
+16
lib/lexicons/types/pub/leaflet/blocks/horizontalRule.ts
+16
lib/lexicons/types/pub/leaflet/blocks/horizontalRule.ts
···
1
+
import type {} from "@atcute/lexicons";
2
+
import * as v from "@atcute/lexicons/validations";
3
+
4
+
const _mainSchema = /*#__PURE__*/ v.object({
5
+
$type: /*#__PURE__*/ v.optional(
6
+
/*#__PURE__*/ v.literal("pub.leaflet.blocks.horizontalRule"),
7
+
),
8
+
});
9
+
10
+
type main$schematype = typeof _mainSchema;
11
+
12
+
export interface mainSchema extends main$schematype {}
13
+
14
+
export const mainSchema = _mainSchema as mainSchema;
15
+
16
+
export interface Main extends v.InferInput<typeof mainSchema> {}
+22
lib/lexicons/types/pub/leaflet/blocks/iframe.ts
+22
lib/lexicons/types/pub/leaflet/blocks/iframe.ts
···
1
+
import type {} from "@atcute/lexicons";
2
+
import * as v from "@atcute/lexicons/validations";
3
+
4
+
const _mainSchema = /*#__PURE__*/ v.object({
5
+
$type: /*#__PURE__*/ v.optional(
6
+
/*#__PURE__*/ v.literal("pub.leaflet.blocks.iframe"),
7
+
),
8
+
height: /*#__PURE__*/ v.optional(
9
+
/*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [
10
+
/*#__PURE__*/ v.integerRange(16, 1600),
11
+
]),
12
+
),
13
+
url: /*#__PURE__*/ v.genericUriString(),
14
+
});
15
+
16
+
type main$schematype = typeof _mainSchema;
17
+
18
+
export interface mainSchema extends main$schematype {}
19
+
20
+
export const mainSchema = _mainSchema as mainSchema;
21
+
22
+
export interface Main extends v.InferInput<typeof mainSchema> {}
+21
-8
lib/types.ts
+21
-8
lib/types.ts
···
1
-
import type { Agent } from "@atproto/api";
1
+
import type { Client } from "@atcute/client";
2
+
import type { ActorIdentifier } from "@atcute/lexicons";
3
+
import type { XRPCProcedures, XRPCQueries } from "@atcute/lexicons/ambient";
2
4
import type { PubLeafletRichtextFacet } from "./lexicons/index.js";
3
5
4
6
export interface LiveLeafletLoaderOptions {
5
7
/**
6
-
* @description Your repo is your DID (did:plc... or did:web...). You can find this information using: https://pdsls.dev
8
+
* @description Your repo is your DID (did:plc... or did:web...) or handle (username.bsky.social). You can find this information using: https://pdsls.dev
7
9
*/
8
10
repo: string;
9
11
}
10
12
11
13
export interface StaticLeafletLoaderOptions {
12
14
/**
13
-
* @description Your repo is your DID (did:plc... or did:web...). You can find this information using: https://pdsls.dev
15
+
* @description Your repo is your DID (did:plc... or did:web...) or handle (username.bsky.social). You can find this information using: https://pdsls.dev
14
16
*/
15
17
repo: string;
16
-
filter?: string;
17
18
/**
19
+
* @description The number of records leaflet records to return for getCollection, the default being 50. The range can be from 1 to 100.
18
20
* @default 50
19
21
*/
20
22
limit?: number;
23
+
/**
24
+
* @description Whether or not the records should be returned in reverse order.
25
+
* @default undefined
26
+
*/
27
+
reverse?: boolean;
21
28
}
22
29
23
30
export interface LeafletDocumentRecord {
···
58
65
}
59
66
60
67
export interface GetLeafletDocumentsParams {
61
-
repo: string;
62
-
agent: Agent;
68
+
repo: ActorIdentifier;
69
+
rpc: Client<XRPCQueries, XRPCProcedures>;
63
70
cursor?: string;
64
71
limit?: number;
65
72
reverse?: boolean;
66
73
}
67
74
68
75
export interface GetSingleLeafletDocumentParams {
69
-
repo: string;
70
-
agent: Agent;
76
+
repo: ActorIdentifier;
77
+
rpc: Client<XRPCQueries, XRPCProcedures>;
71
78
id: string;
72
79
}
73
80
···
76
83
text: string;
77
84
facet?: Exclude<Facet["features"], { $type: string }>;
78
85
}
86
+
87
+
// yoinked from: https://github.com/mary-ext/atcute/blob/trunk/packages/lexicons/lexicons/lib/syntax/handle.ts
88
+
/**
89
+
* represents a decentralized identifier (DID).
90
+
*/
91
+
export type Did<Method extends string = string> = `did:${Method}:${string}`;
+215
-107
lib/utils.ts
+215
-107
lib/utils.ts
···
1
+
import type {} from "@atcute/atproto";
1
2
import { is } from "@atcute/lexicons";
2
3
import { AtUri, UnicodeString } from "@atproto/api";
4
+
import katex from "katex";
3
5
import sanitizeHTML from "sanitize-html";
4
6
import {
7
+
PubLeafletBlocksBlockquote,
8
+
PubLeafletBlocksCode,
5
9
PubLeafletBlocksHeader,
10
+
PubLeafletBlocksHorizontalRule,
11
+
PubLeafletBlocksIframe,
12
+
PubLeafletBlocksImage,
13
+
PubLeafletBlocksMath,
6
14
PubLeafletBlocksText,
15
+
PubLeafletBlocksUnorderedList,
7
16
PubLeafletPagesLinearDocument,
8
17
} from "./lexicons/index.js";
9
18
import type {
19
+
Did,
10
20
Facet,
11
21
GetLeafletDocumentsParams,
12
22
GetSingleLeafletDocumentParams,
···
29
39
export function uriToRkey(uri: string): string {
30
40
const u = AtUri.make(uri);
31
41
if (!u.rkey) {
32
-
throw new Error("Failed to get rkey from uri.");
42
+
throw new Error("failed to get rkey");
33
43
}
34
44
return u.rkey;
35
45
}
···
47
57
}
48
58
const data = (await response.json()) as MiniDoc;
49
59
50
-
return data.pds;
60
+
return {
61
+
pds: data.pds,
62
+
did: data.did,
63
+
};
51
64
} catch {
52
65
throw new Error(`failed to resolve handle: ${handleOrDid}`);
53
66
}
···
57
70
repo,
58
71
reverse,
59
72
cursor,
60
-
agent,
73
+
rpc,
61
74
limit,
62
75
}: GetLeafletDocumentsParams) {
63
-
const response = await agent.com.atproto.repo.listRecords({
64
-
repo,
65
-
collection: "pub.leaflet.document",
66
-
cursor,
67
-
reverse,
68
-
limit,
76
+
const { ok, data } = await rpc.get("com.atproto.repo.listRecords", {
77
+
params: {
78
+
collection: "pub.leaflet.document",
79
+
cursor,
80
+
reverse,
81
+
limit,
82
+
repo,
83
+
},
69
84
});
70
85
71
-
if (response.success === false) {
86
+
if (!ok) {
72
87
throw new LiveLoaderError(
73
88
"error fetching leaflet documents",
74
89
"DOCUMENT_FETCH_ERROR",
···
76
91
}
77
92
78
93
return {
79
-
documents: response?.data?.records,
80
-
cursor: response?.data?.cursor,
94
+
documents: data?.records,
95
+
cursor: data?.cursor,
81
96
};
82
97
}
83
98
84
99
export async function getSingleLeafletDocument({
85
-
agent,
100
+
rpc,
86
101
repo,
87
102
id,
88
103
}: GetSingleLeafletDocumentParams) {
89
-
const response = await agent.com.atproto.repo.getRecord({
90
-
repo,
91
-
collection: "pub.leaflet.document",
92
-
rkey: id,
104
+
const { ok, data } = await rpc.get("com.atproto.repo.getRecord", {
105
+
params: {
106
+
collection: "pub.leaflet.document",
107
+
repo,
108
+
rkey: id,
109
+
},
93
110
});
94
111
95
-
if (response.success === false) {
112
+
if (!ok) {
96
113
throw new LiveLoaderError(
97
114
"error fetching single document",
98
115
"DOCUMENT_FETCH_ERROR",
99
116
);
100
117
}
101
118
102
-
return response?.data;
119
+
return data;
103
120
}
104
121
105
122
export function leafletDocumentRecordToView({
···
122
139
};
123
140
}
124
141
125
-
export function leafletBlocksToHTML(record: {
126
-
id: string;
127
-
uri: string;
128
-
cid: string;
129
-
value: LeafletDocumentRecord;
142
+
export function leafletBlocksToHTML({
143
+
record,
144
+
did,
145
+
}: {
146
+
record: LeafletDocumentRecord;
147
+
did: string;
130
148
}) {
131
149
let html = "";
132
-
const firstPage = record.value.pages[0];
150
+
const firstPage = record.pages[0];
133
151
let blocks: PubLeafletPagesLinearDocument.Block[] = [];
152
+
134
153
if (is(PubLeafletPagesLinearDocument.mainSchema, firstPage)) {
135
154
blocks = firstPage.blocks || [];
136
155
}
137
156
138
157
for (const block of blocks) {
139
-
if (is(PubLeafletBlocksText.mainSchema, block.block)) {
140
-
const rt = new RichText({
141
-
text: block.block.plaintext,
142
-
facets: block.block.facets || [],
143
-
});
144
-
const children = [];
145
-
for (const segment of rt.segments()) {
146
-
const link = segment.facet?.find(
147
-
(segment) => segment.$type === "pub.leaflet.richtext.facet#link",
148
-
);
149
-
const isBold = segment.facet?.find(
150
-
(segment) => segment.$type === "pub.leaflet.richtext.facet#bold",
151
-
);
152
-
const isCode = segment.facet?.find(
153
-
(segment) => segment.$type === "pub.leaflet.richtext.facet#code",
154
-
);
155
-
const isStrikethrough = segment.facet?.find(
156
-
(segment) =>
157
-
segment.$type === "pub.leaflet.richtext.facet#strikethrough",
158
-
);
159
-
const isUnderline = segment.facet?.find(
160
-
(segment) => segment.$type === "pub.leaflet.richtext.facet#underline",
161
-
);
162
-
const isItalic = segment.facet?.find(
163
-
(segment) => segment.$type === "pub.leaflet.richtext.facet#italic",
164
-
);
165
-
if (isCode) {
166
-
children.push(` <code>
167
-
${segment.text}
168
-
</code>`);
169
-
} else if (link) {
170
-
children.push(
171
-
` <a
172
-
href="${link.uri}"
173
-
target="_blank"
174
-
>
175
-
${segment.text}
176
-
</a>`,
177
-
);
178
-
} else if (isBold) {
179
-
children.push(`<b>${segment.text}</b>`);
180
-
} else if (isStrikethrough) {
181
-
children.push(`<s>${segment.text}</s>`);
182
-
} else if (isUnderline) {
183
-
children.push(
184
-
`<span style="text-decoration:underline;">${segment.text}</span>`,
185
-
);
186
-
} else if (isItalic) {
187
-
children.push(`<i>${segment.text}</i>`);
188
-
} else {
189
-
children.push(
190
-
`
191
-
${segment.text}
192
-
`,
193
-
);
194
-
}
195
-
}
196
-
html += `<p>${children.join("\n")}</p>`;
197
-
}
198
-
199
-
if (is(PubLeafletBlocksHeader.mainSchema, block.block)) {
200
-
if (block.block.level === 1) {
201
-
html += `<h2>${block.block.plaintext}</h2>`;
202
-
}
203
-
}
204
-
if (is(PubLeafletBlocksHeader.mainSchema, block.block)) {
205
-
if (block.block.level === 2) {
206
-
html += `<h3>${block.block.plaintext}</h3>`;
207
-
}
208
-
}
209
-
if (is(PubLeafletBlocksHeader.mainSchema, block.block)) {
210
-
if (block.block.level === 3) {
211
-
html += `<h4>${block.block.plaintext}</h4>`;
212
-
}
213
-
}
214
-
if (is(PubLeafletBlocksHeader.mainSchema, block.block)) {
215
-
if (!block.block.level) {
216
-
html += `<h6>${block.block.plaintext}</h6>`;
217
-
}
218
-
}
158
+
html += parseBlocks({ block, did });
219
159
}
220
160
221
-
return sanitizeHTML(html);
161
+
return sanitizeHTML(html, {
162
+
allowedAttributes: {
163
+
"*": ["class", "style"],
164
+
img: ["src", "height", "width", "alt"],
165
+
a: ["href", "target", "rel"],
166
+
iframe: ["height", "allow", "loading", "src"],
167
+
},
168
+
allowedTags: [
169
+
"img",
170
+
"pre",
171
+
"code",
172
+
"p",
173
+
"a",
174
+
"b",
175
+
"s",
176
+
"ul",
177
+
"li",
178
+
"i",
179
+
"h1",
180
+
"h2",
181
+
"h3",
182
+
"h4",
183
+
"h5",
184
+
"h6",
185
+
"hr",
186
+
"div",
187
+
"span",
188
+
"blockquote",
189
+
"iframe",
190
+
],
191
+
selfClosing: ["img"],
192
+
});
222
193
}
223
194
224
195
export class RichText {
225
196
unicodeText: UnicodeString;
226
197
facets?: Facet[];
227
-
228
198
constructor(props: { text: string; facets: Facet[] }) {
229
199
this.unicodeText = new UnicodeString(props.text);
230
200
this.facets = props.facets;
···
278
248
}
279
249
}
280
250
}
251
+
252
+
export function parseTextBlock(block: PubLeafletBlocksText.Main) {
253
+
let html = "";
254
+
const rt = new RichText({
255
+
text: block.plaintext,
256
+
facets: block.facets || [],
257
+
});
258
+
const children = [];
259
+
for (const segment of rt.segments()) {
260
+
const link = segment.facet?.find(
261
+
(segment) => segment.$type === "pub.leaflet.richtext.facet#link",
262
+
);
263
+
const isBold = segment.facet?.find(
264
+
(segment) => segment.$type === "pub.leaflet.richtext.facet#bold",
265
+
);
266
+
const isCode = segment.facet?.find(
267
+
(segment) => segment.$type === "pub.leaflet.richtext.facet#code",
268
+
);
269
+
const isStrikethrough = segment.facet?.find(
270
+
(segment) => segment.$type === "pub.leaflet.richtext.facet#strikethrough",
271
+
);
272
+
const isUnderline = segment.facet?.find(
273
+
(segment) => segment.$type === "pub.leaflet.richtext.facet#underline",
274
+
);
275
+
const isItalic = segment.facet?.find(
276
+
(segment) => segment.$type === "pub.leaflet.richtext.facet#italic",
277
+
);
278
+
if (isCode) {
279
+
children.push(`<pre><code>${segment.text}</code></pre>`);
280
+
} else if (link) {
281
+
children.push(
282
+
`<a href="${link.uri}" target="_blank" rel="noopener noreferrer">${segment.text}</a>`,
283
+
);
284
+
} else if (isBold) {
285
+
children.push(`<b>${segment.text}</b>`);
286
+
} else if (isStrikethrough) {
287
+
children.push(`<s>${segment.text}</s>`);
288
+
} else if (isUnderline) {
289
+
children.push(
290
+
`<span style="text-decoration:underline;">${segment.text}</span>`,
291
+
);
292
+
} else if (isItalic) {
293
+
children.push(`<i>${segment.text}</i>`);
294
+
} else {
295
+
children.push(`${segment.text}`);
296
+
}
297
+
}
298
+
html += `<p>${children.join("")}</p>`;
299
+
300
+
return html.trim();
301
+
}
302
+
303
+
export function parseBlocks({
304
+
block,
305
+
did,
306
+
}: {
307
+
block: PubLeafletPagesLinearDocument.Block;
308
+
did: string;
309
+
}): string {
310
+
let html = "";
311
+
312
+
if (is(PubLeafletBlocksText.mainSchema, block.block)) {
313
+
html += parseTextBlock(block.block);
314
+
}
315
+
316
+
if (is(PubLeafletBlocksHeader.mainSchema, block.block)) {
317
+
if (block.block.level === 1) {
318
+
html += `<h2>${block.block.plaintext}</h2>`;
319
+
}
320
+
}
321
+
if (is(PubLeafletBlocksHeader.mainSchema, block.block)) {
322
+
if (block.block.level === 2) {
323
+
html += `<h3>${block.block.plaintext}</h3>`;
324
+
}
325
+
}
326
+
if (is(PubLeafletBlocksHeader.mainSchema, block.block)) {
327
+
if (block.block.level === 3) {
328
+
html += `<h4>${block.block.plaintext}</h4>`;
329
+
}
330
+
}
331
+
if (is(PubLeafletBlocksHeader.mainSchema, block.block)) {
332
+
if (!block.block.level) {
333
+
html += `<h6>${block.block.plaintext}</h6>`;
334
+
}
335
+
}
336
+
337
+
if (is(PubLeafletBlocksHorizontalRule.mainSchema, block.block)) {
338
+
html += `<hr />`;
339
+
}
340
+
if (is(PubLeafletBlocksUnorderedList.mainSchema, block.block)) {
341
+
html += `<ul>${block.block.children.map((child) => renderListItem({ item: child, did })).join("")}</ul>`;
342
+
}
343
+
344
+
if (is(PubLeafletBlocksMath.mainSchema, block.block)) {
345
+
html += `<div>${katex.renderToString(block.block.tex, { displayMode: true, output: "html", throwOnError: false })}</div>`;
346
+
}
347
+
348
+
if (is(PubLeafletBlocksCode.mainSchema, block.block)) {
349
+
html += `<pre><code data-language=${block.block.language}>${block.block.plaintext}</code></pre>`;
350
+
}
351
+
352
+
if (is(PubLeafletBlocksImage.mainSchema, block.block)) {
353
+
// @ts-ignore
354
+
html += `<div><img src="https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${block.block.image.ref.$link}@jpeg" height="${block.block.aspectRatio.height}" width="${block.block.aspectRatio.width}" alt="${block.block.alt}" /></div>`;
355
+
}
356
+
357
+
if (is(PubLeafletBlocksBlockquote.mainSchema, block.block)) {
358
+
html += `<blockquote>${parseTextBlock(block.block)}</blockquote>`;
359
+
}
360
+
361
+
if (is(PubLeafletBlocksIframe.mainSchema, block.block)) {
362
+
// @ts-ignore
363
+
html += `<iframe height="${block.block.height}" allow="fullscreen" loading="lazy" src="${block.block.url}"></iframe>`;
364
+
}
365
+
366
+
return html.trim();
367
+
}
368
+
369
+
export function renderListItem({
370
+
item,
371
+
did,
372
+
}: {
373
+
item: PubLeafletBlocksUnorderedList.ListItem;
374
+
did: string;
375
+
}): string {
376
+
const children: string | null = item.children?.length
377
+
? `<ul>${item.children.map((child) => renderListItem({ item: child, did }))}</ul>`
378
+
: "";
379
+
380
+
return `<li>${parseBlocks({ block: { block: item.content }, did })}${children}</li>`;
381
+
}
382
+
383
+
// yoinked from: https://github.com/mary-ext/atcute/blob/trunk/packages/lexicons/lexicons/lib/syntax/handle.ts
384
+
const PLC_DID_RE = /^did:plc:([a-z2-7]{24})$/;
385
+
386
+
export const isPlcDid = (input: string): input is Did<"plc"> => {
387
+
return input.length === 32 && PLC_DID_RE.test(input);
388
+
};
+8
-5
package.json
+8
-5
package.json
···
1
1
{
2
2
"name": "@nulfrost/leaflet-loader-astro",
3
-
"version": "1.0.0",
3
+
"version": "1.3.0",
4
4
"description": "A leaflet.pub astro collection loader",
5
5
"keywords": [
6
6
"astro",
···
12
12
"bugs": {
13
13
"url": "https://github.com/nulfrost/leaflet-loader-astro/issues"
14
14
},
15
-
"author": "Dane Miller",
15
+
"author": "Dane Miller <me@dane.computer>",
16
16
"repository": {
17
17
"type": "git",
18
18
"url": "git+https://github.com/nulfrost/leaflet-loader-astro.git"
···
25
25
"typecheck": "tsc",
26
26
"release": "pnpm run build && changeset publish",
27
27
"build": "rm -rf dist && tsup --format esm --dts",
28
-
"pack": "pnpm build && pnpm pack"
28
+
"pack": "rm -rf *.tgz && pnpm build && pnpm pack"
29
29
},
30
30
"license": "MIT",
31
31
"files": [
···
45
45
}
46
46
},
47
47
"devDependencies": {
48
+
"@atcute/atproto": "^3.1.1",
48
49
"@atcute/lex-cli": "^2.1.1",
49
50
"@biomejs/biome": "2.1.3",
50
51
"@changesets/cli": "^2.29.5",
52
+
"@types/bun": "^1.2.19",
51
53
"@types/sanitize-html": "^2.16.0",
52
54
"astro": "^5.12.8",
53
55
"tsup": "^8.5.0",
···
55
57
"vitest": "^3.2.4"
56
58
},
57
59
"dependencies": {
60
+
"@atcute/client": "^4.0.3",
58
61
"@atcute/lexicons": "^1.1.0",
59
-
"@atproto/api": "^0.16.1",
60
-
"@atproto/did": "^0.1.5",
62
+
"@atproto/api": "^0.16.2",
63
+
"katex": "^0.16.22",
61
64
"sanitize-html": "^2.17.0"
62
65
}
63
66
}
+118
-48
pnpm-lock.yaml
+118
-48
pnpm-lock.yaml
···
8
8
9
9
.:
10
10
dependencies:
11
+
'@atcute/client':
12
+
specifier: ^4.0.3
13
+
version: 4.0.3
11
14
'@atcute/lexicons':
12
15
specifier: ^1.1.0
13
16
version: 1.1.0
14
17
'@atproto/api':
15
-
specifier: ^0.16.1
16
-
version: 0.16.1
17
-
'@atproto/did':
18
-
specifier: ^0.1.5
19
-
version: 0.1.5
18
+
specifier: ^0.16.2
19
+
version: 0.16.2
20
+
katex:
21
+
specifier: ^0.16.22
22
+
version: 0.16.22
20
23
sanitize-html:
21
24
specifier: ^2.17.0
22
25
version: 2.17.0
23
26
devDependencies:
27
+
'@atcute/atproto':
28
+
specifier: ^3.1.1
29
+
version: 3.1.1
24
30
'@atcute/lex-cli':
25
31
specifier: ^2.1.1
26
32
version: 2.1.1
···
30
36
'@changesets/cli':
31
37
specifier: ^2.29.5
32
38
version: 2.29.5
39
+
'@types/bun':
40
+
specifier: ^1.2.19
41
+
version: 1.2.19(@types/react@19.1.9)
33
42
'@types/sanitize-html':
34
43
specifier: ^2.16.0
35
44
version: 2.16.0
36
45
astro:
37
46
specifier: ^5.12.8
38
-
version: 5.12.8(@types/node@24.2.0)(rollup@4.46.2)(typescript@5.9.2)
47
+
version: 5.12.9(@types/node@24.2.1)(rollup@4.46.2)(typescript@5.9.2)
39
48
tsup:
40
49
specifier: ^8.5.0
41
50
version: 8.5.0(postcss@8.5.6)(typescript@5.9.2)
···
44
53
version: 5.9.2
45
54
vitest:
46
55
specifier: ^3.2.4
47
-
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.0)
56
+
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)
48
57
49
58
packages:
50
59
···
65
74
resolution: {integrity: sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==}
66
75
engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0}
67
76
77
+
'@atcute/atproto@3.1.1':
78
+
resolution: {integrity: sha512-D+RLTIPF0xLu7BPZY8KSewAPemJFh+3n3zeQ3ROsLxbTtCHbrTDMAmAFexaVRAPGcPYrwXaBUlv7yZjScJolMg==}
79
+
80
+
'@atcute/client@4.0.3':
81
+
resolution: {integrity: sha512-RIOZWFVLca/HiPAAUDqQPOdOreCxTbL5cb+WUf5yqQOKIu5yEAP3eksinmlLmgIrlr5qVOE7brazUUzaskFCfw==}
82
+
83
+
'@atcute/identity@1.0.3':
84
+
resolution: {integrity: sha512-mNMxbKHFGys03A8JXKk0KfMBzdd0vrYMzZZWjpw1nYTs0+ea6bo5S1hwqVUZxHdo1gFHSe/t63jxQIF4yL9aKw==}
85
+
68
86
'@atcute/lex-cli@2.1.1':
69
87
resolution: {integrity: sha512-QaR0sOP8Z24opGHKsSfleDbP/ahUb6HECkVaOqSwG7ORZzbLK1w0265o1BRjCVr2dT6FxlsMUa2Ge85JMA9bxg==}
70
88
hasBin: true
···
75
93
'@atcute/lexicons@1.1.0':
76
94
resolution: {integrity: sha512-LFqwnria78xLYb62Ri/+WwQpUTgZp2DuyolNGIIOV1dpiKhFFFh//nscHMA6IExFLQRqWDs3tTjy7zv0h3sf1Q==}
77
95
78
-
'@atproto/api@0.16.1':
79
-
resolution: {integrity: sha512-w48BlTmzKym7nZETWxgiuUX/wwRXU3xsLLKORWo/xtGnwlvpchUFnHKI3k4ttYJ2/JQE59+/4C16BaLzDyiU2w==}
96
+
'@atproto/api@0.16.2':
97
+
resolution: {integrity: sha512-sSTg31J8ws8DNaoiizp+/uJideRxRaJsq+Nyl8rnSxGw0w3oCvoeRU19iRWh2t0jZEmiRJAGkveGu23NKmPYEQ==}
80
98
81
99
'@atproto/common-web@0.4.2':
82
100
resolution: {integrity: sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw==}
83
-
84
-
'@atproto/did@0.1.5':
85
-
resolution: {integrity: sha512-8+1D08QdGE5TF0bB0vV8HLVrVZJeLNITpRTUVEoABNMRaUS7CoYSVb0+JNQDeJIVmqMjOL8dOjvCUDkp3gEaGQ==}
86
101
87
102
'@atproto/lexicon@0.4.12':
88
103
resolution: {integrity: sha512-fcEvEQ1GpQYF5igZ4IZjPWEoWVpsEF22L9RexxLS3ptfySXLflEyH384e7HITzO/73McDeaJx3lqHIuqn9ulnw==}
···
672
687
673
688
'@swc/helpers@0.5.17':
674
689
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
690
+
691
+
'@types/bun@1.2.19':
692
+
resolution: {integrity: sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg==}
675
693
676
694
'@types/chai@5.2.2':
677
695
resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==}
···
703
721
'@types/node@12.20.55':
704
722
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
705
723
706
-
'@types/node@24.2.0':
707
-
resolution: {integrity: sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==}
724
+
'@types/node@24.2.1':
725
+
resolution: {integrity: sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==}
726
+
727
+
'@types/react@19.1.9':
728
+
resolution: {integrity: sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==}
708
729
709
730
'@types/sanitize-html@2.16.0':
710
731
resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==}
···
800
821
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
801
822
engines: {node: '>=12'}
802
823
803
-
astro@5.12.8:
804
-
resolution: {integrity: sha512-KkJ7FR+c2SyZYlpakm48XBiuQcRsrVtdjG5LN5an0givI/tLik+ePJ4/g3qrAVhYMjJOxBA2YgFQxANPiWB+Mw==}
824
+
astro@5.12.9:
825
+
resolution: {integrity: sha512-cZ7kZ61jyE5nwSrFKSRyf5Gds+uJELqQxJFqMkcgiWQvhWZJUSShn8Uz3yc9WLyLw5Kim5P5un9SkJSGogfEZQ==}
805
826
engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'}
806
827
hasBin: true
807
828
···
845
866
brotli@1.3.3:
846
867
resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
847
868
869
+
bun-types@1.2.19:
870
+
resolution: {integrity: sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ==}
871
+
peerDependencies:
872
+
'@types/react': ^19
873
+
848
874
bundle-require@5.1.0:
849
875
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
850
876
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
···
931
957
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
932
958
engines: {node: '>= 6'}
933
959
960
+
commander@8.3.0:
961
+
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
962
+
engines: {node: '>= 12'}
963
+
934
964
common-ancestor-path@1.0.1:
935
965
resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==}
936
966
···
967
997
engines: {node: '>=4'}
968
998
hasBin: true
969
999
1000
+
csstype@3.1.3:
1001
+
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
1002
+
970
1003
debug@4.4.1:
971
1004
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
972
1005
engines: {node: '>=6.0'}
···
1336
1369
1337
1370
jsonfile@4.0.0:
1338
1371
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
1372
+
1373
+
katex@0.16.22:
1374
+
resolution: {integrity: sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==}
1375
+
hasBin: true
1339
1376
1340
1377
kleur@3.0.3:
1341
1378
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
···
1876
1913
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
1877
1914
engines: {node: '>=8'}
1878
1915
1879
-
smol-toml@1.4.1:
1880
-
resolution: {integrity: sha512-CxdwHXyYTONGHThDbq5XdwbFsuY4wlClRGejfE2NtwUtiHYsP1QtNsHb/hnj31jKYSchztJsaA8pSQoVzkfCFg==}
1916
+
smol-toml@1.4.2:
1917
+
resolution: {integrity: sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==}
1881
1918
engines: {node: '>= 18'}
1882
1919
1883
1920
source-map-js@1.2.1:
···
2218
2255
yaml:
2219
2256
optional: true
2220
2257
2221
-
vite@7.0.6:
2222
-
resolution: {integrity: sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==}
2258
+
vite@7.1.1:
2259
+
resolution: {integrity: sha512-yJ+Mp7OyV+4S+afWo+QyoL9jFWD11QFH0i5i7JypnfTcA1rmgxCbiA8WwAICDEtZ1Z1hzrVhN8R8rGTqkTY8ZQ==}
2223
2260
engines: {node: ^20.19.0 || >=22.12.0}
2224
2261
hasBin: true
2225
2262
peerDependencies:
···
2398
2435
remark-rehype: 11.1.2
2399
2436
remark-smartypants: 3.0.2
2400
2437
shiki: 3.9.2
2401
-
smol-toml: 1.4.1
2438
+
smol-toml: 1.4.2
2402
2439
unified: 11.0.5
2403
2440
unist-util-remove-position: 5.0.0
2404
2441
unist-util-visit: 5.0.0
···
2423
2460
transitivePeerDependencies:
2424
2461
- supports-color
2425
2462
2463
+
'@atcute/atproto@3.1.1':
2464
+
dependencies:
2465
+
'@atcute/lexicons': 1.1.0
2466
+
2467
+
'@atcute/client@4.0.3':
2468
+
dependencies:
2469
+
'@atcute/identity': 1.0.3
2470
+
'@atcute/lexicons': 1.1.0
2471
+
2472
+
'@atcute/identity@1.0.3':
2473
+
dependencies:
2474
+
'@atcute/lexicons': 1.1.0
2475
+
'@badrap/valita': 0.4.6
2476
+
2426
2477
'@atcute/lex-cli@2.1.1':
2427
2478
dependencies:
2428
2479
'@atcute/lexicon-doc': 1.0.3
···
2439
2490
dependencies:
2440
2491
esm-env: 1.2.2
2441
2492
2442
-
'@atproto/api@0.16.1':
2493
+
'@atproto/api@0.16.2':
2443
2494
dependencies:
2444
2495
'@atproto/common-web': 0.4.2
2445
2496
'@atproto/lexicon': 0.4.12
···
2455
2506
graphemer: 1.4.0
2456
2507
multiformats: 9.9.0
2457
2508
uint8arrays: 3.0.0
2458
-
zod: 3.25.76
2459
-
2460
-
'@atproto/did@0.1.5':
2461
-
dependencies:
2462
2509
zod: 3.25.76
2463
2510
2464
2511
'@atproto/lexicon@0.4.12':
···
3001
3048
dependencies:
3002
3049
tslib: 2.8.1
3003
3050
3051
+
'@types/bun@1.2.19(@types/react@19.1.9)':
3052
+
dependencies:
3053
+
bun-types: 1.2.19(@types/react@19.1.9)
3054
+
transitivePeerDependencies:
3055
+
- '@types/react'
3056
+
3004
3057
'@types/chai@5.2.2':
3005
3058
dependencies:
3006
3059
'@types/deep-eql': 4.0.2
···
3015
3068
3016
3069
'@types/fontkit@2.0.8':
3017
3070
dependencies:
3018
-
'@types/node': 24.2.0
3071
+
'@types/node': 24.2.1
3019
3072
3020
3073
'@types/hast@3.0.4':
3021
3074
dependencies:
···
3033
3086
3034
3087
'@types/node@12.20.55': {}
3035
3088
3036
-
'@types/node@24.2.0':
3089
+
'@types/node@24.2.1':
3037
3090
dependencies:
3038
3091
undici-types: 7.10.0
3039
3092
3093
+
'@types/react@19.1.9':
3094
+
dependencies:
3095
+
csstype: 3.1.3
3096
+
3040
3097
'@types/sanitize-html@2.16.0':
3041
3098
dependencies:
3042
3099
htmlparser2: 8.0.2
···
3053
3110
chai: 5.2.1
3054
3111
tinyrainbow: 2.0.0
3055
3112
3056
-
'@vitest/mocker@3.2.4(vite@7.0.6(@types/node@24.2.0))':
3113
+
'@vitest/mocker@3.2.4(vite@7.1.1(@types/node@24.2.1))':
3057
3114
dependencies:
3058
3115
'@vitest/spy': 3.2.4
3059
3116
estree-walker: 3.0.3
3060
3117
magic-string: 0.30.17
3061
3118
optionalDependencies:
3062
-
vite: 7.0.6(@types/node@24.2.0)
3119
+
vite: 7.1.1(@types/node@24.2.1)
3063
3120
3064
3121
'@vitest/pretty-format@3.2.4':
3065
3122
dependencies:
···
3126
3183
3127
3184
assertion-error@2.0.1: {}
3128
3185
3129
-
astro@5.12.8(@types/node@24.2.0)(rollup@4.46.2)(typescript@5.9.2):
3186
+
astro@5.12.9(@types/node@24.2.1)(rollup@4.46.2)(typescript@5.9.2):
3130
3187
dependencies:
3131
3188
'@astrojs/compiler': 2.12.2
3132
3189
'@astrojs/internal-helpers': 0.7.1
···
3173
3230
rehype: 13.0.2
3174
3231
semver: 7.7.2
3175
3232
shiki: 3.9.2
3176
-
smol-toml: 1.4.1
3233
+
smol-toml: 1.4.2
3177
3234
tinyexec: 0.3.2
3178
3235
tinyglobby: 0.2.14
3179
3236
tsconfck: 3.1.6(typescript@5.9.2)
···
3182
3239
unist-util-visit: 5.0.0
3183
3240
unstorage: 1.16.1
3184
3241
vfile: 6.0.3
3185
-
vite: 6.3.5(@types/node@24.2.0)
3186
-
vitefu: 1.1.1(vite@6.3.5(@types/node@24.2.0))
3242
+
vite: 6.3.5(@types/node@24.2.1)
3243
+
vitefu: 1.1.1(vite@6.3.5(@types/node@24.2.1))
3187
3244
xxhash-wasm: 1.1.0
3188
3245
yargs-parser: 21.1.1
3189
3246
yocto-spinner: 0.2.3
···
3268
3325
dependencies:
3269
3326
base64-js: 1.5.1
3270
3327
3328
+
bun-types@1.2.19(@types/react@19.1.9):
3329
+
dependencies:
3330
+
'@types/node': 24.2.1
3331
+
'@types/react': 19.1.9
3332
+
3271
3333
bundle-require@5.1.0(esbuild@0.25.8):
3272
3334
dependencies:
3273
3335
esbuild: 0.25.8
···
3335
3397
3336
3398
commander@4.1.1: {}
3337
3399
3400
+
commander@8.3.0: {}
3401
+
3338
3402
common-ancestor-path@1.0.1: {}
3339
3403
3340
3404
confbox@0.1.8: {}
···
3367
3431
source-map-js: 1.2.1
3368
3432
3369
3433
cssesc@3.0.0: {}
3434
+
3435
+
csstype@3.1.3: {}
3370
3436
3371
3437
debug@4.4.1:
3372
3438
dependencies:
···
3793
3859
optionalDependencies:
3794
3860
graceful-fs: 4.2.11
3795
3861
3862
+
katex@0.16.22:
3863
+
dependencies:
3864
+
commander: 8.3.0
3865
+
3796
3866
kleur@3.0.3: {}
3797
3867
3798
3868
kleur@4.1.5: {}
···
4550
4620
4551
4621
slash@3.0.0: {}
4552
4622
4553
-
smol-toml@1.4.1: {}
4623
+
smol-toml@1.4.2: {}
4554
4624
4555
4625
source-map-js@1.2.1: {}
4556
4626
···
4815
4885
'@types/unist': 3.0.3
4816
4886
vfile-message: 4.0.3
4817
4887
4818
-
vite-node@3.2.4(@types/node@24.2.0):
4888
+
vite-node@3.2.4(@types/node@24.2.1):
4819
4889
dependencies:
4820
4890
cac: 6.7.14
4821
4891
debug: 4.4.1
4822
4892
es-module-lexer: 1.7.0
4823
4893
pathe: 2.0.3
4824
-
vite: 7.0.6(@types/node@24.2.0)
4894
+
vite: 7.1.1(@types/node@24.2.1)
4825
4895
transitivePeerDependencies:
4826
4896
- '@types/node'
4827
4897
- jiti
···
4836
4906
- tsx
4837
4907
- yaml
4838
4908
4839
-
vite@6.3.5(@types/node@24.2.0):
4909
+
vite@6.3.5(@types/node@24.2.1):
4840
4910
dependencies:
4841
4911
esbuild: 0.25.8
4842
4912
fdir: 6.4.6(picomatch@4.0.3)
···
4845
4915
rollup: 4.46.2
4846
4916
tinyglobby: 0.2.14
4847
4917
optionalDependencies:
4848
-
'@types/node': 24.2.0
4918
+
'@types/node': 24.2.1
4849
4919
fsevents: 2.3.3
4850
4920
4851
-
vite@7.0.6(@types/node@24.2.0):
4921
+
vite@7.1.1(@types/node@24.2.1):
4852
4922
dependencies:
4853
4923
esbuild: 0.25.8
4854
4924
fdir: 6.4.6(picomatch@4.0.3)
···
4857
4927
rollup: 4.46.2
4858
4928
tinyglobby: 0.2.14
4859
4929
optionalDependencies:
4860
-
'@types/node': 24.2.0
4930
+
'@types/node': 24.2.1
4861
4931
fsevents: 2.3.3
4862
4932
4863
-
vitefu@1.1.1(vite@6.3.5(@types/node@24.2.0)):
4933
+
vitefu@1.1.1(vite@6.3.5(@types/node@24.2.1)):
4864
4934
optionalDependencies:
4865
-
vite: 6.3.5(@types/node@24.2.0)
4935
+
vite: 6.3.5(@types/node@24.2.1)
4866
4936
4867
-
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.2.0):
4937
+
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.2.1):
4868
4938
dependencies:
4869
4939
'@types/chai': 5.2.2
4870
4940
'@vitest/expect': 3.2.4
4871
-
'@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@24.2.0))
4941
+
'@vitest/mocker': 3.2.4(vite@7.1.1(@types/node@24.2.1))
4872
4942
'@vitest/pretty-format': 3.2.4
4873
4943
'@vitest/runner': 3.2.4
4874
4944
'@vitest/snapshot': 3.2.4
···
4886
4956
tinyglobby: 0.2.14
4887
4957
tinypool: 1.1.1
4888
4958
tinyrainbow: 2.0.0
4889
-
vite: 7.0.6(@types/node@24.2.0)
4890
-
vite-node: 3.2.4(@types/node@24.2.0)
4959
+
vite: 7.1.1(@types/node@24.2.1)
4960
+
vite-node: 3.2.4(@types/node@24.2.1)
4891
4961
why-is-node-running: 2.3.0
4892
4962
optionalDependencies:
4893
4963
'@types/debug': 4.1.12
4894
-
'@types/node': 24.2.0
4964
+
'@types/node': 24.2.1
4895
4965
transitivePeerDependencies:
4896
4966
- jiti
4897
4967
- less
-5
pnpm-workspace.yaml
-5
pnpm-workspace.yaml
+189
tests/parse-blocks.test.ts
+189
tests/parse-blocks.test.ts
···
1
+
import { expect, test } from "vitest";
2
+
import { parseBlocks } from "../lib/utils";
3
+
4
+
test("should correctly parse an h1 block to an h2 tag", () => {
5
+
const html = parseBlocks({
6
+
block: {
7
+
$type: "pub.leaflet.pages.linearDocument#block",
8
+
block: {
9
+
$type: "pub.leaflet.blocks.header",
10
+
level: 1,
11
+
facets: [],
12
+
plaintext: "heading 1",
13
+
},
14
+
},
15
+
did: "did:plc:qttsv4e7pu2jl3ilanfgc3zn",
16
+
});
17
+
18
+
expect(html).toMatchInlineSnapshot(`"<h2>heading 1</h2>"`);
19
+
});
20
+
21
+
test("should correctly parse an h2 block to an h3 tag", () => {
22
+
const html = parseBlocks({
23
+
block: {
24
+
$type: "pub.leaflet.pages.linearDocument#block",
25
+
block: {
26
+
$type: "pub.leaflet.blocks.header",
27
+
level: 2,
28
+
facets: [],
29
+
plaintext: "heading 2",
30
+
},
31
+
},
32
+
did: "did:plc:qttsv4e7pu2jl3ilanfgc3zn",
33
+
});
34
+
35
+
expect(html).toMatchInlineSnapshot(`"<h3>heading 2</h3>"`);
36
+
});
37
+
38
+
test("should correctly parse an h3 block to an h4 tag", () => {
39
+
const html = parseBlocks({
40
+
block: {
41
+
$type: "pub.leaflet.pages.linearDocument#block",
42
+
block: {
43
+
$type: "pub.leaflet.blocks.header",
44
+
level: 3,
45
+
facets: [],
46
+
plaintext: "heading 3",
47
+
},
48
+
},
49
+
did: "did:plc:qttsv4e7pu2jl3ilanfgc3zn",
50
+
});
51
+
52
+
expect(html).toMatchInlineSnapshot(`"<h4>heading 3</h4>"`);
53
+
});
54
+
55
+
test("should correctly parse a block with no level to an h6 tag", () => {
56
+
const html = parseBlocks({
57
+
block: {
58
+
$type: "pub.leaflet.pages.linearDocument#block",
59
+
block: {
60
+
$type: "pub.leaflet.blocks.header",
61
+
facets: [],
62
+
plaintext: "heading 6",
63
+
},
64
+
},
65
+
did: "did:plc:qttsv4e7pu2jl3ilanfgc3zn",
66
+
});
67
+
68
+
expect(html).toMatchInlineSnapshot(`"<h6>heading 6</h6>"`);
69
+
});
70
+
71
+
test("should correctly parse an unordered list block", () => {
72
+
const html = parseBlocks({
73
+
block: {
74
+
$type: "pub.leaflet.pages.linearDocument#block",
75
+
block: {
76
+
$type: "pub.leaflet.blocks.unorderedList",
77
+
children: [
78
+
{
79
+
$type: "pub.leaflet.blocks.unorderedList#listItem",
80
+
content: {
81
+
$type: "pub.leaflet.blocks.text",
82
+
facets: [
83
+
{
84
+
index: {
85
+
byteEnd: 18,
86
+
byteStart: 0,
87
+
},
88
+
features: [
89
+
{
90
+
uri: "https://pdsls.dev/",
91
+
$type: "pub.leaflet.richtext.facet#link",
92
+
},
93
+
],
94
+
},
95
+
{
96
+
index: {
97
+
byteEnd: 28,
98
+
byteStart: 22,
99
+
},
100
+
features: [
101
+
{
102
+
uri: "https://bsky.app/profile/juli.ee",
103
+
$type: "pub.leaflet.richtext.facet#link",
104
+
},
105
+
],
106
+
},
107
+
],
108
+
plaintext: "https://pdsls.dev/ by Juliet",
109
+
},
110
+
children: [],
111
+
},
112
+
{
113
+
$type: "pub.leaflet.blocks.unorderedList#listItem",
114
+
content: {
115
+
$type: "pub.leaflet.blocks.text",
116
+
facets: [
117
+
{
118
+
index: {
119
+
byteEnd: 34,
120
+
byteStart: 0,
121
+
},
122
+
features: [
123
+
{
124
+
uri: "https://github.com/mary-ext/atcute",
125
+
$type: "pub.leaflet.richtext.facet#link",
126
+
},
127
+
],
128
+
},
129
+
{
130
+
index: {
131
+
byteEnd: 42,
132
+
byteStart: 38,
133
+
},
134
+
features: [
135
+
{
136
+
uri: "https://bsky.app/profile/mary.my.id",
137
+
$type: "pub.leaflet.richtext.facet#link",
138
+
},
139
+
],
140
+
},
141
+
],
142
+
plaintext: "https://github.com/mary-ext/atcute by mary",
143
+
},
144
+
children: [],
145
+
},
146
+
{
147
+
$type: "pub.leaflet.blocks.unorderedList#listItem",
148
+
content: {
149
+
$type: "pub.leaflet.blocks.text",
150
+
facets: [
151
+
{
152
+
index: {
153
+
byteEnd: 27,
154
+
byteStart: 0,
155
+
},
156
+
features: [
157
+
{
158
+
uri: "https://www.microcosm.blue/",
159
+
$type: "pub.leaflet.richtext.facet#link",
160
+
},
161
+
],
162
+
},
163
+
{
164
+
index: {
165
+
byteEnd: 35,
166
+
byteStart: 31,
167
+
},
168
+
features: [
169
+
{
170
+
uri: "https://bsky.app/profile/bad-example.com",
171
+
$type: "pub.leaflet.richtext.facet#link",
172
+
},
173
+
],
174
+
},
175
+
],
176
+
plaintext: "https://www.microcosm.blue/ by phil",
177
+
},
178
+
children: [],
179
+
},
180
+
],
181
+
},
182
+
},
183
+
did: "did:plc:qttsv4e7pu2jl3ilanfgc3zn",
184
+
});
185
+
186
+
expect(html).toMatchInlineSnapshot(
187
+
`"<ul><li><p><a href="https://pdsls.dev/" target="_blank" rel="noopener noreferrer">https://pdsls.dev/</a> by <a href="https://bsky.app/profile/juli.ee" target="_blank" rel="noopener noreferrer">Juliet</a></p></li><li><p><a href="https://github.com/mary-ext/atcute" target="_blank" rel="noopener noreferrer">https://github.com/mary-ext/atcute</a> by <a href="https://bsky.app/profile/mary.my.id" target="_blank" rel="noopener noreferrer">mary</a></p></li><li><p><a href="https://www.microcosm.blue/" target="_blank" rel="noopener noreferrer">https://www.microcosm.blue/</a> by <a href="https://bsky.app/profile/bad-example.com" target="_blank" rel="noopener noreferrer">phil</a></p></li></ul>"`,
188
+
);
189
+
});
+133
tests/parse-text-blocks.test.ts
+133
tests/parse-text-blocks.test.ts
···
1
+
import { expect, test } from "vitest";
2
+
import { parseTextBlock } from "../lib/utils";
3
+
4
+
test("should correctly parse a text block without facets", () => {
5
+
const html = parseTextBlock({
6
+
$type: "pub.leaflet.blocks.text",
7
+
facets: [],
8
+
plaintext: "just plaintext no facets",
9
+
});
10
+
11
+
expect(html).toMatchInlineSnapshot(`"<p>just plaintext no facets</p>"`);
12
+
});
13
+
14
+
test("should correctly parse a text block with bolded text", () => {
15
+
const html = parseTextBlock({
16
+
$type: "pub.leaflet.blocks.text",
17
+
facets: [
18
+
{
19
+
index: {
20
+
byteEnd: 11,
21
+
byteStart: 0,
22
+
},
23
+
features: [
24
+
{
25
+
$type: "pub.leaflet.richtext.facet#bold",
26
+
},
27
+
],
28
+
},
29
+
],
30
+
plaintext: "bolded text with some plaintext",
31
+
});
32
+
33
+
expect(html).toMatchInlineSnapshot(
34
+
`"<p><b>bolded text</b> with some plaintext</p>"`,
35
+
);
36
+
});
37
+
38
+
test("should correctly parse a text block with an inline link", () => {
39
+
const html = parseTextBlock({
40
+
$type: "pub.leaflet.blocks.text",
41
+
facets: [
42
+
{
43
+
index: {
44
+
byteEnd: 27,
45
+
byteStart: 0,
46
+
},
47
+
features: [
48
+
{
49
+
uri: "https://blacksky.community/",
50
+
$type: "pub.leaflet.richtext.facet#link",
51
+
},
52
+
],
53
+
},
54
+
],
55
+
plaintext: "https://blacksky.community/",
56
+
});
57
+
58
+
expect(html).toMatchInlineSnapshot(
59
+
`"<p><a href="https://blacksky.community/" target="_blank" rel="noopener noreferrer">https://blacksky.community/</a></p>"`,
60
+
);
61
+
});
62
+
63
+
test("should correctly parse a text block with strikethrough text", () => {
64
+
const html = parseTextBlock({
65
+
$type: "pub.leaflet.blocks.text",
66
+
facets: [
67
+
{
68
+
index: {
69
+
byteEnd: 13,
70
+
byteStart: 0,
71
+
},
72
+
features: [
73
+
{
74
+
$type: "pub.leaflet.richtext.facet#strikethrough",
75
+
},
76
+
],
77
+
},
78
+
],
79
+
plaintext: "strikethrough text with some plaintext",
80
+
});
81
+
82
+
expect(html).toMatchInlineSnapshot(
83
+
`"<p><s>strikethrough</s> text with some plaintext</p>"`,
84
+
);
85
+
});
86
+
87
+
test("should correctly parse a text block with underlined text", () => {
88
+
const html = parseTextBlock({
89
+
$type: "pub.leaflet.blocks.text",
90
+
facets: [
91
+
{
92
+
index: {
93
+
byteEnd: 10,
94
+
byteStart: 0,
95
+
},
96
+
features: [
97
+
{
98
+
$type: "pub.leaflet.richtext.facet#underline",
99
+
},
100
+
],
101
+
},
102
+
],
103
+
plaintext: "underlined text with some plaintext",
104
+
});
105
+
106
+
expect(html).toMatchInlineSnapshot(
107
+
`"<p><span style="text-decoration:underline;">underlined</span> text with some plaintext</p>"`,
108
+
);
109
+
});
110
+
111
+
test("should correctly parse a text block with italicized text", () => {
112
+
const html = parseTextBlock({
113
+
$type: "pub.leaflet.blocks.text",
114
+
facets: [
115
+
{
116
+
index: {
117
+
byteEnd: 10,
118
+
byteStart: 0,
119
+
},
120
+
features: [
121
+
{
122
+
$type: "pub.leaflet.richtext.facet#italic",
123
+
},
124
+
],
125
+
},
126
+
],
127
+
plaintext: "italicized text with some plaintext",
128
+
});
129
+
130
+
expect(html).toMatchInlineSnapshot(
131
+
`"<p><i>italicized</i> text with some plaintext</p>"`,
132
+
);
133
+
});