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
25 changes: 25 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,31 @@ jobs:
- name: Run Package Unit Tests
run: npx turbo run test:unit --ui stream

obsidian-store-compliance:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v6

- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.15.1
run_install: false

- name: Setup Node.js environment
uses: actions/setup-node@v6
with:
node-version: "22"
cache: "pnpm"

- name: Install Dependencies
run: pnpm install --frozen-lockfile

# Blocking on purpose: every rule in this gate can get the plugin pulled from the Obsidian store.
- name: Check Obsidian plugin store compliance
run: pnpm --dir apps/obsidian lint:store

lint-changed-files:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
Expand Down
18 changes: 18 additions & 0 deletions apps/obsidian/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,24 @@ Example: ( ![[lucide-cog.svg#icon]] )

## Plugin Store Guidelines

Run the store compliance gate before opening a PR:

```
pnpm --dir apps/obsidian lint:store
```

This runs `eslint-plugin-obsidianmd` with only its error-level rules — the ones
that can get the plugin rejected from, or pulled off, the community store. It is
a blocking CI job, so a failure here must be fixed rather than suppressed.

It is deliberately separate from `pnpm lint`. The shared repo config loads
`eslint-plugin-only-warn`, which forces every rule to "warn", so store blockers
are indistinguishable from style advice in a normal lint run. `lint:store` runs
in its own process with its own config to keep the error signal intact.
`scripts/` is exempt — build tooling never ships in the plugin bundle.

The rules below are what that gate enforces, plus guidance it cannot check.

These rules must be followed for the plugin to be accepted into the Obsidian community plugin store.

### Security
Expand Down
19 changes: 19 additions & 0 deletions apps/obsidian/eslint.config.store.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { config } from "@repo/eslint-config/obsidian-store";

export default [
...config,
{
// Build and test-data tooling never ships in the plugin bundle, so Obsidian never reviews it.
ignores: ["scripts/**"],
},
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
project: true,
ecmaFeatures: { jsx: true },
},
},
},
];
2 changes: 2 additions & 0 deletions apps/obsidian/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"build": "tsx scripts/build.ts",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint:store": "eslint . --config eslint.config.store.mjs --no-config-lookup",
"publish": "tsx scripts/publish.ts",
"check-types": "tsc --noEmit --skipLibCheck"
},
Expand All @@ -28,6 +29,7 @@
"dotenv": "^16.4.5",
"esbuild": "0.17.3",
"eslint": "catalog:",
"eslint-plugin-obsidianmd": "0.4.1",
"obsidian": "^1.7.2",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
Expand Down
6 changes: 3 additions & 3 deletions apps/obsidian/src/components/ModifyNodeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,6 @@ export const ModifyNodeForm = ({
if (isOpen && titleInputRef.current && popoverRef.current) {
const inputRect = titleInputRef.current.getBoundingClientRect();
const popover = popoverRef.current;
popover.style.position = "fixed";
popover.style.top = `${inputRect.bottom + 4}px`;
popover.style.left = `${inputRect.left}px`;
popover.style.width = `${inputRect.width}px`;
Expand Down Expand Up @@ -201,8 +200,9 @@ export const ModifyNodeForm = ({
useEffect(() => {
const el = titleInputRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
// Two steps, not one: collapsing to auto first is what makes scrollHeight report the shrunk size.
el.setCssProps({ height: "auto" });
el.setCssProps({ height: `${el.scrollHeight}px` });
}, [query]);

// Determine available relationships based on current file and selected node type
Expand Down
27 changes: 14 additions & 13 deletions apps/obsidian/src/components/canvas/DiscourseToolPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ import {
} from "./DiscourseRelationTool";
import { TOOL_ARROW_ICON_SVG } from "~/icons";

const DRAG_GHOST_HIDDEN_CLASSES = "hidden";
const DRAG_GHOST_VISIBLE_CLASSES =
"pointer-events-none fixed left-0 top-0 flex h-[50px] w-[50px] items-center";
// Centres the 50px ghost on the cursor.
const DRAG_GHOST_CURSOR_OFFSET = 25;

const TOOL_ARROW_ICON_DATA_URL = `data:image/svg+xml;base64,${btoa(TOOL_ARROW_ICON_SVG)}`;

export const DiscourseToolPanel = ({
Expand Down Expand Up @@ -161,7 +167,7 @@ export const DiscourseToolPanel = ({
switch (current.name) {
case "idle":
case "pointing_item": {
imageRef.setAttribute("style", "display: none");
imageRef.className = DRAG_GHOST_HIDDEN_CLASSES;
break;
}
case "dragging": {
Expand All @@ -175,18 +181,12 @@ export const DiscourseToolPanel = ({
const viewportScreenBounds = editor.getViewportScreenBounds();
const isInside = Box.ContainsPoint(box, current.currentPosition);
if (isInside) {
imageRef.style.display = "none";
imageRef.className = DRAG_GHOST_HIDDEN_CLASSES;
} else {
imageRef.style.display = "block";
imageRef.style.position = "fixed";
imageRef.style.pointerEvents = "none";
imageRef.style.left = "0px";
imageRef.style.top = "0px";
imageRef.style.transform = `translate(${current.currentPosition.x - viewportScreenBounds.x - 25}px, ${current.currentPosition.y - viewportScreenBounds.y - 25}px)`;
imageRef.style.width = "50px";
imageRef.style.height = "50px";
imageRef.style.display = "flex";
imageRef.style.alignItems = "center";
imageRef.className = DRAG_GHOST_VISIBLE_CLASSES;
imageRef.setCssProps({
transform: `translate(${current.currentPosition.x - viewportScreenBounds.x - DRAG_GHOST_CURSOR_OFFSET}px, ${current.currentPosition.y - viewportScreenBounds.y - DRAG_GHOST_CURSOR_OFFSET}px)`,
});
}
}
}
Expand Down Expand Up @@ -295,7 +295,8 @@ export const DiscourseToolPanel = ({
/>
))}
</div>
<div ref={rDraggingImage}>
{/* className must stay constant: the drag reactor writes it imperatively, and React only clobbers props whose rendered value changed. */}
<div ref={rDraggingImage} className={DRAG_GHOST_HIDDEN_CLASSES}>
{state.name === "dragging"
? (getNodeTypeById(plugin, state.nodeTypeId)?.name ?? "")
: null}
Expand Down
6 changes: 3 additions & 3 deletions apps/obsidian/src/components/canvas/utils/relationUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1463,11 +1463,11 @@ function PatternFillDefForCanvas() {
if (htmlLayer) {
// Wait for `patternContext` to be picked up
editor.timers.requestAnimationFrame(() => {
htmlLayer.style.display = "none";
htmlLayer.classList.add("hidden");

// Wait for 'display = "none"' to take effect
// Wait for the hidden class to take effect
editor.timers.requestAnimationFrame(() => {
htmlLayer.style.display = "";
htmlLayer.classList.remove("hidden");
});
});
}
Expand Down
43 changes: 43 additions & 0 deletions packages/eslint-config/obsidian-store.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import obsidianmd from "eslint-plugin-obsidianmd";
import preferArrows from "eslint-plugin-prefer-arrow-functions";
import pluginReactHooks from "eslint-plugin-react-hooks";
import turboPlugin from "eslint-plugin-turbo";

// Obsidian community plugin store compliance gate: everything it reports can get the plugin pulled from the store.
// Deliberately not built on ./base.js — that loads eslint-plugin-only-warn, which patches ESLint on import and forces every rule to "warn".
// Must run in its own process (see `lint:store`) so a normal lint run's only-warn patch cannot leak in.

const severityOf = (value) => (Array.isArray(value) ? value[0] : value);
const isError = (value) =>
severityOf(value) === "error" || severityOf(value) === 2;

// obsidianmd's recommended set mixes store blockers (error) with style advice (warn); keep only its own error-level rules.
const storeRules = {};
for (const block of obsidianmd.configs.recommended) {
if (!block.rules) continue;
for (const [rule, value] of Object.entries(block.rules)) {
if (rule.startsWith("obsidianmd/") && isError(value)) {
storeRules[rule] = value;
}
}
}

/** @type {import("eslint").Linter.Config[]} */
export const config = [
{ ignores: ["dist/**", "node_modules/**", "*.config.*"] },
// Language/plugin setup from the recommended set, without its rule severities.
...obsidianmd.configs.recommended.filter((block) => !block.rules),
{
// Registered only so existing `eslint-disable` comments naming these rules don't error as "Definition for rule not found".
plugins: { preferArrows, "react-hooks": pluginReactHooks, turboPlugin },
},
{
files: ["**/*.{ts,tsx}"],
linterOptions: { reportUnusedDisableDirectives: "off" },
rules: {
...storeRules,
// The recommended set's globals don't know about the JSX runtime, and type-aware linting already covers this.
"no-undef": "off",
},
},
];
4 changes: 3 additions & 1 deletion packages/eslint-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
"exports": {
"./base": "./base.js",
"./next-js": "./next.js",
"./react-internal": "./react-internal.js"
"./react-internal": "./react-internal.js",
"./obsidian-store": "./obsidian-store.js"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
"@next/eslint-plugin-next": "~15.0.3",
"eslint": "catalog:",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-obsidianmd": "^0.4.1",
"eslint-plugin-only-warn": "^1.1.0",
"eslint-plugin-prefer-arrow-functions": "3.4.2",
"eslint-plugin-react": "^7.37.2",
Expand Down
Loading