AT protocol bookmarking platforms in obsidian

Compare changes

Choose any two refs to compare.

+293 -936
-17
bun.lock
··· 15 15 "@atcute/standard-site": "^1.0.0", 16 16 "obsidian": "latest", 17 17 "remark-parse": "^11.0.0", 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 - "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], 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 - 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 - 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 - 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 - "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], 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 - 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 - 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 - 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 - "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 - import { createDocument, putDocument, getPublication, markdownToLeafletContent, stripMarkdown, markdownToPcktContent, buildDocumentUrl } from "../lib"; 3 + import { createDocument, putDocument, getPublication, markdownToLeafletContent, stripMarkdown, markdownToPcktContent } from "../lib"; 4 4 import { PublicationSelection, SelectPublicationModal } from "../components/selectPublicationModal"; 5 - import { type ResourceUri, } from "@atcute/lexicons"; 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 + } 45 + 46 + function buildDocumentUrl(pubUrl: string, docUri: string, record: SiteStandardDocument.Main): string { 47 + const baseUrl = pubUrl.replace(/\/$/, ''); 48 + 49 + // leaflet does not use path, url just uses rkey 50 + if (record.path === undefined || record.path === '') { 51 + const parsed = parseResourceUri(docUri) 52 + if (parsed.ok) { 53 + return `${baseUrl}/${parsed.value.rkey}`; 54 + } 55 + return "" 56 + } 57 + 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 - getActor(identifier: string): Promise<ResolvedActor> { 30 - return this.hh.getActor(identifier); 31 - } 32 29 } 33 30 34 31 export class Handler implements FetchHandlerObject {
-118
src/lib/clipper.ts
··· 1 - import { ATRecord, buildDocumentUrl } from "lib"; 2 - import { Main as Document } from "@atcute/standard-site/types/document"; 3 - import { Main as Publication } from "@atcute/standard-site/types/publication"; 4 - import { is, parseResourceUri } from "@atcute/lexicons"; 5 - import { Notice, TFile } from "obsidian"; 6 - import ATmarkPlugin from "main"; 7 - import { leafletContentToMarkdown } from "./markdown/leaflet"; 8 - import { pcktContentToMarkdown } from "./markdown/pckt"; 9 - import { ResolvedActor } from "@atcute/identity-resolver"; 10 - import { PubLeafletContent } from "@atcute/leaflet"; 11 - import { BlogPcktContent } from "@atcute/pckt"; 12 - 13 - 14 - function bskyLink(handle: string) { 15 - return `https://bsky.app/profile/${handle}`; 16 - } 17 - 18 - export class Clipper { 19 - plugin: ATmarkPlugin; 20 - 21 - constructor(plugin: ATmarkPlugin) { 22 - this.plugin = plugin; 23 - } 24 - 25 - safeFilePath(title: string, clipDir: string) { 26 - const safeTitle = title.replace(/[/\\?%*:|"<>]/g, "-").substring(0, 50); 27 - return `${clipDir}/${safeTitle}.md`; 28 - } 29 - 30 - existsInClipDir(doc: ATRecord<Document>) { 31 - const vault = this.plugin.app.vault; 32 - const clipDir = this.plugin.settings.clipDir 33 - 34 - 35 - const filePath = this.safeFilePath(doc.value.title, clipDir); 36 - const file = vault.getAbstractFileByPath(filePath); 37 - return file !== null; 38 - } 39 - 40 - 41 - async writeFrontmatter(file: TFile, doc: ATRecord<Document>, pub: ATRecord<Publication>) { 42 - let actor: ResolvedActor | null = null; 43 - const repoParsed = parseResourceUri(doc.uri); 44 - if (repoParsed.ok) { 45 - actor = await this.plugin.client.getActor(repoParsed.value.repo); 46 - } 47 - // Add frontmatter using Obsidian's processFrontMatter 48 - await this.plugin.app.fileManager.processFrontMatter(file, (fm: Record<string, unknown>) => { 49 - fm["title"] = doc.value.title; 50 - if (actor && actor.handle) { 51 - fm["author"] = `[${actor.handle}](${bskyLink(actor.handle)})`; 52 - } 53 - fm["aturi"] = doc.uri; 54 - 55 - let docUrl = ""; 56 - 57 - // pubUrl is at:// record uri or https:// for loose document 58 - // fetch pub if at:// so we can get the url 59 - // otherwise just use the url as is 60 - if (doc.value.site.startsWith("https://")) { 61 - docUrl = buildDocumentUrl(doc.value.site, doc.uri, doc.value); 62 - } else { 63 - docUrl = buildDocumentUrl(pub.value.url, doc.uri, doc.value); 64 - 65 - } 66 - if (docUrl) { 67 - fm["url"] = docUrl; 68 - } 69 - }); 70 - } 71 - 72 - async clipDocument(doc: ATRecord<Document>, pub: ATRecord<Publication>) { 73 - const vault = this.plugin.app.vault; 74 - const clipDir = this.plugin.settings.clipDir 75 - 76 - const parsed = parseResourceUri(pub.uri); 77 - if (!parsed.ok) { 78 - throw new Error(`Invalid publication URI: ${pub.uri}`); 79 - } 80 - if (!vault.getAbstractFileByPath(clipDir)) { 81 - await vault.createFolder(clipDir); 82 - } 83 - const filePath = this.safeFilePath(doc.value.title, clipDir); 84 - 85 - let content = `# ${doc.value.title}\n\n`; 86 - 87 - if (doc.value.description) { 88 - content += `> ${doc.value.description}\n\n`; 89 - } 90 - 91 - content += `---\n\n`; 92 - 93 - let bodyContent = ""; 94 - if (doc.value.content) { 95 - if (is(PubLeafletContent.mainSchema, doc.value.content)) { 96 - bodyContent = leafletContentToMarkdown(doc.value.content); 97 - } else if (is(BlogPcktContent.mainSchema, doc.value.content)) { 98 - bodyContent = pcktContentToMarkdown(doc.value.content); 99 - } 100 - } 101 - 102 - if (!bodyContent && doc.value.textContent) { 103 - bodyContent = doc.value.textContent; 104 - } 105 - 106 - content += bodyContent; 107 - 108 - const file = await vault.create(filePath, content); 109 - await this.writeFrontmatter(file, doc, pub); 110 - 111 - 112 - const leaf = this.plugin.app.workspace.getLeaf(false); 113 - await leaf.openFile(file); 114 - 115 - new Notice(`Clipped document to ${filePath}`); 116 - } 117 - } 118 -
-46
src/lib/markdown/index.ts
··· 1 - import { unified } from "unified"; 2 - import remarkParse from "remark-parse"; 3 - import type { Root, RootContent } from "mdast"; 4 - 5 - export function parseMarkdown(markdown: string): Root { 6 - return unified().use(remarkParse).parse(markdown); 7 - } 8 - 9 - export function extractText(node: RootContent | Root): string { 10 - if (node.type === "text") { 11 - return node.value; 12 - } 13 - 14 - if (node.type === "inlineCode") { 15 - return node.value; 16 - } 17 - 18 - if ("children" in node && Array.isArray(node.children)) { 19 - return node.children.map(extractText).join(""); 20 - } 21 - 22 - if ("value" in node && typeof node.value === "string") { 23 - return node.value; 24 - } 25 - 26 - return ""; 27 - } 28 - 29 - /** 30 - * Strip markdown formatting to plain text 31 - * Used for the textContent field in standard.site documents 32 - */ 33 - export function stripMarkdown(markdown: string): string { 34 - const tree = parseMarkdown(markdown); 35 - return tree.children.map(extractText).join("\n\n").trim(); 36 - } 37 - 38 - export function cleanPlaintext(text: string): string { 39 - return text.trim(); 40 - } 41 - 42 - export type { Root, RootContent }; 43 - 44 - export { markdownToPcktContent, pcktContentToMarkdown } from "./pckt"; 45 - export { markdownToLeafletContent, leafletContentToMarkdown } from "./leaflet"; 46 -
-191
src/lib/markdown/leaflet.ts
··· 1 - import type { RootContent, Root } from "mdast"; 2 - import { unified } from "unified"; 3 - import remarkStringify from "remark-stringify"; 4 - import { 5 - PubLeafletBlocksUnorderedList, 6 - PubLeafletContent, 7 - PubLeafletPagesLinearDocument, 8 - } from "@atcute/leaflet"; 9 - import { parseMarkdown, extractText, cleanPlaintext } from "../markdown"; 10 - 11 - export function markdownToLeafletContent(markdown: string): PubLeafletContent.Main { 12 - const tree = parseMarkdown(markdown); 13 - const blocks: PubLeafletPagesLinearDocument.Block[] = []; 14 - 15 - for (const node of tree.children) { 16 - const block = convertNodeToBlock(node); 17 - if (block) { 18 - blocks.push(block); 19 - } 20 - } 21 - 22 - return { 23 - $type: "pub.leaflet.content", 24 - pages: [{ 25 - $type: "pub.leaflet.pages.linearDocument", 26 - blocks, 27 - }], 28 - }; 29 - } 30 - 31 - function convertNodeToBlock(node: RootContent): PubLeafletPagesLinearDocument.Block | null { 32 - switch (node.type) { 33 - case "heading": 34 - return { 35 - block: { 36 - $type: "pub.leaflet.blocks.header", 37 - level: node.depth, 38 - plaintext: extractText(node), 39 - }, 40 - alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 41 - }; 42 - 43 - case "paragraph": 44 - return { 45 - block: { 46 - $type: "pub.leaflet.blocks.text", 47 - plaintext: extractText(node), 48 - textSize: "default", 49 - }, 50 - alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 51 - }; 52 - 53 - case "list": { 54 - const listItems: PubLeafletBlocksUnorderedList.ListItem[] = node.children.map((item) => ({ 55 - $type: "pub.leaflet.blocks.unorderedList#listItem", 56 - content: { 57 - $type: "pub.leaflet.blocks.text", 58 - plaintext: extractText(item), 59 - textSize: "default", 60 - }, 61 - })); 62 - 63 - return { 64 - block: { 65 - $type: "pub.leaflet.blocks.unorderedList", 66 - children: listItems, 67 - }, 68 - alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 69 - }; 70 - } 71 - 72 - case "code": 73 - return { 74 - block: { 75 - $type: "pub.leaflet.blocks.code", 76 - plaintext: node.value, 77 - language: node.lang || undefined, 78 - }, 79 - alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 80 - }; 81 - 82 - case "thematicBreak": 83 - return { 84 - block: { 85 - $type: "pub.leaflet.blocks.horizontalRule", 86 - }, 87 - alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 88 - }; 89 - 90 - case "blockquote": 91 - return { 92 - block: { 93 - $type: "pub.leaflet.blocks.blockquote", 94 - plaintext: extractText(node), 95 - }, 96 - alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 97 - }; 98 - 99 - default: 100 - return null; 101 - } 102 - } 103 - 104 - export function leafletContentToMarkdown(content: PubLeafletContent.Main): string { 105 - const mdastNodes: RootContent[] = []; 106 - 107 - for (const page of content.pages) { 108 - if (page.$type !== "pub.leaflet.pages.linearDocument") { 109 - continue; 110 - } 111 - 112 - for (const item of page.blocks) { 113 - const block = item.block; 114 - const node = leafletBlockToMdast(block); 115 - if (node) { 116 - mdastNodes.push(node); 117 - } 118 - } 119 - } 120 - 121 - const root: Root = { 122 - type: "root", 123 - children: mdastNodes, 124 - }; 125 - 126 - return unified().use(remarkStringify).stringify(root); 127 - } 128 - 129 - // Extract the union type of all possible leaflet blocks from the Block interface 130 - type LeafletBlockType = PubLeafletPagesLinearDocument.Block['block']; 131 - 132 - function leafletBlockToMdast(block: LeafletBlockType): RootContent | null { 133 - switch (block.$type) { 134 - case "pub.leaflet.blocks.header": 135 - return { 136 - type: "heading", 137 - depth: block.level as 1 | 2 | 3 | 4 | 5 | 6, 138 - children: [{ type: "text", value: cleanPlaintext(block.plaintext) }], 139 - }; 140 - 141 - case "pub.leaflet.blocks.text": 142 - return { 143 - type: "paragraph", 144 - children: [{ type: "text", value: cleanPlaintext(block.plaintext) }], 145 - }; 146 - 147 - case "pub.leaflet.blocks.unorderedList": 148 - return { 149 - type: "list", 150 - ordered: false, 151 - spread: false, 152 - children: block.children.map((item: PubLeafletBlocksUnorderedList.ListItem) => { 153 - // Extract plaintext from the content, which can be Header, Image, or Text 154 - const plaintext = 'plaintext' in item.content ? cleanPlaintext(item.content.plaintext) : ''; 155 - return { 156 - type: "listItem", 157 - spread: false, 158 - children: [{ 159 - type: "paragraph", 160 - children: [{ type: "text", value: plaintext }], 161 - }], 162 - }; 163 - }), 164 - }; 165 - 166 - case "pub.leaflet.blocks.code": 167 - return { 168 - type: "code", 169 - lang: block.language || null, 170 - meta: null, 171 - value: block.plaintext, // Keep code blocks as-is to preserve formatting 172 - }; 173 - 174 - case "pub.leaflet.blocks.horizontalRule": 175 - return { 176 - type: "thematicBreak", 177 - }; 178 - 179 - case "pub.leaflet.blocks.blockquote": 180 - return { 181 - type: "blockquote", 182 - children: [{ 183 - type: "paragraph", 184 - children: [{ type: "text", value: cleanPlaintext(block.plaintext) }], 185 - }], 186 - }; 187 - 188 - default: 189 - return null; 190 - } 191 - }
-223
src/lib/markdown/pckt.ts
··· 1 - import type { RootContent, Root } from "mdast"; 2 - import { unified } from "unified"; 3 - import remarkStringify from "remark-stringify"; 4 - import { 5 - BlogPcktBlockListItem, 6 - BlogPcktBlockText, 7 - BlogPcktBlockHeading, 8 - BlogPcktBlockCodeBlock, 9 - BlogPcktBlockBulletList, 10 - BlogPcktBlockOrderedList, 11 - BlogPcktBlockHorizontalRule, 12 - BlogPcktBlockBlockquote, 13 - BlogPcktContent, 14 - } from "@atcute/pckt"; 15 - import { parseMarkdown, extractText, cleanPlaintext } from "../markdown"; 16 - 17 - type PcktBlock = 18 - | BlogPcktBlockText.Main 19 - | BlogPcktBlockHeading.Main 20 - | BlogPcktBlockCodeBlock.Main 21 - | BlogPcktBlockBulletList.Main 22 - | BlogPcktBlockOrderedList.Main 23 - | BlogPcktBlockHorizontalRule.Main 24 - | BlogPcktBlockBlockquote.Main; 25 - 26 - export function markdownToPcktContent(markdown: string): BlogPcktContent.Main { 27 - const tree = parseMarkdown(markdown); 28 - const items: PcktBlock[] = []; 29 - 30 - for (const node of tree.children) { 31 - const block = convertNodeToBlock(node); 32 - if (block) { 33 - items.push(block); 34 - } 35 - } 36 - 37 - return { 38 - $type: "blog.pckt.content", 39 - items, 40 - } as BlogPcktContent.Main; 41 - } 42 - 43 - function convertNodeToBlock(node: RootContent): PcktBlock | null { 44 - switch (node.type) { 45 - case "heading": { 46 - const block: BlogPcktBlockHeading.Main = { 47 - $type: "blog.pckt.block.heading", 48 - level: node.depth, 49 - plaintext: extractText(node), 50 - }; 51 - return block; 52 - } 53 - 54 - case "paragraph": { 55 - const block: BlogPcktBlockText.Main = { 56 - $type: "blog.pckt.block.text", 57 - plaintext: extractText(node), 58 - }; 59 - return block; 60 - } 61 - 62 - case "list": { 63 - const listItems: BlogPcktBlockListItem.Main[] = node.children.map((item) => ({ 64 - $type: "blog.pckt.block.listItem", 65 - content: [{ 66 - $type: "blog.pckt.block.text", 67 - plaintext: extractText(item), 68 - }], 69 - })); 70 - 71 - if (node.ordered) { 72 - const block: BlogPcktBlockOrderedList.Main = { 73 - $type: "blog.pckt.block.orderedList", 74 - content: listItems, 75 - }; 76 - return block; 77 - } else { 78 - const block: BlogPcktBlockBulletList.Main = { 79 - $type: "blog.pckt.block.bulletList", 80 - content: listItems, 81 - }; 82 - return block; 83 - } 84 - } 85 - 86 - case "code": { 87 - const block: BlogPcktBlockCodeBlock.Main = { 88 - $type: "blog.pckt.block.codeBlock", 89 - plaintext: node.value, 90 - language: node.lang || undefined, 91 - }; 92 - return block; 93 - } 94 - 95 - case "thematicBreak": { 96 - const block: BlogPcktBlockHorizontalRule.Main = { 97 - $type: "blog.pckt.block.horizontalRule", 98 - }; 99 - return block; 100 - } 101 - 102 - case "blockquote": { 103 - const block: BlogPcktBlockBlockquote.Main = { 104 - $type: "blog.pckt.block.blockquote", 105 - content: [{ 106 - $type: "blog.pckt.block.text", 107 - plaintext: extractText(node), 108 - }], 109 - }; 110 - return block; 111 - } 112 - 113 - default: 114 - return null; 115 - } 116 - } 117 - 118 - /** 119 - * Convert pckt content to markdown string 120 - */ 121 - export function pcktContentToMarkdown(content: BlogPcktContent.Main): string { 122 - const mdastNodes: RootContent[] = []; 123 - 124 - for (const block of content.items) { 125 - const node = pcktBlockToMdast(block); 126 - if (node) { 127 - mdastNodes.push(node); 128 - } 129 - } 130 - 131 - const root: Root = { 132 - type: "root", 133 - children: mdastNodes, 134 - }; 135 - 136 - return unified().use(remarkStringify).stringify(root); 137 - } 138 - 139 - function pcktBlockToMdast(block: PcktBlock): RootContent | null { 140 - switch (block.$type) { 141 - case "blog.pckt.block.heading": 142 - return { 143 - type: "heading", 144 - depth: block.level as 1 | 2 | 3 | 4 | 5 | 6, 145 - children: [{ type: "text", value: cleanPlaintext(block.plaintext) }], 146 - }; 147 - 148 - case "blog.pckt.block.text": 149 - return { 150 - type: "paragraph", 151 - children: [{ type: "text", value: cleanPlaintext(block.plaintext) }], 152 - }; 153 - 154 - case "blog.pckt.block.bulletList": 155 - return { 156 - type: "list", 157 - ordered: false, 158 - spread: false, 159 - children: block.content.map((item: BlogPcktBlockListItem.Main) => { 160 - const text = item.content 161 - .map((c) => ('plaintext' in c ? cleanPlaintext(c.plaintext) : '')) 162 - .join(" "); 163 - return { 164 - type: "listItem", 165 - spread: false, 166 - children: [{ 167 - type: "paragraph", 168 - children: [{ type: "text", value: text }], 169 - }], 170 - }; 171 - }), 172 - }; 173 - 174 - case "blog.pckt.block.orderedList": 175 - return { 176 - type: "list", 177 - ordered: true, 178 - spread: false, 179 - children: block.content.map((item: BlogPcktBlockListItem.Main) => { 180 - const text = item.content 181 - .map((c) => ('plaintext' in c ? cleanPlaintext(c.plaintext) : '')) 182 - .join(" "); 183 - return { 184 - type: "listItem", 185 - spread: false, 186 - children: [{ 187 - type: "paragraph", 188 - children: [{ type: "text", value: text }], 189 - }], 190 - }; 191 - }), 192 - }; 193 - 194 - case "blog.pckt.block.codeBlock": 195 - return { 196 - type: "code", 197 - lang: block.language || null, 198 - meta: null, 199 - value: block.plaintext, 200 - }; 201 - 202 - case "blog.pckt.block.horizontalRule": 203 - return { 204 - type: "thematicBreak", 205 - }; 206 - 207 - case "blog.pckt.block.blockquote": { 208 - const text = block.content 209 - .map((c: BlogPcktBlockText.Main) => cleanPlaintext(c.plaintext)) 210 - .join("\n"); 211 - return { 212 - type: "blockquote", 213 - children: [{ 214 - type: "paragraph", 215 - children: [{ type: "text", value: text }], 216 - }], 217 - }; 218 - } 219 - 220 - default: 221 - return null; 222 - } 223 - }
+38
src/lib/markdown.ts
··· 1 + import { unified } from "unified"; 2 + import remarkParse from "remark-parse"; 3 + import type { Root, RootContent } from "mdast"; 4 + 5 + export function parseMarkdown(markdown: string): Root { 6 + return unified().use(remarkParse).parse(markdown); 7 + } 8 + 9 + export function extractText(node: RootContent | Root): string { 10 + if (node.type === "text") { 11 + return node.value; 12 + } 13 + 14 + if (node.type === "inlineCode") { 15 + return node.value; 16 + } 17 + 18 + if ("children" in node && Array.isArray(node.children)) { 19 + return node.children.map(extractText).join(""); 20 + } 21 + 22 + if ("value" in node && typeof node.value === "string") { 23 + return node.value; 24 + } 25 + 26 + return ""; 27 + } 28 + 29 + /** 30 + * Strip markdown formatting to plain text 31 + * Used for the textContent field in standard.site documents 32 + */ 33 + export function stripMarkdown(markdown: string): string { 34 + const tree = parseMarkdown(markdown); 35 + return tree.children.map(extractText).join("\n\n").trim(); 36 + } 37 + 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 - export function buildDocumentUrl(pubUrl: string, docUri: string, record: SiteStandardDocument.Main): string { 13 - const baseUrl = pubUrl.replace(/\/$/, ''); 14 - 15 - // leaflet does not use path, url just uses rkey 16 - if (record.path === undefined || record.path === '') { 17 - const parsed = parseResourceUri(docUri) 18 - if (parsed.ok) { 19 - return `${baseUrl}/${parsed.value.rkey}`; 20 - } 21 - return "" 22 - } 23 - 24 - return `${baseUrl}/${record.path}` 25 - } 26 - 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 + // 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 + import type { RootContent } from "mdast"; 2 + import { 3 + PubLeafletBlocksUnorderedList, 4 + PubLeafletContent, 5 + PubLeafletPagesLinearDocument, 6 + } from "@atcute/leaflet"; 7 + import { parseMarkdown, extractText } from "../markdown"; 8 + 9 + export function markdownToLeafletContent(markdown: string): PubLeafletContent.Main { 10 + const tree = parseMarkdown(markdown); 11 + const blocks: PubLeafletPagesLinearDocument.Block[] = []; 12 + 13 + for (const node of tree.children) { 14 + const block = convertNodeToBlock(node); 15 + if (block) { 16 + blocks.push(block); 17 + } 18 + } 19 + 20 + return { 21 + $type: "pub.leaflet.content", 22 + pages: [{ 23 + $type: "pub.leaflet.pages.linearDocument", 24 + blocks, 25 + }], 26 + }; 27 + } 28 + 29 + function convertNodeToBlock(node: RootContent): PubLeafletPagesLinearDocument.Block | null { 30 + switch (node.type) { 31 + case "heading": 32 + return { 33 + block: { 34 + $type: "pub.leaflet.blocks.header", 35 + level: node.depth, 36 + plaintext: extractText(node), 37 + }, 38 + alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 39 + }; 40 + 41 + case "paragraph": 42 + return { 43 + block: { 44 + $type: "pub.leaflet.blocks.text", 45 + plaintext: extractText(node), 46 + textSize: "default", 47 + }, 48 + alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 49 + }; 50 + 51 + case "list": { 52 + const listItems: PubLeafletBlocksUnorderedList.ListItem[] = node.children.map((item) => ({ 53 + $type: "pub.leaflet.blocks.unorderedList#listItem", 54 + content: { 55 + $type: "pub.leaflet.blocks.text", 56 + plaintext: extractText(item), 57 + textSize: "default", 58 + }, 59 + })); 60 + 61 + return { 62 + block: { 63 + $type: "pub.leaflet.blocks.unorderedList", 64 + children: listItems, 65 + }, 66 + alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 67 + }; 68 + } 69 + 70 + case "code": 71 + return { 72 + block: { 73 + $type: "pub.leaflet.blocks.code", 74 + plaintext: node.value, 75 + language: node.lang || undefined, 76 + }, 77 + alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 78 + }; 79 + 80 + case "thematicBreak": 81 + return { 82 + block: { 83 + $type: "pub.leaflet.blocks.horizontalRule", 84 + }, 85 + alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 86 + }; 87 + 88 + case "blockquote": 89 + return { 90 + block: { 91 + $type: "pub.leaflet.blocks.blockquote", 92 + plaintext: extractText(node), 93 + }, 94 + alignment: "pub.leaflet.pages.linearDocument#textAlignLeft", 95 + }; 96 + 97 + default: 98 + return null; 99 + } 100 + }
+119
src/lib/standardsite/pckt.ts
··· 1 + /** 2 + * Markdown to Pckt blocks parser 3 + * Converts markdown content to blog.pckt.content format 4 + */ 5 + 6 + import type { RootContent } from "mdast"; 7 + import { 8 + BlogPcktBlockListItem, 9 + BlogPcktBlockText, 10 + BlogPcktBlockHeading, 11 + BlogPcktBlockCodeBlock, 12 + BlogPcktBlockBulletList, 13 + BlogPcktBlockOrderedList, 14 + BlogPcktBlockHorizontalRule, 15 + BlogPcktBlockBlockquote, 16 + BlogPcktContent, 17 + } from "@atcute/pckt"; 18 + import { parseMarkdown, extractText } from "../markdown"; 19 + 20 + type PcktBlock = 21 + | BlogPcktBlockText.Main 22 + | BlogPcktBlockHeading.Main 23 + | BlogPcktBlockCodeBlock.Main 24 + | BlogPcktBlockBulletList.Main 25 + | BlogPcktBlockOrderedList.Main 26 + | BlogPcktBlockHorizontalRule.Main 27 + | BlogPcktBlockBlockquote.Main; 28 + 29 + export function markdownToPcktContent(markdown: string): BlogPcktContent.Main { 30 + const tree = parseMarkdown(markdown); 31 + const items: PcktBlock[] = []; 32 + 33 + for (const node of tree.children) { 34 + const block = convertNodeToBlock(node); 35 + if (block) { 36 + items.push(block); 37 + } 38 + } 39 + 40 + return { 41 + $type: "blog.pckt.content", 42 + items, 43 + } as BlogPcktContent.Main; 44 + } 45 + 46 + function convertNodeToBlock(node: RootContent): PcktBlock | null { 47 + switch (node.type) { 48 + case "heading": { 49 + const block: BlogPcktBlockHeading.Main = { 50 + $type: "blog.pckt.block.heading", 51 + level: node.depth, 52 + plaintext: extractText(node), 53 + }; 54 + return block; 55 + } 56 + 57 + case "paragraph": { 58 + const block: BlogPcktBlockText.Main = { 59 + $type: "blog.pckt.block.text", 60 + plaintext: extractText(node), 61 + }; 62 + return block; 63 + } 64 + 65 + case "list": { 66 + const listItems: BlogPcktBlockListItem.Main[] = node.children.map((item) => ({ 67 + $type: "blog.pckt.block.listItem", 68 + content: [{ 69 + $type: "blog.pckt.block.text", 70 + plaintext: extractText(item), 71 + }], 72 + })); 73 + 74 + if (node.ordered) { 75 + const block: BlogPcktBlockOrderedList.Main = { 76 + $type: "blog.pckt.block.orderedList", 77 + content: listItems, 78 + }; 79 + return block; 80 + } else { 81 + const block: BlogPcktBlockBulletList.Main = { 82 + $type: "blog.pckt.block.bulletList", 83 + content: listItems, 84 + }; 85 + return block; 86 + } 87 + } 88 + 89 + case "code": { 90 + const block: BlogPcktBlockCodeBlock.Main = { 91 + $type: "blog.pckt.block.codeBlock", 92 + plaintext: node.value, 93 + language: node.lang || undefined, 94 + }; 95 + return block; 96 + } 97 + 98 + case "thematicBreak": { 99 + const block: BlogPcktBlockHorizontalRule.Main = { 100 + $type: "blog.pckt.block.horizontalRule", 101 + }; 102 + return block; 103 + } 104 + 105 + case "blockquote": { 106 + const block: BlogPcktBlockBlockquote.Main = { 107 + $type: "blog.pckt.block.blockquote", 108 + content: [{ 109 + $type: "blog.pckt.block.text", 110 + plaintext: extractText(node), 111 + }], 112 + }; 113 + return block; 114 + } 115 + 116 + default: 117 + return null; 118 + } 119 + }
+3 -7
src/lib.ts
··· 24 24 } from "./lib/bookmarks/margin"; 25 25 26 26 export { 27 - getPublicationDocuments, 28 27 createDocument, 29 28 putDocument, 30 29 getPublication, 31 30 getPublications, 32 31 getSubscribedPublications, 33 32 createPublication, 34 - buildDocumentUrl 35 33 } from "./lib/standardsite"; 36 34 37 - export { 38 - stripMarkdown, 39 - markdownToLeafletContent, 40 - markdownToPcktContent, 41 - } from "./lib/markdown"; 35 + export { markdownToLeafletContent } from "./lib/standardsite/leaflet"; 36 + export { markdownToPcktContent } from "./lib/standardsite/pckt"; 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 - import { Clipper } from "lib/clipper"; 8 7 9 8 export default class ATmarkPlugin extends Plugin { 10 9 settings: AtProtoSettings = DEFAULT_SETTINGS; 11 - client: ATClient; 12 - clipper: Clipper; 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 - 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 - clipDir: string; 8 7 } 9 8 10 9 export const DEFAULT_SETTINGS: AtProtoSettings = { 11 10 identifier: "", 12 11 appPassword: "", 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 - new Setting(containerEl) 54 - .setName("Clip directory") 55 - .setDesc("Directory in your vault to save clips (will be created if it doesn't exist)") 56 - .addText((text) => 57 - text 58 - .setValue(this.plugin.settings.clipDir) 59 - .onChange(async (value) => { 60 - this.plugin.settings.clipDir = value; 61 - await this.plugin.saveSettings(); 62 - }) 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 - import { ItemView, Notice, WorkspaceLeaf, setIcon } from "obsidian"; 4 - import { Main as Document } from "@atcute/standard-site/types/document"; 5 - import { Main as Publication } from "@atcute/standard-site/types/publication"; 3 + import { ItemView, WorkspaceLeaf } from "obsidian"; 4 + import { SiteStandardPublication } from "@atcute/standard-site"; 6 5 import { ATRecord } from "lib"; 7 - import { parseResourceUri } from "@atcute/lexicons"; 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 - 43 39 const loading = container.createEl("p", { text: "Loading feed..." }); 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 - container.createEl("p", { text: "No subscriptions found" }); 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 - void this.renderPublicationCard(list, pub); 53 + this.renderPublicationCard(list, pub); 57 54 } 58 55 } catch (error) { 56 + loading.remove(); 59 57 const message = error instanceof Error ? error.message : String(error); 60 - console.error("Failed to load feed:", error); 61 58 container.createEl("p", { text: `Failed to load feed: ${message}`, cls: "standard-site-error" }); 62 - } finally { 63 - loading.remove(); 64 59 } 65 60 } 66 61 67 - private async renderPublicationCard(container: HTMLElement, pub: ATRecord<Publication>) { 62 + private renderPublicationCard(container: HTMLElement, pub: ATRecord<SiteStandardPublication.Main>) { 68 63 const card = container.createEl("div", { cls: "standard-site-publication" }); 69 64 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 - const extLink = header.createEl("span", { cls: "clickable standard-site-publication-external" }); 76 - setIcon(extLink, "external-link"); 77 - extLink.addEventListener("click", (e) => { 78 - e.stopPropagation(); 79 - window.open(pub.value.url, "_blank"); 80 - }); 81 71 72 + // Body 82 73 const body = card.createEl("div", { cls: "standard-site-publication-body" }); 83 74 84 - const handleEl = body.createEl("span", { cls: "standard-site-author-handle", text: "..." }); 85 - const parsed = parseResourceUri(pub.uri); 86 - if (parsed.ok) { 87 - this.plugin.client.getActor(parsed.value.repo).then(actor => { 88 - if (actor?.handle) { 89 - handleEl.setText(`@${actor.handle}`); 90 - } else { 91 - handleEl.setText(""); 92 - } 93 - }).catch(() => { 94 - handleEl.setText(""); 95 - }); 96 - } 97 - 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 + // 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 + // Make card clickable 109 89 card.addClass("clickable"); 110 90 card.addEventListener("click", (e) => { 91 + // Don't trigger if clicking the link 111 92 if ((e.target as HTMLElement).tagName !== "A") { 112 - void this.renderPublicationDocuments(pub); 113 - } 114 - }); 115 - } 116 - 117 - private async renderPublicationDocuments(pub: ATRecord<Publication>) { 118 - const container = this.contentEl; 119 - container.empty(); 120 - container.addClass("standard-site-view"); 121 - 122 - const header = container.createEl("div", { cls: "standard-site-header" }); 123 - const backBtn = header.createEl("span", { text: "Back", cls: "clickable standard-site-back" }); 124 - setIcon(backBtn, "arrow-left"); 125 - backBtn.addEventListener("click", () => { 126 - void this.render(); 127 - }); 128 - 129 - const titleGroup = header.createEl("div", { cls: "standard-site-title-group" }); 130 - titleGroup.createEl("h2", { text: pub.value.name }); 131 - const handleEl = titleGroup.createEl("span", { cls: "standard-site-author-handle", text: "..." }); 132 - 133 - const parsed = parseResourceUri(pub.uri); 134 - if (!parsed.ok) { 135 - // This is the name of the plugin, which contains the acronym "AT" 136 - // eslint-disable-next-line obsidianmd/ui/sentence-case 137 - container.createEl("p", { text: "Failed to parse publication URI." }); 138 - console.error("Failed to parse publication URI:", parsed.error); 139 - 140 - return; 141 - } 142 - 143 - // Fetch actor handle asynchronously without blocking document load 144 - this.plugin.client.getActor(parsed.value.repo).then(actor => { 145 - if (actor?.handle) { 146 - handleEl.setText(`@${actor.handle}`); 147 - } else { 148 - handleEl.setText(""); 93 + window.open(pub.value.url, "_blank"); 149 94 } 150 - }).catch(() => { 151 - handleEl.setText(""); 152 95 }); 153 - 154 - const loading = container.createEl("p", { text: "Loading documents..." }); 155 - 156 - try { 157 - const docsResp = await getPublicationDocuments(this.plugin.client, parsed.value.repo, pub.uri); 158 - loading.remove(); 159 - 160 - if (docsResp.records.length === 0) { 161 - container.createEl("p", { text: "No documents found for this publication." }); 162 - return; 163 - } 164 - 165 - const list = container.createEl("div", { cls: "standard-site-list" }); 166 - for (const doc of docsResp.records) { 167 - this.renderDocumentCard(list, doc, pub); 168 - } 169 - } catch (error) { 170 - loading.remove(); 171 - const message = error instanceof Error ? error.message : String(error); 172 - container.createEl("p", { text: `Failed to load documents: ${message}`, cls: "standard-site-error" }); 173 - } 174 - } 175 - 176 - 177 - private renderDocumentCard(container: HTMLElement, doc: ATRecord<Document>, pub: ATRecord<Publication>) { 178 - const card = container.createEl("div", { cls: "standard-site-document" }); 179 - 180 - const header = card.createEl("div", { cls: "standard-site-document-header" }); 181 - header.createEl("h3", { text: doc.value.title, cls: "standard-site-document-title" }); 182 - 183 - let clipIcon = "book-open"; 184 - if (this.plugin.clipper.existsInClipDir(doc)) { 185 - clipIcon = "book-open-check"; 186 - } 187 - const clipBtn = header.createEl("span", { cls: "clickable standard-site-document-clip" }); 188 - setIcon(clipBtn, clipIcon); 189 - clipBtn.addEventListener("click", (e) => { 190 - e.stopPropagation(); 191 - try { 192 - void this.plugin.clipper.clipDocument(doc, pub); 193 - } catch (error) { 194 - const message = error instanceof Error ? error.message : String(error); 195 - new Notice(`Failed to clip document: ${message}`); 196 - console.error("Failed to clip document:", error); 197 - } 198 - }) 199 - 200 - 201 - if (doc.value.path) { 202 - const extLink = header.createEl("span", { cls: "clickable standard-site-document-external" }); 203 - setIcon(extLink, "external-link"); 204 - const baseUrl = pub.value.url.replace(/\/+$/, ""); 205 - const path = doc.value.path.startsWith("/") ? doc.value.path : `/${doc.value.path}`; 206 - extLink.addEventListener("click", (e) => { 207 - e.stopPropagation(); 208 - window.open(`${baseUrl}${path}`, "_blank"); 209 - }); 210 - } 211 - 212 - const body = card.createEl("div", { cls: "standard-site-document-body" }); 213 - 214 - if (doc.value.description) { 215 - body.createEl("p", { text: doc.value.description, cls: "standard-site-document-description" }); 216 - } 217 - 218 - if (doc.value.tags && doc.value.tags.length > 0) { 219 - const tags = body.createEl("div", { cls: "standard-site-document-tags" }); 220 - for (const tag of doc.value.tags) { 221 - tags.createEl("span", { text: tag, cls: "standard-site-document-tag" }); 222 - } 223 - } 224 - 225 - if (doc.value.publishedAt) { 226 - const footer = card.createEl("div", { cls: "standard-site-document-footer" }); 227 - const date = new Date(doc.value.publishedAt).toLocaleDateString(); 228 - footer.createEl("span", { text: date, cls: "standard-site-document-date" }); 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 - 1211 - /* Standard Site Documents */ 1212 - .standard-site-document { 1213 - background: var(--background-secondary); 1214 - border: 1px solid var(--background-modifier-border); 1215 - border-radius: var(--radius-m); 1216 - padding: 16px; 1217 - display: flex; 1218 - flex-direction: column; 1219 - transition: box-shadow 0.15s ease, border-color 0.15s ease; 1220 - } 1221 - 1222 - .standard-site-document:hover { 1223 - box-shadow: var(--shadow-s); 1224 - border-color: var(--background-modifier-border-hover); 1225 - } 1226 - 1227 - .standard-site-document-header { 1228 - display: flex; 1229 - align-items: flex-start; 1230 - justify-content: space-between; 1231 - gap: 8px; 1232 - margin-bottom: 8px; 1233 - } 1234 - 1235 - .standard-site-document-title { 1236 - margin: 0; 1237 - font-size: var(--h3-size); 1238 - font-weight: var(--font-semibold); 1239 - color: var(--text-normal); 1240 - flex: 1; 1241 - line-height: 1.3; 1242 - } 1243 - 1244 - .standard-site-document-external { 1245 - display: flex; 1246 - align-items: center; 1247 - justify-content: center; 1248 - flex-shrink: 0; 1249 - width: 24px; 1250 - height: 24px; 1251 - border-radius: var(--radius-s); 1252 - color: var(--text-faint); 1253 - transition: all 0.15s ease; 1254 - } 1255 - 1256 - .standard-site-document-external:hover { 1257 - background: var(--background-modifier-hover); 1258 - color: var(--text-normal); 1259 - } 1260 - 1261 - .standard-site-document-external svg { 1262 - width: 14px; 1263 - height: 14px; 1264 - } 1265 - 1266 - .standard-site-document-body { 1267 - display: flex; 1268 - flex-direction: column; 1269 - gap: 8px; 1270 - } 1271 - 1272 - .standard-site-document-description { 1273 - margin: 0; 1274 - color: var(--text-muted); 1275 - font-size: var(--font-small); 1276 - line-height: var(--line-height-normal); 1277 - display: -webkit-box; 1278 - -webkit-line-clamp: 3; 1279 - -webkit-box-orient: vertical; 1280 - overflow: hidden; 1281 - } 1282 - 1283 - .standard-site-document-tags { 1284 - display: flex; 1285 - flex-wrap: wrap; 1286 - gap: 6px; 1287 - } 1288 - 1289 - .standard-site-document-tag { 1290 - font-size: var(--font-smallest); 1291 - padding: 2px 8px; 1292 - border-radius: var(--radius-s); 1293 - background: var(--background-modifier-border); 1294 - color: var(--text-muted); 1295 - border: 1px solid var(--background-modifier-border-hover); 1296 - } 1297 - 1298 - .standard-site-document-footer { 1299 - display: flex; 1300 - align-items: center; 1301 - margin-top: 12px; 1302 - padding-top: 8px; 1303 - border-top: 1px solid var(--background-modifier-border); 1304 - } 1305 - 1306 - .standard-site-document-date { 1307 - font-size: var(--font-smallest); 1308 - color: var(--text-faint); 1309 - } 1310 - 1311 - .standard-site-title-group { 1312 - display: flex; 1313 - flex-direction: column; 1314 - flex: 1; 1315 - min-width: 0; 1316 - } 1317 - 1318 - .standard-site-author-handle { 1319 - font-size: var(--font-small); 1320 - color: var(--text-muted); 1321 - } 1322 - 1323 - .standard-site-back { 1324 - font-size: var(--font-small); 1325 - color: var(--text-muted); 1326 - padding: 4px 8px; 1327 - border-radius: var(--radius-s); 1328 - transition: all 0.15s ease; 1329 - } 1330 - 1331 - .standard-site-back:hover { 1332 - background: var(--background-modifier-hover); 1333 - color: var(--text-normal); 1334 - } 1335 - 1336 - .standard-site-publication-external { 1337 - display: flex; 1338 - align-items: center; 1339 - justify-content: center; 1340 - flex-shrink: 0; 1341 - width: 24px; 1342 - height: 24px; 1343 - border-radius: var(--radius-s); 1344 - color: var(--text-faint); 1345 - transition: all 0.15s ease; 1346 - } 1347 - 1348 - .standard-site-publication-external:hover { 1349 - background: var(--background-modifier-hover); 1350 - color: var(--text-normal); 1351 - } 1352 - 1353 - .standard-site-publication-external svg { 1354 - width: 14px; 1355 - height: 14px; 1356 - }