tangled
alpha
login
or
join now
treethought.xyz
/
obsidian-atmark
AT protocol bookmarking platforms in obsidian
8
fork
atom
overview
issues
pulls
pipelines
Compare changes
Choose any two refs to compare.
base:
view-docs
standard-site
responsive-sizing
pds-setting
margin-tags
margin
main
kipclip
client-cache
0.1.8
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
compare:
view-docs
standard-site
responsive-sizing
pds-setting
margin-tags
margin
main
kipclip
client-cache
0.1.8
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
go
+293
-936
17 changed files
expand all
collapse all
unified
split
bun.lock
package.json
src
commands
publishDocument.ts
lib
client.ts
clipper.ts
markdown
index.ts
leaflet.ts
pckt.ts
markdown.ts
standardsite
index.ts
leaflet.ts
pckt.ts
lib.ts
main.ts
settings.ts
views
standardfeed.ts
styles.css
-17
bun.lock
···
15
15
"@atcute/standard-site": "^1.0.0",
16
16
"obsidian": "latest",
17
17
"remark-parse": "^11.0.0",
18
18
-
"remark-stringify": "^11.0.0",
19
18
"unified": "^11.0.5",
20
19
},
21
20
"devDependencies": {
···
541
540
542
541
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
543
542
544
544
-
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
545
545
-
546
543
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
547
544
548
545
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
549
546
550
547
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
551
551
-
552
552
-
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
553
553
-
554
554
-
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
555
548
556
549
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
557
550
···
674
667
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
675
668
676
669
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
677
677
-
678
678
-
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
679
670
680
671
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
681
672
···
783
774
784
775
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
785
776
786
786
-
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
787
787
-
788
777
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
789
789
-
790
790
-
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
791
791
-
792
792
-
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
793
778
794
779
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
795
780
···
816
801
"yaml-eslint-parser": ["yaml-eslint-parser@1.3.2", "", { "dependencies": { "eslint-visitor-keys": "^3.0.0", "yaml": "^2.0.0" } }, "sha512-odxVsHAkZYYglR30aPYRY4nUGJnoJ2y1ww2HDvZALo0BDETv9kWbi16J52eHs+PWRNmF4ub6nZqfVOeesOvntg=="],
817
802
818
803
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
819
819
-
820
820
-
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
821
804
822
805
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
823
806
-1
package.json
···
35
35
"@atcute/standard-site": "^1.0.0",
36
36
"obsidian": "latest",
37
37
"remark-parse": "^11.0.0",
38
38
-
"remark-stringify": "^11.0.0",
39
38
"unified": "^11.0.5"
40
39
}
41
40
}
+17
-2
src/commands/publishDocument.ts
···
1
1
import { Notice, TFile } from "obsidian";
2
2
import type ATmarkPlugin from "../main";
3
3
-
import { createDocument, putDocument, getPublication, markdownToLeafletContent, stripMarkdown, markdownToPcktContent, buildDocumentUrl } from "../lib";
3
3
+
import { createDocument, putDocument, getPublication, markdownToLeafletContent, stripMarkdown, markdownToPcktContent } from "../lib";
4
4
import { PublicationSelection, SelectPublicationModal } from "../components/selectPublicationModal";
5
5
-
import { type ResourceUri, } from "@atcute/lexicons";
5
5
+
import { parseResourceUri, type ResourceUri, } from "@atcute/lexicons";
6
6
import { SiteStandardDocument, SiteStandardPublication } from "@atcute/standard-site";
7
7
import { PubLeafletContent } from "@atcute/leaflet";
8
8
import { BlogPcktContent } from "@atcute/pckt";
···
41
41
new Notice(`Error publishing document: ${message}`);
42
42
console.error("Publish document error:", error);
43
43
}
44
44
+
}
45
45
+
46
46
+
function buildDocumentUrl(pubUrl: string, docUri: string, record: SiteStandardDocument.Main): string {
47
47
+
const baseUrl = pubUrl.replace(/\/$/, '');
48
48
+
49
49
+
// leaflet does not use path, url just uses rkey
50
50
+
if (record.path === undefined || record.path === '') {
51
51
+
const parsed = parseResourceUri(docUri)
52
52
+
if (parsed.ok) {
53
53
+
return `${baseUrl}/${parsed.value.rkey}`;
54
54
+
}
55
55
+
return ""
56
56
+
}
57
57
+
58
58
+
return `${baseUrl}/${record.path}`
44
59
}
45
60
46
61
async function updateFrontMatter(
-3
src/lib/client.ts
···
26
26
return this.hh.cm.session;
27
27
}
28
28
29
29
-
getActor(identifier: string): Promise<ResolvedActor> {
30
30
-
return this.hh.getActor(identifier);
31
31
-
}
32
29
}
33
30
34
31
export class Handler implements FetchHandlerObject {
-118
src/lib/clipper.ts
···
1
1
-
import { ATRecord, buildDocumentUrl } from "lib";
2
2
-
import { Main as Document } from "@atcute/standard-site/types/document";
3
3
-
import { Main as Publication } from "@atcute/standard-site/types/publication";
4
4
-
import { is, parseResourceUri } from "@atcute/lexicons";
5
5
-
import { Notice, TFile } from "obsidian";
6
6
-
import ATmarkPlugin from "main";
7
7
-
import { leafletContentToMarkdown } from "./markdown/leaflet";
8
8
-
import { pcktContentToMarkdown } from "./markdown/pckt";
9
9
-
import { ResolvedActor } from "@atcute/identity-resolver";
10
10
-
import { PubLeafletContent } from "@atcute/leaflet";
11
11
-
import { BlogPcktContent } from "@atcute/pckt";
12
12
-
13
13
-
14
14
-
function bskyLink(handle: string) {
15
15
-
return `https://bsky.app/profile/${handle}`;
16
16
-
}
17
17
-
18
18
-
export class Clipper {
19
19
-
plugin: ATmarkPlugin;
20
20
-
21
21
-
constructor(plugin: ATmarkPlugin) {
22
22
-
this.plugin = plugin;
23
23
-
}
24
24
-
25
25
-
safeFilePath(title: string, clipDir: string) {
26
26
-
const safeTitle = title.replace(/[/\\?%*:|"<>]/g, "-").substring(0, 50);
27
27
-
return `${clipDir}/${safeTitle}.md`;
28
28
-
}
29
29
-
30
30
-
existsInClipDir(doc: ATRecord<Document>) {
31
31
-
const vault = this.plugin.app.vault;
32
32
-
const clipDir = this.plugin.settings.clipDir
33
33
-
34
34
-
35
35
-
const filePath = this.safeFilePath(doc.value.title, clipDir);
36
36
-
const file = vault.getAbstractFileByPath(filePath);
37
37
-
return file !== null;
38
38
-
}
39
39
-
40
40
-
41
41
-
async writeFrontmatter(file: TFile, doc: ATRecord<Document>, pub: ATRecord<Publication>) {
42
42
-
let actor: ResolvedActor | null = null;
43
43
-
const repoParsed = parseResourceUri(doc.uri);
44
44
-
if (repoParsed.ok) {
45
45
-
actor = await this.plugin.client.getActor(repoParsed.value.repo);
46
46
-
}
47
47
-
// Add frontmatter using Obsidian's processFrontMatter
48
48
-
await this.plugin.app.fileManager.processFrontMatter(file, (fm: Record<string, unknown>) => {
49
49
-
fm["title"] = doc.value.title;
50
50
-
if (actor && actor.handle) {
51
51
-
fm["author"] = `[${actor.handle}](${bskyLink(actor.handle)})`;
52
52
-
}
53
53
-
fm["aturi"] = doc.uri;
54
54
-
55
55
-
let docUrl = "";
56
56
-
57
57
-
// pubUrl is at:// record uri or https:// for loose document
58
58
-
// fetch pub if at:// so we can get the url
59
59
-
// otherwise just use the url as is
60
60
-
if (doc.value.site.startsWith("https://")) {
61
61
-
docUrl = buildDocumentUrl(doc.value.site, doc.uri, doc.value);
62
62
-
} else {
63
63
-
docUrl = buildDocumentUrl(pub.value.url, doc.uri, doc.value);
64
64
-
65
65
-
}
66
66
-
if (docUrl) {
67
67
-
fm["url"] = docUrl;
68
68
-
}
69
69
-
});
70
70
-
}
71
71
-
72
72
-
async clipDocument(doc: ATRecord<Document>, pub: ATRecord<Publication>) {
73
73
-
const vault = this.plugin.app.vault;
74
74
-
const clipDir = this.plugin.settings.clipDir
75
75
-
76
76
-
const parsed = parseResourceUri(pub.uri);
77
77
-
if (!parsed.ok) {
78
78
-
throw new Error(`Invalid publication URI: ${pub.uri}`);
79
79
-
}
80
80
-
if (!vault.getAbstractFileByPath(clipDir)) {
81
81
-
await vault.createFolder(clipDir);
82
82
-
}
83
83
-
const filePath = this.safeFilePath(doc.value.title, clipDir);
84
84
-
85
85
-
let content = `# ${doc.value.title}\n\n`;
86
86
-
87
87
-
if (doc.value.description) {
88
88
-
content += `> ${doc.value.description}\n\n`;
89
89
-
}
90
90
-
91
91
-
content += `---\n\n`;
92
92
-
93
93
-
let bodyContent = "";
94
94
-
if (doc.value.content) {
95
95
-
if (is(PubLeafletContent.mainSchema, doc.value.content)) {
96
96
-
bodyContent = leafletContentToMarkdown(doc.value.content);
97
97
-
} else if (is(BlogPcktContent.mainSchema, doc.value.content)) {
98
98
-
bodyContent = pcktContentToMarkdown(doc.value.content);
99
99
-
}
100
100
-
}
101
101
-
102
102
-
if (!bodyContent && doc.value.textContent) {
103
103
-
bodyContent = doc.value.textContent;
104
104
-
}
105
105
-
106
106
-
content += bodyContent;
107
107
-
108
108
-
const file = await vault.create(filePath, content);
109
109
-
await this.writeFrontmatter(file, doc, pub);
110
110
-
111
111
-
112
112
-
const leaf = this.plugin.app.workspace.getLeaf(false);
113
113
-
await leaf.openFile(file);
114
114
-
115
115
-
new Notice(`Clipped document to ${filePath}`);
116
116
-
}
117
117
-
}
118
118
-
-46
src/lib/markdown/index.ts
···
1
1
-
import { unified } from "unified";
2
2
-
import remarkParse from "remark-parse";
3
3
-
import type { Root, RootContent } from "mdast";
4
4
-
5
5
-
export function parseMarkdown(markdown: string): Root {
6
6
-
return unified().use(remarkParse).parse(markdown);
7
7
-
}
8
8
-
9
9
-
export function extractText(node: RootContent | Root): string {
10
10
-
if (node.type === "text") {
11
11
-
return node.value;
12
12
-
}
13
13
-
14
14
-
if (node.type === "inlineCode") {
15
15
-
return node.value;
16
16
-
}
17
17
-
18
18
-
if ("children" in node && Array.isArray(node.children)) {
19
19
-
return node.children.map(extractText).join("");
20
20
-
}
21
21
-
22
22
-
if ("value" in node && typeof node.value === "string") {
23
23
-
return node.value;
24
24
-
}
25
25
-
26
26
-
return "";
27
27
-
}
28
28
-
29
29
-
/**
30
30
-
* Strip markdown formatting to plain text
31
31
-
* Used for the textContent field in standard.site documents
32
32
-
*/
33
33
-
export function stripMarkdown(markdown: string): string {
34
34
-
const tree = parseMarkdown(markdown);
35
35
-
return tree.children.map(extractText).join("\n\n").trim();
36
36
-
}
37
37
-
38
38
-
export function cleanPlaintext(text: string): string {
39
39
-
return text.trim();
40
40
-
}
41
41
-
42
42
-
export type { Root, RootContent };
43
43
-
44
44
-
export { markdownToPcktContent, pcktContentToMarkdown } from "./pckt";
45
45
-
export { markdownToLeafletContent, leafletContentToMarkdown } from "./leaflet";
46
46
-
-191
src/lib/markdown/leaflet.ts
···
1
1
-
import type { RootContent, Root } from "mdast";
2
2
-
import { unified } from "unified";
3
3
-
import remarkStringify from "remark-stringify";
4
4
-
import {
5
5
-
PubLeafletBlocksUnorderedList,
6
6
-
PubLeafletContent,
7
7
-
PubLeafletPagesLinearDocument,
8
8
-
} from "@atcute/leaflet";
9
9
-
import { parseMarkdown, extractText, cleanPlaintext } from "../markdown";
10
10
-
11
11
-
export function markdownToLeafletContent(markdown: string): PubLeafletContent.Main {
12
12
-
const tree = parseMarkdown(markdown);
13
13
-
const blocks: PubLeafletPagesLinearDocument.Block[] = [];
14
14
-
15
15
-
for (const node of tree.children) {
16
16
-
const block = convertNodeToBlock(node);
17
17
-
if (block) {
18
18
-
blocks.push(block);
19
19
-
}
20
20
-
}
21
21
-
22
22
-
return {
23
23
-
$type: "pub.leaflet.content",
24
24
-
pages: [{
25
25
-
$type: "pub.leaflet.pages.linearDocument",
26
26
-
blocks,
27
27
-
}],
28
28
-
};
29
29
-
}
30
30
-
31
31
-
function convertNodeToBlock(node: RootContent): PubLeafletPagesLinearDocument.Block | null {
32
32
-
switch (node.type) {
33
33
-
case "heading":
34
34
-
return {
35
35
-
block: {
36
36
-
$type: "pub.leaflet.blocks.header",
37
37
-
level: node.depth,
38
38
-
plaintext: extractText(node),
39
39
-
},
40
40
-
alignment: "pub.leaflet.pages.linearDocument#textAlignLeft",
41
41
-
};
42
42
-
43
43
-
case "paragraph":
44
44
-
return {
45
45
-
block: {
46
46
-
$type: "pub.leaflet.blocks.text",
47
47
-
plaintext: extractText(node),
48
48
-
textSize: "default",
49
49
-
},
50
50
-
alignment: "pub.leaflet.pages.linearDocument#textAlignLeft",
51
51
-
};
52
52
-
53
53
-
case "list": {
54
54
-
const listItems: PubLeafletBlocksUnorderedList.ListItem[] = node.children.map((item) => ({
55
55
-
$type: "pub.leaflet.blocks.unorderedList#listItem",
56
56
-
content: {
57
57
-
$type: "pub.leaflet.blocks.text",
58
58
-
plaintext: extractText(item),
59
59
-
textSize: "default",
60
60
-
},
61
61
-
}));
62
62
-
63
63
-
return {
64
64
-
block: {
65
65
-
$type: "pub.leaflet.blocks.unorderedList",
66
66
-
children: listItems,
67
67
-
},
68
68
-
alignment: "pub.leaflet.pages.linearDocument#textAlignLeft",
69
69
-
};
70
70
-
}
71
71
-
72
72
-
case "code":
73
73
-
return {
74
74
-
block: {
75
75
-
$type: "pub.leaflet.blocks.code",
76
76
-
plaintext: node.value,
77
77
-
language: node.lang || undefined,
78
78
-
},
79
79
-
alignment: "pub.leaflet.pages.linearDocument#textAlignLeft",
80
80
-
};
81
81
-
82
82
-
case "thematicBreak":
83
83
-
return {
84
84
-
block: {
85
85
-
$type: "pub.leaflet.blocks.horizontalRule",
86
86
-
},
87
87
-
alignment: "pub.leaflet.pages.linearDocument#textAlignLeft",
88
88
-
};
89
89
-
90
90
-
case "blockquote":
91
91
-
return {
92
92
-
block: {
93
93
-
$type: "pub.leaflet.blocks.blockquote",
94
94
-
plaintext: extractText(node),
95
95
-
},
96
96
-
alignment: "pub.leaflet.pages.linearDocument#textAlignLeft",
97
97
-
};
98
98
-
99
99
-
default:
100
100
-
return null;
101
101
-
}
102
102
-
}
103
103
-
104
104
-
export function leafletContentToMarkdown(content: PubLeafletContent.Main): string {
105
105
-
const mdastNodes: RootContent[] = [];
106
106
-
107
107
-
for (const page of content.pages) {
108
108
-
if (page.$type !== "pub.leaflet.pages.linearDocument") {
109
109
-
continue;
110
110
-
}
111
111
-
112
112
-
for (const item of page.blocks) {
113
113
-
const block = item.block;
114
114
-
const node = leafletBlockToMdast(block);
115
115
-
if (node) {
116
116
-
mdastNodes.push(node);
117
117
-
}
118
118
-
}
119
119
-
}
120
120
-
121
121
-
const root: Root = {
122
122
-
type: "root",
123
123
-
children: mdastNodes,
124
124
-
};
125
125
-
126
126
-
return unified().use(remarkStringify).stringify(root);
127
127
-
}
128
128
-
129
129
-
// Extract the union type of all possible leaflet blocks from the Block interface
130
130
-
type LeafletBlockType = PubLeafletPagesLinearDocument.Block['block'];
131
131
-
132
132
-
function leafletBlockToMdast(block: LeafletBlockType): RootContent | null {
133
133
-
switch (block.$type) {
134
134
-
case "pub.leaflet.blocks.header":
135
135
-
return {
136
136
-
type: "heading",
137
137
-
depth: block.level as 1 | 2 | 3 | 4 | 5 | 6,
138
138
-
children: [{ type: "text", value: cleanPlaintext(block.plaintext) }],
139
139
-
};
140
140
-
141
141
-
case "pub.leaflet.blocks.text":
142
142
-
return {
143
143
-
type: "paragraph",
144
144
-
children: [{ type: "text", value: cleanPlaintext(block.plaintext) }],
145
145
-
};
146
146
-
147
147
-
case "pub.leaflet.blocks.unorderedList":
148
148
-
return {
149
149
-
type: "list",
150
150
-
ordered: false,
151
151
-
spread: false,
152
152
-
children: block.children.map((item: PubLeafletBlocksUnorderedList.ListItem) => {
153
153
-
// Extract plaintext from the content, which can be Header, Image, or Text
154
154
-
const plaintext = 'plaintext' in item.content ? cleanPlaintext(item.content.plaintext) : '';
155
155
-
return {
156
156
-
type: "listItem",
157
157
-
spread: false,
158
158
-
children: [{
159
159
-
type: "paragraph",
160
160
-
children: [{ type: "text", value: plaintext }],
161
161
-
}],
162
162
-
};
163
163
-
}),
164
164
-
};
165
165
-
166
166
-
case "pub.leaflet.blocks.code":
167
167
-
return {
168
168
-
type: "code",
169
169
-
lang: block.language || null,
170
170
-
meta: null,
171
171
-
value: block.plaintext, // Keep code blocks as-is to preserve formatting
172
172
-
};
173
173
-
174
174
-
case "pub.leaflet.blocks.horizontalRule":
175
175
-
return {
176
176
-
type: "thematicBreak",
177
177
-
};
178
178
-
179
179
-
case "pub.leaflet.blocks.blockquote":
180
180
-
return {
181
181
-
type: "blockquote",
182
182
-
children: [{
183
183
-
type: "paragraph",
184
184
-
children: [{ type: "text", value: cleanPlaintext(block.plaintext) }],
185
185
-
}],
186
186
-
};
187
187
-
188
188
-
default:
189
189
-
return null;
190
190
-
}
191
191
-
}
-223
src/lib/markdown/pckt.ts
···
1
1
-
import type { RootContent, Root } from "mdast";
2
2
-
import { unified } from "unified";
3
3
-
import remarkStringify from "remark-stringify";
4
4
-
import {
5
5
-
BlogPcktBlockListItem,
6
6
-
BlogPcktBlockText,
7
7
-
BlogPcktBlockHeading,
8
8
-
BlogPcktBlockCodeBlock,
9
9
-
BlogPcktBlockBulletList,
10
10
-
BlogPcktBlockOrderedList,
11
11
-
BlogPcktBlockHorizontalRule,
12
12
-
BlogPcktBlockBlockquote,
13
13
-
BlogPcktContent,
14
14
-
} from "@atcute/pckt";
15
15
-
import { parseMarkdown, extractText, cleanPlaintext } from "../markdown";
16
16
-
17
17
-
type PcktBlock =
18
18
-
| BlogPcktBlockText.Main
19
19
-
| BlogPcktBlockHeading.Main
20
20
-
| BlogPcktBlockCodeBlock.Main
21
21
-
| BlogPcktBlockBulletList.Main
22
22
-
| BlogPcktBlockOrderedList.Main
23
23
-
| BlogPcktBlockHorizontalRule.Main
24
24
-
| BlogPcktBlockBlockquote.Main;
25
25
-
26
26
-
export function markdownToPcktContent(markdown: string): BlogPcktContent.Main {
27
27
-
const tree = parseMarkdown(markdown);
28
28
-
const items: PcktBlock[] = [];
29
29
-
30
30
-
for (const node of tree.children) {
31
31
-
const block = convertNodeToBlock(node);
32
32
-
if (block) {
33
33
-
items.push(block);
34
34
-
}
35
35
-
}
36
36
-
37
37
-
return {
38
38
-
$type: "blog.pckt.content",
39
39
-
items,
40
40
-
} as BlogPcktContent.Main;
41
41
-
}
42
42
-
43
43
-
function convertNodeToBlock(node: RootContent): PcktBlock | null {
44
44
-
switch (node.type) {
45
45
-
case "heading": {
46
46
-
const block: BlogPcktBlockHeading.Main = {
47
47
-
$type: "blog.pckt.block.heading",
48
48
-
level: node.depth,
49
49
-
plaintext: extractText(node),
50
50
-
};
51
51
-
return block;
52
52
-
}
53
53
-
54
54
-
case "paragraph": {
55
55
-
const block: BlogPcktBlockText.Main = {
56
56
-
$type: "blog.pckt.block.text",
57
57
-
plaintext: extractText(node),
58
58
-
};
59
59
-
return block;
60
60
-
}
61
61
-
62
62
-
case "list": {
63
63
-
const listItems: BlogPcktBlockListItem.Main[] = node.children.map((item) => ({
64
64
-
$type: "blog.pckt.block.listItem",
65
65
-
content: [{
66
66
-
$type: "blog.pckt.block.text",
67
67
-
plaintext: extractText(item),
68
68
-
}],
69
69
-
}));
70
70
-
71
71
-
if (node.ordered) {
72
72
-
const block: BlogPcktBlockOrderedList.Main = {
73
73
-
$type: "blog.pckt.block.orderedList",
74
74
-
content: listItems,
75
75
-
};
76
76
-
return block;
77
77
-
} else {
78
78
-
const block: BlogPcktBlockBulletList.Main = {
79
79
-
$type: "blog.pckt.block.bulletList",
80
80
-
content: listItems,
81
81
-
};
82
82
-
return block;
83
83
-
}
84
84
-
}
85
85
-
86
86
-
case "code": {
87
87
-
const block: BlogPcktBlockCodeBlock.Main = {
88
88
-
$type: "blog.pckt.block.codeBlock",
89
89
-
plaintext: node.value,
90
90
-
language: node.lang || undefined,
91
91
-
};
92
92
-
return block;
93
93
-
}
94
94
-
95
95
-
case "thematicBreak": {
96
96
-
const block: BlogPcktBlockHorizontalRule.Main = {
97
97
-
$type: "blog.pckt.block.horizontalRule",
98
98
-
};
99
99
-
return block;
100
100
-
}
101
101
-
102
102
-
case "blockquote": {
103
103
-
const block: BlogPcktBlockBlockquote.Main = {
104
104
-
$type: "blog.pckt.block.blockquote",
105
105
-
content: [{
106
106
-
$type: "blog.pckt.block.text",
107
107
-
plaintext: extractText(node),
108
108
-
}],
109
109
-
};
110
110
-
return block;
111
111
-
}
112
112
-
113
113
-
default:
114
114
-
return null;
115
115
-
}
116
116
-
}
117
117
-
118
118
-
/**
119
119
-
* Convert pckt content to markdown string
120
120
-
*/
121
121
-
export function pcktContentToMarkdown(content: BlogPcktContent.Main): string {
122
122
-
const mdastNodes: RootContent[] = [];
123
123
-
124
124
-
for (const block of content.items) {
125
125
-
const node = pcktBlockToMdast(block);
126
126
-
if (node) {
127
127
-
mdastNodes.push(node);
128
128
-
}
129
129
-
}
130
130
-
131
131
-
const root: Root = {
132
132
-
type: "root",
133
133
-
children: mdastNodes,
134
134
-
};
135
135
-
136
136
-
return unified().use(remarkStringify).stringify(root);
137
137
-
}
138
138
-
139
139
-
function pcktBlockToMdast(block: PcktBlock): RootContent | null {
140
140
-
switch (block.$type) {
141
141
-
case "blog.pckt.block.heading":
142
142
-
return {
143
143
-
type: "heading",
144
144
-
depth: block.level as 1 | 2 | 3 | 4 | 5 | 6,
145
145
-
children: [{ type: "text", value: cleanPlaintext(block.plaintext) }],
146
146
-
};
147
147
-
148
148
-
case "blog.pckt.block.text":
149
149
-
return {
150
150
-
type: "paragraph",
151
151
-
children: [{ type: "text", value: cleanPlaintext(block.plaintext) }],
152
152
-
};
153
153
-
154
154
-
case "blog.pckt.block.bulletList":
155
155
-
return {
156
156
-
type: "list",
157
157
-
ordered: false,
158
158
-
spread: false,
159
159
-
children: block.content.map((item: BlogPcktBlockListItem.Main) => {
160
160
-
const text = item.content
161
161
-
.map((c) => ('plaintext' in c ? cleanPlaintext(c.plaintext) : ''))
162
162
-
.join(" ");
163
163
-
return {
164
164
-
type: "listItem",
165
165
-
spread: false,
166
166
-
children: [{
167
167
-
type: "paragraph",
168
168
-
children: [{ type: "text", value: text }],
169
169
-
}],
170
170
-
};
171
171
-
}),
172
172
-
};
173
173
-
174
174
-
case "blog.pckt.block.orderedList":
175
175
-
return {
176
176
-
type: "list",
177
177
-
ordered: true,
178
178
-
spread: false,
179
179
-
children: block.content.map((item: BlogPcktBlockListItem.Main) => {
180
180
-
const text = item.content
181
181
-
.map((c) => ('plaintext' in c ? cleanPlaintext(c.plaintext) : ''))
182
182
-
.join(" ");
183
183
-
return {
184
184
-
type: "listItem",
185
185
-
spread: false,
186
186
-
children: [{
187
187
-
type: "paragraph",
188
188
-
children: [{ type: "text", value: text }],
189
189
-
}],
190
190
-
};
191
191
-
}),
192
192
-
};
193
193
-
194
194
-
case "blog.pckt.block.codeBlock":
195
195
-
return {
196
196
-
type: "code",
197
197
-
lang: block.language || null,
198
198
-
meta: null,
199
199
-
value: block.plaintext,
200
200
-
};
201
201
-
202
202
-
case "blog.pckt.block.horizontalRule":
203
203
-
return {
204
204
-
type: "thematicBreak",
205
205
-
};
206
206
-
207
207
-
case "blog.pckt.block.blockquote": {
208
208
-
const text = block.content
209
209
-
.map((c: BlogPcktBlockText.Main) => cleanPlaintext(c.plaintext))
210
210
-
.join("\n");
211
211
-
return {
212
212
-
type: "blockquote",
213
213
-
children: [{
214
214
-
type: "paragraph",
215
215
-
children: [{ type: "text", value: text }],
216
216
-
}],
217
217
-
};
218
218
-
}
219
219
-
220
220
-
default:
221
221
-
return null;
222
222
-
}
223
223
-
}
+38
src/lib/markdown.ts
···
1
1
+
import { unified } from "unified";
2
2
+
import remarkParse from "remark-parse";
3
3
+
import type { Root, RootContent } from "mdast";
4
4
+
5
5
+
export function parseMarkdown(markdown: string): Root {
6
6
+
return unified().use(remarkParse).parse(markdown);
7
7
+
}
8
8
+
9
9
+
export function extractText(node: RootContent | Root): string {
10
10
+
if (node.type === "text") {
11
11
+
return node.value;
12
12
+
}
13
13
+
14
14
+
if (node.type === "inlineCode") {
15
15
+
return node.value;
16
16
+
}
17
17
+
18
18
+
if ("children" in node && Array.isArray(node.children)) {
19
19
+
return node.children.map(extractText).join("");
20
20
+
}
21
21
+
22
22
+
if ("value" in node && typeof node.value === "string") {
23
23
+
return node.value;
24
24
+
}
25
25
+
26
26
+
return "";
27
27
+
}
28
28
+
29
29
+
/**
30
30
+
* Strip markdown formatting to plain text
31
31
+
* Used for the textContent field in standard.site documents
32
32
+
*/
33
33
+
export function stripMarkdown(markdown: string): string {
34
34
+
const tree = parseMarkdown(markdown);
35
35
+
return tree.children.map(extractText).join("\n\n").trim();
36
36
+
}
37
37
+
38
38
+
export type { Root, RootContent };
+1
-16
src/lib/standardsite/index.ts
···
9
9
import { ATRecord } from "lib";
10
10
import { SiteStandardDocument, SiteStandardGraphSubscription, SiteStandardPublication } from "@atcute/standard-site";
11
11
12
12
-
export function buildDocumentUrl(pubUrl: string, docUri: string, record: SiteStandardDocument.Main): string {
13
13
-
const baseUrl = pubUrl.replace(/\/$/, '');
14
14
-
15
15
-
// leaflet does not use path, url just uses rkey
16
16
-
if (record.path === undefined || record.path === '') {
17
17
-
const parsed = parseResourceUri(docUri)
18
18
-
if (parsed.ok) {
19
19
-
return `${baseUrl}/${parsed.value.rkey}`;
20
20
-
}
21
21
-
return ""
22
22
-
}
23
23
-
24
24
-
return `${baseUrl}/${record.path}`
25
25
-
}
26
26
-
27
27
-
28
12
export async function getPublicationDocuments(client: Client, repo: string, pubUri: ResourceUri) {
29
13
const response = await ok(client.call(ComAtprotoRepoListRecords, {
30
14
params: {
···
34
18
},
35
19
}));
36
20
21
21
+
// filter records by publication uri
37
22
const pubDocs = response.records.filter(record => {
38
23
const parsed = parse(SiteStandardDocument.mainSchema, record.value);
39
24
return parsed.site === pubUri;
+100
src/lib/standardsite/leaflet.ts
···
1
1
+
import type { RootContent } from "mdast";
2
2
+
import {
3
3
+
PubLeafletBlocksUnorderedList,
4
4
+
PubLeafletContent,
5
5
+
PubLeafletPagesLinearDocument,
6
6
+
} from "@atcute/leaflet";
7
7
+
import { parseMarkdown, extractText } from "../markdown";
8
8
+
9
9
+
export function markdownToLeafletContent(markdown: string): PubLeafletContent.Main {
10
10
+
const tree = parseMarkdown(markdown);
11
11
+
const blocks: PubLeafletPagesLinearDocument.Block[] = [];
12
12
+
13
13
+
for (const node of tree.children) {
14
14
+
const block = convertNodeToBlock(node);
15
15
+
if (block) {
16
16
+
blocks.push(block);
17
17
+
}
18
18
+
}
19
19
+
20
20
+
return {
21
21
+
$type: "pub.leaflet.content",
22
22
+
pages: [{
23
23
+
$type: "pub.leaflet.pages.linearDocument",
24
24
+
blocks,
25
25
+
}],
26
26
+
};
27
27
+
}
28
28
+
29
29
+
function convertNodeToBlock(node: RootContent): PubLeafletPagesLinearDocument.Block | null {
30
30
+
switch (node.type) {
31
31
+
case "heading":
32
32
+
return {
33
33
+
block: {
34
34
+
$type: "pub.leaflet.blocks.header",
35
35
+
level: node.depth,
36
36
+
plaintext: extractText(node),
37
37
+
},
38
38
+
alignment: "pub.leaflet.pages.linearDocument#textAlignLeft",
39
39
+
};
40
40
+
41
41
+
case "paragraph":
42
42
+
return {
43
43
+
block: {
44
44
+
$type: "pub.leaflet.blocks.text",
45
45
+
plaintext: extractText(node),
46
46
+
textSize: "default",
47
47
+
},
48
48
+
alignment: "pub.leaflet.pages.linearDocument#textAlignLeft",
49
49
+
};
50
50
+
51
51
+
case "list": {
52
52
+
const listItems: PubLeafletBlocksUnorderedList.ListItem[] = node.children.map((item) => ({
53
53
+
$type: "pub.leaflet.blocks.unorderedList#listItem",
54
54
+
content: {
55
55
+
$type: "pub.leaflet.blocks.text",
56
56
+
plaintext: extractText(item),
57
57
+
textSize: "default",
58
58
+
},
59
59
+
}));
60
60
+
61
61
+
return {
62
62
+
block: {
63
63
+
$type: "pub.leaflet.blocks.unorderedList",
64
64
+
children: listItems,
65
65
+
},
66
66
+
alignment: "pub.leaflet.pages.linearDocument#textAlignLeft",
67
67
+
};
68
68
+
}
69
69
+
70
70
+
case "code":
71
71
+
return {
72
72
+
block: {
73
73
+
$type: "pub.leaflet.blocks.code",
74
74
+
plaintext: node.value,
75
75
+
language: node.lang || undefined,
76
76
+
},
77
77
+
alignment: "pub.leaflet.pages.linearDocument#textAlignLeft",
78
78
+
};
79
79
+
80
80
+
case "thematicBreak":
81
81
+
return {
82
82
+
block: {
83
83
+
$type: "pub.leaflet.blocks.horizontalRule",
84
84
+
},
85
85
+
alignment: "pub.leaflet.pages.linearDocument#textAlignLeft",
86
86
+
};
87
87
+
88
88
+
case "blockquote":
89
89
+
return {
90
90
+
block: {
91
91
+
$type: "pub.leaflet.blocks.blockquote",
92
92
+
plaintext: extractText(node),
93
93
+
},
94
94
+
alignment: "pub.leaflet.pages.linearDocument#textAlignLeft",
95
95
+
};
96
96
+
97
97
+
default:
98
98
+
return null;
99
99
+
}
100
100
+
}
+119
src/lib/standardsite/pckt.ts
···
1
1
+
/**
2
2
+
* Markdown to Pckt blocks parser
3
3
+
* Converts markdown content to blog.pckt.content format
4
4
+
*/
5
5
+
6
6
+
import type { RootContent } from "mdast";
7
7
+
import {
8
8
+
BlogPcktBlockListItem,
9
9
+
BlogPcktBlockText,
10
10
+
BlogPcktBlockHeading,
11
11
+
BlogPcktBlockCodeBlock,
12
12
+
BlogPcktBlockBulletList,
13
13
+
BlogPcktBlockOrderedList,
14
14
+
BlogPcktBlockHorizontalRule,
15
15
+
BlogPcktBlockBlockquote,
16
16
+
BlogPcktContent,
17
17
+
} from "@atcute/pckt";
18
18
+
import { parseMarkdown, extractText } from "../markdown";
19
19
+
20
20
+
type PcktBlock =
21
21
+
| BlogPcktBlockText.Main
22
22
+
| BlogPcktBlockHeading.Main
23
23
+
| BlogPcktBlockCodeBlock.Main
24
24
+
| BlogPcktBlockBulletList.Main
25
25
+
| BlogPcktBlockOrderedList.Main
26
26
+
| BlogPcktBlockHorizontalRule.Main
27
27
+
| BlogPcktBlockBlockquote.Main;
28
28
+
29
29
+
export function markdownToPcktContent(markdown: string): BlogPcktContent.Main {
30
30
+
const tree = parseMarkdown(markdown);
31
31
+
const items: PcktBlock[] = [];
32
32
+
33
33
+
for (const node of tree.children) {
34
34
+
const block = convertNodeToBlock(node);
35
35
+
if (block) {
36
36
+
items.push(block);
37
37
+
}
38
38
+
}
39
39
+
40
40
+
return {
41
41
+
$type: "blog.pckt.content",
42
42
+
items,
43
43
+
} as BlogPcktContent.Main;
44
44
+
}
45
45
+
46
46
+
function convertNodeToBlock(node: RootContent): PcktBlock | null {
47
47
+
switch (node.type) {
48
48
+
case "heading": {
49
49
+
const block: BlogPcktBlockHeading.Main = {
50
50
+
$type: "blog.pckt.block.heading",
51
51
+
level: node.depth,
52
52
+
plaintext: extractText(node),
53
53
+
};
54
54
+
return block;
55
55
+
}
56
56
+
57
57
+
case "paragraph": {
58
58
+
const block: BlogPcktBlockText.Main = {
59
59
+
$type: "blog.pckt.block.text",
60
60
+
plaintext: extractText(node),
61
61
+
};
62
62
+
return block;
63
63
+
}
64
64
+
65
65
+
case "list": {
66
66
+
const listItems: BlogPcktBlockListItem.Main[] = node.children.map((item) => ({
67
67
+
$type: "blog.pckt.block.listItem",
68
68
+
content: [{
69
69
+
$type: "blog.pckt.block.text",
70
70
+
plaintext: extractText(item),
71
71
+
}],
72
72
+
}));
73
73
+
74
74
+
if (node.ordered) {
75
75
+
const block: BlogPcktBlockOrderedList.Main = {
76
76
+
$type: "blog.pckt.block.orderedList",
77
77
+
content: listItems,
78
78
+
};
79
79
+
return block;
80
80
+
} else {
81
81
+
const block: BlogPcktBlockBulletList.Main = {
82
82
+
$type: "blog.pckt.block.bulletList",
83
83
+
content: listItems,
84
84
+
};
85
85
+
return block;
86
86
+
}
87
87
+
}
88
88
+
89
89
+
case "code": {
90
90
+
const block: BlogPcktBlockCodeBlock.Main = {
91
91
+
$type: "blog.pckt.block.codeBlock",
92
92
+
plaintext: node.value,
93
93
+
language: node.lang || undefined,
94
94
+
};
95
95
+
return block;
96
96
+
}
97
97
+
98
98
+
case "thematicBreak": {
99
99
+
const block: BlogPcktBlockHorizontalRule.Main = {
100
100
+
$type: "blog.pckt.block.horizontalRule",
101
101
+
};
102
102
+
return block;
103
103
+
}
104
104
+
105
105
+
case "blockquote": {
106
106
+
const block: BlogPcktBlockBlockquote.Main = {
107
107
+
$type: "blog.pckt.block.blockquote",
108
108
+
content: [{
109
109
+
$type: "blog.pckt.block.text",
110
110
+
plaintext: extractText(node),
111
111
+
}],
112
112
+
};
113
113
+
return block;
114
114
+
}
115
115
+
116
116
+
default:
117
117
+
return null;
118
118
+
}
119
119
+
}
+3
-7
src/lib.ts
···
24
24
} from "./lib/bookmarks/margin";
25
25
26
26
export {
27
27
-
getPublicationDocuments,
28
27
createDocument,
29
28
putDocument,
30
29
getPublication,
31
30
getPublications,
32
31
getSubscribedPublications,
33
32
createPublication,
34
34
-
buildDocumentUrl
35
33
} from "./lib/standardsite";
36
34
37
37
-
export {
38
38
-
stripMarkdown,
39
39
-
markdownToLeafletContent,
40
40
-
markdownToPcktContent,
41
41
-
} from "./lib/markdown";
35
35
+
export { markdownToLeafletContent } from "./lib/standardsite/leaflet";
36
36
+
export { markdownToPcktContent } from "./lib/standardsite/pckt";
37
37
+
export { stripMarkdown } from "./lib/markdown";
42
38
43
39
export type ATRecord<T> = Record & { value: T };
+1
-4
src/main.ts
···
4
4
import { publishFileAsDocument } from "./commands/publishDocument";
5
5
import { StandardFeedView, VIEW_STANDARD_FEED } from "views/standardfeed";
6
6
import { ATClient } from "lib/client";
7
7
-
import { Clipper } from "lib/clipper";
8
7
9
8
export default class ATmarkPlugin extends Plugin {
10
9
settings: AtProtoSettings = DEFAULT_SETTINGS;
11
11
-
client: ATClient;
12
12
-
clipper: Clipper;
10
10
+
client: ATClient
13
11
14
12
async onload() {
15
13
await this.loadSettings();
···
19
17
password: this.settings.appPassword,
20
18
};
21
19
this.client = new ATClient(creds);
22
22
-
this.clipper = new Clipper(this);
23
20
24
21
this.registerView(VIEW_TYPE_ATMARK, (leaf) => {
25
22
return new ATmarkView(leaf, this);
-13
src/settings.ts
···
4
4
export interface AtProtoSettings {
5
5
identifier: string;
6
6
appPassword: string;
7
7
-
clipDir: string;
8
7
}
9
8
10
9
export const DEFAULT_SETTINGS: AtProtoSettings = {
11
10
identifier: "",
12
11
appPassword: "",
13
13
-
clipDir: "AtmosphereClips",
14
12
};
15
13
16
14
export class SettingTab extends PluginSettingTab {
···
50
48
await this.plugin.saveSettings();
51
49
});
52
50
});
53
53
-
new Setting(containerEl)
54
54
-
.setName("Clip directory")
55
55
-
.setDesc("Directory in your vault to save clips (will be created if it doesn't exist)")
56
56
-
.addText((text) =>
57
57
-
text
58
58
-
.setValue(this.plugin.settings.clipDir)
59
59
-
.onChange(async (value) => {
60
60
-
this.plugin.settings.clipDir = value;
61
61
-
await this.plugin.saveSettings();
62
62
-
})
63
63
-
);
64
51
}
65
52
}
+14
-148
src/views/standardfeed.ts
···
1
1
import { getSubscribedPublications } from "lib/standardsite";
2
2
import ATmarkPlugin from "main";
3
3
-
import { ItemView, Notice, WorkspaceLeaf, setIcon } from "obsidian";
4
4
-
import { Main as Document } from "@atcute/standard-site/types/document";
5
5
-
import { Main as Publication } from "@atcute/standard-site/types/publication";
3
3
+
import { ItemView, WorkspaceLeaf } from "obsidian";
4
4
+
import { SiteStandardPublication } from "@atcute/standard-site";
6
5
import { ATRecord } from "lib";
7
7
-
import { parseResourceUri } from "@atcute/lexicons";
8
8
-
import { getPublicationDocuments } from "lib/standardsite";
9
6
10
7
export const VIEW_STANDARD_FEED = "standard-site-feed";
11
8
···
39
36
container.addClass("standard-site-view");
40
37
this.renderHeader(container);
41
38
42
42
-
43
39
const loading = container.createEl("p", { text: "Loading feed..." });
40
40
+
44
41
try {
45
42
const pubs = await getSubscribedPublications(this.plugin.client, this.plugin.settings.identifier);
46
43
loading.remove();
47
44
48
45
if (pubs.length === 0) {
49
49
-
container.createEl("p", { text: "No subscriptions found" });
46
46
+
container.createEl("p", { text: "No subscriptions found. Subscribe to publications first." });
50
47
return;
51
48
}
52
49
53
50
const list = container.createEl("div", { cls: "standard-site-list" });
54
51
55
52
for (const pub of pubs) {
56
56
-
void this.renderPublicationCard(list, pub);
53
53
+
this.renderPublicationCard(list, pub);
57
54
}
58
55
} catch (error) {
56
56
+
loading.remove();
59
57
const message = error instanceof Error ? error.message : String(error);
60
60
-
console.error("Failed to load feed:", error);
61
58
container.createEl("p", { text: `Failed to load feed: ${message}`, cls: "standard-site-error" });
62
62
-
} finally {
63
63
-
loading.remove();
64
59
}
65
60
}
66
61
67
67
-
private async renderPublicationCard(container: HTMLElement, pub: ATRecord<Publication>) {
62
62
+
private renderPublicationCard(container: HTMLElement, pub: ATRecord<SiteStandardPublication.Main>) {
68
63
const card = container.createEl("div", { cls: "standard-site-publication" });
69
64
65
65
+
// Header with name
70
66
const header = card.createEl("div", { cls: "standard-site-publication-header" });
71
67
header.createEl("h3", {
72
68
text: pub.value.name,
73
69
cls: "standard-site-publication-name"
74
70
});
75
75
-
const extLink = header.createEl("span", { cls: "clickable standard-site-publication-external" });
76
76
-
setIcon(extLink, "external-link");
77
77
-
extLink.addEventListener("click", (e) => {
78
78
-
e.stopPropagation();
79
79
-
window.open(pub.value.url, "_blank");
80
80
-
});
81
71
72
72
+
// Body
82
73
const body = card.createEl("div", { cls: "standard-site-publication-body" });
83
74
84
84
-
const handleEl = body.createEl("span", { cls: "standard-site-author-handle", text: "..." });
85
85
-
const parsed = parseResourceUri(pub.uri);
86
86
-
if (parsed.ok) {
87
87
-
this.plugin.client.getActor(parsed.value.repo).then(actor => {
88
88
-
if (actor?.handle) {
89
89
-
handleEl.setText(`@${actor.handle}`);
90
90
-
} else {
91
91
-
handleEl.setText("");
92
92
-
}
93
93
-
}).catch(() => {
94
94
-
handleEl.setText("");
95
95
-
});
96
96
-
}
97
97
-
75
75
+
// URL
98
76
const urlLine = body.createEl("div", { cls: "standard-site-publication-url" });
99
77
const link = urlLine.createEl("a", { text: pub.value.url, href: pub.value.url });
100
78
link.setAttr("target", "_blank");
101
79
80
80
+
// Description
102
81
if (pub.value.description) {
103
82
body.createEl("p", {
104
83
text: pub.value.description,
···
106
85
});
107
86
}
108
87
88
88
+
// Make card clickable
109
89
card.addClass("clickable");
110
90
card.addEventListener("click", (e) => {
91
91
+
// Don't trigger if clicking the link
111
92
if ((e.target as HTMLElement).tagName !== "A") {
112
112
-
void this.renderPublicationDocuments(pub);
113
113
-
}
114
114
-
});
115
115
-
}
116
116
-
117
117
-
private async renderPublicationDocuments(pub: ATRecord<Publication>) {
118
118
-
const container = this.contentEl;
119
119
-
container.empty();
120
120
-
container.addClass("standard-site-view");
121
121
-
122
122
-
const header = container.createEl("div", { cls: "standard-site-header" });
123
123
-
const backBtn = header.createEl("span", { text: "Back", cls: "clickable standard-site-back" });
124
124
-
setIcon(backBtn, "arrow-left");
125
125
-
backBtn.addEventListener("click", () => {
126
126
-
void this.render();
127
127
-
});
128
128
-
129
129
-
const titleGroup = header.createEl("div", { cls: "standard-site-title-group" });
130
130
-
titleGroup.createEl("h2", { text: pub.value.name });
131
131
-
const handleEl = titleGroup.createEl("span", { cls: "standard-site-author-handle", text: "..." });
132
132
-
133
133
-
const parsed = parseResourceUri(pub.uri);
134
134
-
if (!parsed.ok) {
135
135
-
// This is the name of the plugin, which contains the acronym "AT"
136
136
-
// eslint-disable-next-line obsidianmd/ui/sentence-case
137
137
-
container.createEl("p", { text: "Failed to parse publication URI." });
138
138
-
console.error("Failed to parse publication URI:", parsed.error);
139
139
-
140
140
-
return;
141
141
-
}
142
142
-
143
143
-
// Fetch actor handle asynchronously without blocking document load
144
144
-
this.plugin.client.getActor(parsed.value.repo).then(actor => {
145
145
-
if (actor?.handle) {
146
146
-
handleEl.setText(`@${actor.handle}`);
147
147
-
} else {
148
148
-
handleEl.setText("");
93
93
+
window.open(pub.value.url, "_blank");
149
94
}
150
150
-
}).catch(() => {
151
151
-
handleEl.setText("");
152
95
});
153
153
-
154
154
-
const loading = container.createEl("p", { text: "Loading documents..." });
155
155
-
156
156
-
try {
157
157
-
const docsResp = await getPublicationDocuments(this.plugin.client, parsed.value.repo, pub.uri);
158
158
-
loading.remove();
159
159
-
160
160
-
if (docsResp.records.length === 0) {
161
161
-
container.createEl("p", { text: "No documents found for this publication." });
162
162
-
return;
163
163
-
}
164
164
-
165
165
-
const list = container.createEl("div", { cls: "standard-site-list" });
166
166
-
for (const doc of docsResp.records) {
167
167
-
this.renderDocumentCard(list, doc, pub);
168
168
-
}
169
169
-
} catch (error) {
170
170
-
loading.remove();
171
171
-
const message = error instanceof Error ? error.message : String(error);
172
172
-
container.createEl("p", { text: `Failed to load documents: ${message}`, cls: "standard-site-error" });
173
173
-
}
174
174
-
}
175
175
-
176
176
-
177
177
-
private renderDocumentCard(container: HTMLElement, doc: ATRecord<Document>, pub: ATRecord<Publication>) {
178
178
-
const card = container.createEl("div", { cls: "standard-site-document" });
179
179
-
180
180
-
const header = card.createEl("div", { cls: "standard-site-document-header" });
181
181
-
header.createEl("h3", { text: doc.value.title, cls: "standard-site-document-title" });
182
182
-
183
183
-
let clipIcon = "book-open";
184
184
-
if (this.plugin.clipper.existsInClipDir(doc)) {
185
185
-
clipIcon = "book-open-check";
186
186
-
}
187
187
-
const clipBtn = header.createEl("span", { cls: "clickable standard-site-document-clip" });
188
188
-
setIcon(clipBtn, clipIcon);
189
189
-
clipBtn.addEventListener("click", (e) => {
190
190
-
e.stopPropagation();
191
191
-
try {
192
192
-
void this.plugin.clipper.clipDocument(doc, pub);
193
193
-
} catch (error) {
194
194
-
const message = error instanceof Error ? error.message : String(error);
195
195
-
new Notice(`Failed to clip document: ${message}`);
196
196
-
console.error("Failed to clip document:", error);
197
197
-
}
198
198
-
})
199
199
-
200
200
-
201
201
-
if (doc.value.path) {
202
202
-
const extLink = header.createEl("span", { cls: "clickable standard-site-document-external" });
203
203
-
setIcon(extLink, "external-link");
204
204
-
const baseUrl = pub.value.url.replace(/\/+$/, "");
205
205
-
const path = doc.value.path.startsWith("/") ? doc.value.path : `/${doc.value.path}`;
206
206
-
extLink.addEventListener("click", (e) => {
207
207
-
e.stopPropagation();
208
208
-
window.open(`${baseUrl}${path}`, "_blank");
209
209
-
});
210
210
-
}
211
211
-
212
212
-
const body = card.createEl("div", { cls: "standard-site-document-body" });
213
213
-
214
214
-
if (doc.value.description) {
215
215
-
body.createEl("p", { text: doc.value.description, cls: "standard-site-document-description" });
216
216
-
}
217
217
-
218
218
-
if (doc.value.tags && doc.value.tags.length > 0) {
219
219
-
const tags = body.createEl("div", { cls: "standard-site-document-tags" });
220
220
-
for (const tag of doc.value.tags) {
221
221
-
tags.createEl("span", { text: tag, cls: "standard-site-document-tag" });
222
222
-
}
223
223
-
}
224
224
-
225
225
-
if (doc.value.publishedAt) {
226
226
-
const footer = card.createEl("div", { cls: "standard-site-document-footer" });
227
227
-
const date = new Date(doc.value.publishedAt).toLocaleDateString();
228
228
-
footer.createEl("span", { text: date, cls: "standard-site-document-date" });
229
229
-
}
230
96
}
231
97
232
98
renderHeader(container: HTMLElement) {
-147
styles.css
···
1207
1207
.standard-site-error {
1208
1208
color: var(--text-error);
1209
1209
}
1210
1210
-
1211
1211
-
/* Standard Site Documents */
1212
1212
-
.standard-site-document {
1213
1213
-
background: var(--background-secondary);
1214
1214
-
border: 1px solid var(--background-modifier-border);
1215
1215
-
border-radius: var(--radius-m);
1216
1216
-
padding: 16px;
1217
1217
-
display: flex;
1218
1218
-
flex-direction: column;
1219
1219
-
transition: box-shadow 0.15s ease, border-color 0.15s ease;
1220
1220
-
}
1221
1221
-
1222
1222
-
.standard-site-document:hover {
1223
1223
-
box-shadow: var(--shadow-s);
1224
1224
-
border-color: var(--background-modifier-border-hover);
1225
1225
-
}
1226
1226
-
1227
1227
-
.standard-site-document-header {
1228
1228
-
display: flex;
1229
1229
-
align-items: flex-start;
1230
1230
-
justify-content: space-between;
1231
1231
-
gap: 8px;
1232
1232
-
margin-bottom: 8px;
1233
1233
-
}
1234
1234
-
1235
1235
-
.standard-site-document-title {
1236
1236
-
margin: 0;
1237
1237
-
font-size: var(--h3-size);
1238
1238
-
font-weight: var(--font-semibold);
1239
1239
-
color: var(--text-normal);
1240
1240
-
flex: 1;
1241
1241
-
line-height: 1.3;
1242
1242
-
}
1243
1243
-
1244
1244
-
.standard-site-document-external {
1245
1245
-
display: flex;
1246
1246
-
align-items: center;
1247
1247
-
justify-content: center;
1248
1248
-
flex-shrink: 0;
1249
1249
-
width: 24px;
1250
1250
-
height: 24px;
1251
1251
-
border-radius: var(--radius-s);
1252
1252
-
color: var(--text-faint);
1253
1253
-
transition: all 0.15s ease;
1254
1254
-
}
1255
1255
-
1256
1256
-
.standard-site-document-external:hover {
1257
1257
-
background: var(--background-modifier-hover);
1258
1258
-
color: var(--text-normal);
1259
1259
-
}
1260
1260
-
1261
1261
-
.standard-site-document-external svg {
1262
1262
-
width: 14px;
1263
1263
-
height: 14px;
1264
1264
-
}
1265
1265
-
1266
1266
-
.standard-site-document-body {
1267
1267
-
display: flex;
1268
1268
-
flex-direction: column;
1269
1269
-
gap: 8px;
1270
1270
-
}
1271
1271
-
1272
1272
-
.standard-site-document-description {
1273
1273
-
margin: 0;
1274
1274
-
color: var(--text-muted);
1275
1275
-
font-size: var(--font-small);
1276
1276
-
line-height: var(--line-height-normal);
1277
1277
-
display: -webkit-box;
1278
1278
-
-webkit-line-clamp: 3;
1279
1279
-
-webkit-box-orient: vertical;
1280
1280
-
overflow: hidden;
1281
1281
-
}
1282
1282
-
1283
1283
-
.standard-site-document-tags {
1284
1284
-
display: flex;
1285
1285
-
flex-wrap: wrap;
1286
1286
-
gap: 6px;
1287
1287
-
}
1288
1288
-
1289
1289
-
.standard-site-document-tag {
1290
1290
-
font-size: var(--font-smallest);
1291
1291
-
padding: 2px 8px;
1292
1292
-
border-radius: var(--radius-s);
1293
1293
-
background: var(--background-modifier-border);
1294
1294
-
color: var(--text-muted);
1295
1295
-
border: 1px solid var(--background-modifier-border-hover);
1296
1296
-
}
1297
1297
-
1298
1298
-
.standard-site-document-footer {
1299
1299
-
display: flex;
1300
1300
-
align-items: center;
1301
1301
-
margin-top: 12px;
1302
1302
-
padding-top: 8px;
1303
1303
-
border-top: 1px solid var(--background-modifier-border);
1304
1304
-
}
1305
1305
-
1306
1306
-
.standard-site-document-date {
1307
1307
-
font-size: var(--font-smallest);
1308
1308
-
color: var(--text-faint);
1309
1309
-
}
1310
1310
-
1311
1311
-
.standard-site-title-group {
1312
1312
-
display: flex;
1313
1313
-
flex-direction: column;
1314
1314
-
flex: 1;
1315
1315
-
min-width: 0;
1316
1316
-
}
1317
1317
-
1318
1318
-
.standard-site-author-handle {
1319
1319
-
font-size: var(--font-small);
1320
1320
-
color: var(--text-muted);
1321
1321
-
}
1322
1322
-
1323
1323
-
.standard-site-back {
1324
1324
-
font-size: var(--font-small);
1325
1325
-
color: var(--text-muted);
1326
1326
-
padding: 4px 8px;
1327
1327
-
border-radius: var(--radius-s);
1328
1328
-
transition: all 0.15s ease;
1329
1329
-
}
1330
1330
-
1331
1331
-
.standard-site-back:hover {
1332
1332
-
background: var(--background-modifier-hover);
1333
1333
-
color: var(--text-normal);
1334
1334
-
}
1335
1335
-
1336
1336
-
.standard-site-publication-external {
1337
1337
-
display: flex;
1338
1338
-
align-items: center;
1339
1339
-
justify-content: center;
1340
1340
-
flex-shrink: 0;
1341
1341
-
width: 24px;
1342
1342
-
height: 24px;
1343
1343
-
border-radius: var(--radius-s);
1344
1344
-
color: var(--text-faint);
1345
1345
-
transition: all 0.15s ease;
1346
1346
-
}
1347
1347
-
1348
1348
-
.standard-site-publication-external:hover {
1349
1349
-
background: var(--background-modifier-hover);
1350
1350
-
color: var(--text-normal);
1351
1351
-
}
1352
1352
-
1353
1353
-
.standard-site-publication-external svg {
1354
1354
-
width: 14px;
1355
1355
-
height: 14px;
1356
1356
-
}