Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/obsidian/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ for (let leaf of app.workspace.getActiveLeavesOfType(MY_VIEW_TYPE)) {
- Node.js and Electron APIs (`fs`, `crypto`, `os`) are unavailable on mobile
- If the plugin targets mobile, use web API equivalents: `SubtleCrypto` instead of `crypto`, `navigator.clipboard` for clipboard access
- Regex lookbehind assertions are not supported on some mobile — avoid them if possible
- The plugin ships with `isDesktopOnly: false`, so it must load on mobile
- Node builtins and `electron` are deliberately not in the esbuild `external` array in `scripts/compile.ts`. Marking them external lets a dependency's `require("fs")` reach the bundle and throw on mobile; leaving them out fails the build instead. Replace the dependency rather than re-adding the external

### Code quality

Expand Down
2 changes: 1 addition & 1 deletion apps/obsidian/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
"description": "Add semantic structure to your notes with the Discourse Graph protocol.",
"author": "Discourse Graphs",
"authorUrl": "https://discoursegraphs.com",
"isDesktopOnly": true
"isDesktopOnly": false
}
5 changes: 1 addition & 4 deletions apps/obsidian/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,10 @@
"@octokit/core": "^6.1.2",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/mime-types": "3.0.1",
"@types/node": "catalog:",
"@types/react": "catalog:obsidian",
"@types/react-dom": "catalog:obsidian",
"autoprefixer": "^10.4.21",
"builtin-modules": "3.3.0",
"dotenv": "^16.4.5",
"esbuild": "0.17.3",
"eslint": "catalog:",
Expand All @@ -39,6 +37,7 @@
"typescript": "5.5.4",
"uuidv7": "1.1.0",
"vitest": "catalog:",
"yaml": "^2.8.1",
"zod": "^3.24.1"
},
"dependencies": {
Expand All @@ -48,8 +47,6 @@
"@repo/utils": "workspace:*",
"@supabase/supabase-js": "catalog:",
"date-fns": "^4.1.0",
"gray-matter": "^4.0.3",
"mime-types": "^3.0.1",
"nanoid": "^4.0.2",
"react": "catalog:obsidian",
"react-dom": "catalog:obsidian",
Expand Down
7 changes: 4 additions & 3 deletions apps/obsidian/scripts/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import esbuild from "esbuild";
import fs from "fs";
import path from "path";
import { z } from "zod";
import builtins from "builtin-modules";
import dotenv from "dotenv";
import postcss from "postcss";
import tailwindcss from "tailwindcss";
Expand Down Expand Up @@ -43,9 +42,11 @@ export const args = {
format: "cjs",
root: ".",
mirror: process.env.OBSIDIAN_PLUGIN_PATH,
// Node builtins and "electron" are deliberately NOT external. Marking them external lets a
// dependency's `require("fs")` survive into the bundle, which throws at runtime on Obsidian
// mobile; leaving them out fails the build instead. Replace the dependency, do not re-add these.
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
Expand All @@ -58,7 +59,6 @@ export const args = {
"@lezer/highlight",
"@lezer/lr",
"tslib=window.TSLib",
...builtins,
],
} as CliOpts;

Expand Down Expand Up @@ -115,6 +115,7 @@ export const compile = ({
outdir,
bundle: true,
format,
platform: "browser",
sourcemap: isProd ? undefined : "inline",
minify: isProd,
entryNames: out,
Expand Down
49 changes: 49 additions & 0 deletions apps/obsidian/src/utils/__tests__/mimeType.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_MIME_TYPE, getMimeTypeForPath } from "~/utils/mimeType";

describe("getMimeTypeForPath", () => {
it("types the attachment kinds Obsidian accepts", () => {
expect(getMimeTypeForPath("a/b.png")).toBe("image/png");
expect(getMimeTypeForPath("a/b.pdf")).toBe("application/pdf");
expect(getMimeTypeForPath("a/b.mp4")).toBe("video/mp4");
expect(getMimeTypeForPath("a/b.mp3")).toBe("audio/mpeg");
});

it("ignores extension case", () => {
expect(getMimeTypeForPath("A.PNG")).toBe("image/png");
expect(getMimeTypeForPath("A.JpEg")).toBe("image/jpeg");
});

// The caller skips every `text/` attachment, so an extension missing from the
// table would start being uploaded. Transcluded notes are the common case.
it("keeps transcluded text files under a text/ type", () => {
for (const path of ["note.md", "note.markdown", "a.mdx", "a.txt", "a.csv"])
expect(getMimeTypeForPath(path).startsWith("text/")).toBe(true);
});

it("does not classify xml as text, which would stop it being uploaded", () => {
expect(getMimeTypeForPath("a.xml")).toBe("application/xml");
});

it("uses the last extension of a multi-dot name", () => {
expect(getMimeTypeForPath("archive.tar.png")).toBe("image/png");
});

it("ignores dots in parent directories", () => {
expect(getMimeTypeForPath("folder.v2/asset.png")).toBe("image/png");
});

// A plain object literal would return an inherited member here, and the
// caller's `.startsWith` on it aborts the node's whole asset sync.
it("falls back for an extension that names an Object prototype member", () => {
for (const path of ["a.constructor", "a.__proto__", "a.CONSTRUCTOR"])
expect(getMimeTypeForPath(path)).toBe(DEFAULT_MIME_TYPE);
});

it("falls back for unknown, extensionless and dotfile paths", () => {
expect(getMimeTypeForPath("a.unknownext")).toBe(DEFAULT_MIME_TYPE);
expect(getMimeTypeForPath("noextension")).toBe(DEFAULT_MIME_TYPE);
expect(getMimeTypeForPath("dir/.config")).toBe(DEFAULT_MIME_TYPE);
expect(getMimeTypeForPath("trailing.")).toBe(DEFAULT_MIME_TYPE);
});
});
42 changes: 42 additions & 0 deletions apps/obsidian/src/utils/__tests__/parseFrontmatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { parseFrontmatter } from "~/utils/importNodes";

describe("parseFrontmatter", () => {
it("reads the fields callers depend on", () => {
const { frontmatter } = parseFrontmatter(
"---\nnodeTypeId: 7f3a9c12-4b8e\nimportedFromRid: rid://abc-123\n---\nbody",
);
expect(frontmatter.nodeTypeId).toBe("7f3a9c12-4b8e");
expect(frontmatter.importedFromRid).toBe("rid://abc-123");
});

it("returns empty frontmatter when the file has no block", () => {
expect(parseFrontmatter("just body")).toEqual({
frontmatter: {},
body: "just body",
});
});

// `gray-matter` threw here. Obsidian tolerates bad YAML, so the import keeps
// going and the node type falls back to the one the import supplies.
it("treats unparseable yaml as absent instead of throwing", () => {
expect(() =>
parseFrontmatter("---\na: [unclosed\n---\nbody"),
).not.toThrow();
expect(
parseFrontmatter("---\na: [unclosed\n---\nbody").frontmatter,
).toEqual({});
});

it("strips the block from the body it returns, even for bad yaml", () => {
expect(parseFrontmatter("---\na: [unclosed\n---\nbody").body).toBe("body");
});

// Obsidian's parseYaml is the `yaml` package, so an unquoted date stays a
// string. No caller reads a date field; this pins the engine's behaviour.
it("leaves an unquoted date as a string", () => {
expect(
parseFrontmatter("---\ncreated: 2026-08-07\n---\n").frontmatter,
).toEqual({ created: "2026-08-07" });
});
});
63 changes: 63 additions & 0 deletions apps/obsidian/src/utils/__tests__/splitFrontmatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { splitFrontmatter } from "~/utils/splitFrontmatter";

describe("splitFrontmatter", () => {
it("splits a standard block", () => {
expect(splitFrontmatter("---\na: 1\n---\nbody\n")).toEqual({
yaml: "a: 1",
body: "body\n",
});
});

it("handles CRLF line endings", () => {
expect(splitFrontmatter("---\r\na: 1\r\n---\r\nbody")).toEqual({
yaml: "a: 1",
body: "body",
});
});

it("reports an empty block as empty yaml, not as absent", () => {
expect(splitFrontmatter("---\n---\nbody")).toEqual({
yaml: "",
body: "body",
});
});

it("returns null yaml when the file has no block", () => {
expect(splitFrontmatter("just body\n")).toEqual({
yaml: null,
body: "just body\n",
});
});

it("leaves a horizontal rule in the body alone", () => {
const content = "intro\n\n---\n\nafter the rule\n";
expect(splitFrontmatter(content)).toEqual({ yaml: null, body: content });
});

it("treats a file that is only frontmatter as having an empty body", () => {
expect(splitFrontmatter("---\na: 1\n---\n")).toEqual({
yaml: "a: 1",
body: "",
});
});

it("keeps multi-line yaml values intact", () => {
const { yaml } = splitFrontmatter(
"---\ntags:\n - one\n - two\n---\nbody",
);
expect(yaml).toBe("tags:\n - one\n - two");
});

it("tolerates trailing spaces on the fences", () => {
expect(splitFrontmatter("--- \na: 1\n--- \nbody")).toEqual({
yaml: "a: 1",
body: "body",
});
});

it("ignores a block that does not start at the first character", () => {
const content = "\n---\na: 1\n---\nbody";
expect(splitFrontmatter(content)).toEqual({ yaml: null, body: content });
});
});
33 changes: 22 additions & 11 deletions apps/obsidian/src/utils/importNodes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import matter from "gray-matter";
import { App, Notice, TFile } from "obsidian";
import { App, Notice, TFile, parseYaml } from "obsidian";
import type { DGSupabaseClient } from "@repo/database/lib/client";
import { listGroupSharedNodes } from "@repo/database/lib/sharedNodes";
import type DiscourseGraphPlugin from "~/index";
Expand Down Expand Up @@ -39,6 +38,7 @@ import {
} from "./importedNodeContent";
import { decorateTitle } from "@repo/database/lib/decorateTitle";
import { buildSchemaRid, findLocalNodeTypeMatch } from "./schemaMatching";
import { splitFrontmatter } from "~/utils/splitFrontmatter";

type PublishedNode = {
source_local_id: string;
Expand Down Expand Up @@ -697,12 +697,18 @@ const updateMarkdownAssetLinks = ({
},
);

// Match markdown links (non-image): [text](path) — internal paths resolved like wikilinks, href kept URL-encoded
const markdownLinkRegex = /(?<!!)\[([^\]]*)\]\(([^)]+)\)/g;
// Match markdown links (non-image): [text](path) — internal paths resolved like wikilinks, href kept URL-encoded.
// The leading `!` is captured rather than excluded with a lookbehind, which older mobile WebViews do not support.
const markdownLinkRegex = /(!?)\[([^\]]*)\]\(([^)]+)\)/g;
updatedContent = updatedContent.replace(
markdownLinkRegex,
(match, linkText: string, linkPath: string) => {
if (!linkPath) return match;
// A rest parameter keeps this within the arrow-function parameter limit.
(match, ...groups: string[]) => {
const [imagePrefix, linkText, rawLinkPath] = groups;
// An `!` prefix makes this an image embed, handled by the image pass below.
if (imagePrefix) return match;
if (!rawLinkPath) return match;
let linkPath = rawLinkPath;
// Resolve by row before looking at the shape of the link: a Roam-origin asset is
// referenced by its storage URL. Matched on the raw link, because the decoding
// below would mangle the percent-escaping a storage URL carries.
Expand Down Expand Up @@ -1176,14 +1182,19 @@ type ParsedFrontmatter = {
[key: string]: unknown;
};

// Unparseable frontmatter is treated as absent rather than thrown, matching how
// Obsidian itself tolerates bad YAML. `gray-matter` threw here instead.
export const parseFrontmatter = (
content: string,
): { frontmatter: ParsedFrontmatter; body: string } => {
const { data, content: body } = matter(content);
return {
frontmatter: (data ?? {}) as ParsedFrontmatter,
body: body ?? "",
};
const { yaml, body } = splitFrontmatter(content);
if (yaml === null) return { frontmatter: {}, body };
try {
const data = parseYaml(yaml) as ParsedFrontmatter | null;
return { frontmatter: data ?? {}, body };
} catch {
return { frontmatter: {}, body };
}
};

/**
Expand Down
Loading
Loading