import { unified } from "https://esm.sh/unified@10.1.2"; import { render } from "gfm"; import "https://esm.sh/prismjs@1.29.0/components/prism-typescript?no-check"; import "https://esm.sh/prismjs@1.29.0/components/prism-bash?no-check"; import "https://esm.sh/prismjs@1.29.0/components/prism-rust?no-check"; import remarkParse from "https://esm.sh/remark-parse@10.0.2"; import remarkStringify from "https://esm.sh/remark-stringify@10.0.3"; import remarkFrontmatter, { Root, } from "https://esm.sh/remark-frontmatter@4.0.1"; import { SILVERBULLET_SERVER } from "@lib/env.ts"; import { fixRenderedMarkdown } from "@lib/helpers.ts"; import { createLogger } from "@lib/log/index.ts"; import { db } from "@lib/db/sqlite.ts"; import { documentTable } from "@lib/db/schema.ts"; import { eq } from "drizzle-orm/sql"; export type Document = { name: string; lastModified: number; contentType: string; content: string | null; size: number; perm: string; }; const log = createLogger("documents"); export async function getDocuments(): Promise { let documents = await db.select().from(documentTable).all(); if (documents.length) return documents; const headers = new Headers(); headers.append("Accept", "application/json"); headers.append("X-Sync-Mode", "true"); log.debug("fetching all documents"); const response = await fetch(`${SILVERBULLET_SERVER}/index.json`, { headers: headers, }); documents = await response.json(); await db.delete(documentTable); await db.insert(documentTable).values(documents); return documents; } export function createDocument( name: string, content: string | ArrayBuffer, mediaType?: string, ) { const headers = new Headers(); if (mediaType) { headers.append("Content-Type", mediaType); } log.info("creating document", { name }); if (typeof content === "string") { updateDocument(name, content).catch(log.error); } return fetch(SILVERBULLET_SERVER + "/" + name, { body: content, method: "PUT", headers, }); } async function fetchDocument(name: string) { log.debug("fetching document", { name }); const headers = new Headers(); headers.append("X-Sync-Mode", "true"); const response = await fetch(SILVERBULLET_SERVER + "/" + name, { headers }); if (response.status === 404) { return; } return response.text(); } export async function getDocument(name: string): Promise { const documents = await db.select().from(documentTable).where( eq(documentTable.name, name), ).limit(1); // This updates the document in the background fetchDocument(name).then((content) => { if (content) { updateDocument(name, content); } else { db.delete(documentTable).where(eq(documentTable.name, name)); } }).catch( log.error, ); if (documents[0]?.content) return documents[0].content; const text = await fetchDocument(name); if (!text) { db.delete(documentTable).where(eq(documentTable.name, name)); return; } await updateDocument(name, text); return text; } export function updateDocument(name: string, content: string) { return db.update(documentTable).set({ content, }).where(eq(documentTable.name, name)).run(); } export function transformDocument(input: string, cb: (r: Root) => Root) { const out = unified() .use(remarkParse) .use(remarkFrontmatter, ["yaml"]) .use(() => (tree) => { return cb(tree); }) .use(remarkStringify) .processSync(input); return fixRenderedMarkdown(String(out)); } export function parseDocument(doc: string) { return unified() .use(remarkParse) .use(remarkFrontmatter, ["yaml", "toml"]) .parse(doc); } function removeFrontmatter(doc: string) { if (doc.trim().startsWith("---")) { return doc.trim().split("---").filter((s) => s.length).slice(1).join("---"); } return doc; } export function removeImage(doc: string, imageUrl?: string) { if (!imageUrl) { return doc; } // Remove image from content const first = doc.slice(0, 500); const second = doc.slice(500); // Regex pattern to match the image Markdown syntax with the specific URL const pattern = new RegExp( `!\\[.*?\\]\\(${imageUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\)`, "g", ); // Remove the matched image const updatedMarkdown = first.replace(pattern, ""); return updatedMarkdown + second; } export function renderMarkdown(doc: string) { return render(removeFrontmatter(doc), { baseUrl: SILVERBULLET_SERVER, allowMath: true, }); } export type ParsedDocument = ReturnType; export type DocumentChild = ParsedDocument["children"][number]; export function findRangeOfChildren(children: DocumentChild[]) { const firstChild = children[0]; const lastChild = children.length > 1 ? children[children.length - 1] : firstChild; const start = firstChild.position?.start.offset; const end = lastChild.position?.end.offset; if (typeof start !== "number" || typeof end !== "number") return; return [start, end]; } export function getTextOfRange(children: DocumentChild[], text: string) { if (!children || children.length === 0) { return; } const range = findRangeOfChildren(children); if (!range) return; return text.substring(range[0], range[1]); } export function getTextOfChild(child: DocumentChild): string | undefined { if ("value" in child) return child.value; if ("children" in child) { return getTextOfChild(child.children[0]); } return; }