Compare commits

..

131 commits
1.3.4 ... main

Author SHA1 Message Date
Michael Naumov
b016ba5af8 chore: update libs 2026-07-21 19:18:15 -06:00
Michael Naumov
877db481cd chore: release 1.6.11 2026-07-20 18:39:46 -06:00
Michael Naumov
a3c2a3763b chore: update libs 2026-07-20 18:38:21 -06:00
Michael Naumov
259f6c2b58 chore: release 1.6.10 2026-07-20 14:51:11 -06:00
Michael Naumov
cd847532c7 chore: update libs 2026-07-20 14:50:32 -06:00
Michael Naumov
5547b20570 chore(demo-vault): drop committed Invocables placeholder
The Demo Vault Helper (obsidian-dev-utils `bootstrapDemoVault`) now creates the
CodeScript Toolkit invocable-scripts folder at runtime, so the vault no longer
commits an otherwise-empty `Invocables/.gitkeep` placeholder.
2026-07-20 14:37:55 -06:00
Michael Naumov
f58b8b91f4 fix(demo-vault): export invoke() from startup script; add Invocables folder
CodeScript Toolkit runs the startup script via `startupScript.invoke(app)`, so a
top-level script with no `invoke` export threw `this.startupScript.invoke is not
a function` on vault open. Wrap the body in an exported `invoke(app)`. Also add an
empty `Invocables/` folder so every demo vault has the same CodeScript Toolkit
layout regardless of whether it ships invocable scripts.
2026-07-20 14:12:45 -06:00
Michael Naumov
633e49a849 chore: release 1.6.9 2026-07-20 12:50:26 -06:00
Michael Naumov
fc770c4818 docs: standardize demo-vault README 2026-07-20 11:08:11 -06:00
Michael Naumov
34aab9a63b docs: drop per-plugin demo-vault setup notes (bootstrap covered by ODU harness) 2026-07-20 10:46:40 -06:00
Michael Naumov
5e424f6fb1 docs: unnumber demo-vault setup notes 2026-07-20 10:18:08 -06:00
Michael Naumov
284ec28dcb Merge branch 'T107-renumber': number Backlink Full Path demo vault example notes (S2) 2026-07-20 09:04:58 -06:00
Michael Naumov
83f2bd105c docs: number demo vault example notes 2026-07-20 09:01:39 -06:00
Michael Naumov
696e59f268 Merge branch 'T107': create the Backlink Full Path demo vault (S2)
Bumps obsidian-dev-utils across the 86->87 major (no source fallout), registers
the Open demo vault command, and ships a curated demo-vault/ where three same-named
notes make the full-path Backlinks feature visible, with a static demo-vault
coverage suite. Bootstrapped by the release-injected demo-vault-helper.
2026-07-20 02:24:23 -06:00
Michael Naumov
3e27a6af52 feat: add demo vault
Ship a curated demo-vault/ for Backlink Full Path, bootstrapped by the
release-injected obsidian-dev-utils demo-vault-helper. Three same-named Meeting
notes in different folders link to a shared note so the Backlinks pane shows each
one's full path; a Settings note documents every option. Add a static
registerDemoVaultCoverageSuite no-app test, the project dprint.json (excludes
demo-vault), and the README demo vault access section.
2026-07-20 02:23:55 -06:00
Michael Naumov
b2d243c06f feat: register the open demo vault command
Add OpenDemoVaultCommandHandler (87.0.3 pluginId/pluginVersion signature) so the
plugin exposes the "Open demo vault" command; assert it is registered in the
plugin test.
2026-07-20 02:23:48 -06:00
Michael Naumov
4f3aed400d chore: update obsidian-dev-utils to 87.1.0
Bump across the 86 -> 87 major with no source fallout; unlocks the S2 demo-vault
mechanism (release-injected helper, coverage suite, demo-vault-safe lint runner).
2026-07-20 02:17:37 -06:00
Michael Naumov
a5753c14c9 chore: update libs 2026-07-15 01:16:47 -06:00
Michael Naumov
4e902f316d chore: update libs 2026-07-15 00:46:00 -06:00
Michael Naumov
8bde799cea docs: update 2026-07-15 00:22:48 -06:00
Michael Naumov
f80fd3b489 docs: migrate to AGENTS.md 2026-07-14 15:14:19 -06:00
Michael Naumov
89d84b434f chore: release 1.6.8 2026-07-11 05:26:35 -06:00
Michael Naumov
d0400cddba chore: update libs 2026-07-11 03:32:01 -06:00
Michael Naumov
cd3344a0c9 chore: update obsidian-dev-utils to 85.0.0 2026-07-10 22:45:18 -06:00
Michael Naumov
47605cd005 build: lock typescript to 6.0.3 2026-07-09 16:37:52 -06:00
Michael Naumov
5fd078852d test: wire integration-testing vitest-setup into integration projects 2026-06-30 16:41:40 -06:00
Michael Naumov
7e1aa58fb5 chore: update libs 2026-06-30 16:39:05 -06:00
Michael Naumov
a0d4c2aaf5 chore: sort tsconfig types 2026-06-27 21:12:11 -06:00
Michael Naumov
0885eab60e chore: release 1.6.7 2026-06-27 00:45:30 -06:00
Michael Naumov
e36472f767 chore: update libs 2026-06-26 14:26:24 -06:00
Michael Naumov
c63d18de36 test: add desktop-performance integration suite 2026-06-26 11:55:46 -06:00
Michael Naumov
d33fae860c chore: update libs 2026-06-24 19:26:43 -06:00
Michael Naumov
90009a4b55 chore: update overexposed scripts 2026-06-24 19:04:04 -06:00
Michael Naumov
162e5a2529 chore: honor NANO_STAGED opt-out in nano-staged config 2026-06-24 19:04:01 -06:00
Michael Naumov
ac75ad65d6 chore: update libs 2026-06-24 17:54:21 -06:00
Michael Naumov
1d8ba1cf22 chore: update libs 2026-06-24 15:16:42 -06:00
Michael Naumov
1d68a6ed1f chore: find-overexposed
Add find-overexposed and find-overexposed:fix scripts wrapping the
obsidian-dev-utils over-exposure linter.
2026-06-24 00:59:12 -06:00
Michael Naumov
a94cdeccdb chore: update libs
Update obsidian-dev-utils to 78.0.0 and adapt to the breaking change
dropping the `app` argument from affected APIs.
2026-06-24 00:58:11 -06:00
Michael Naumov
5474afcd9f chore: update libs
Bump obsidian-dev-utils ^75.0.0 -> ^76.1.0 and obsidian-integration-testing ^4.3.0 ->
^4.4.0. No new lint/type errors surfaced from the 76.x breaking changes.
2026-06-21 21:44:39 -06:00
Michael Naumov
6032a3f0e5 chore: update libs
Bump obsidian-dev-utils ^74.2.0 -> ^75.0.0 (major) plus other caret dev
dependencies: @obsidian-typings/obsidian-public-latest ^6.15.0 -> ^6.16.0,
@types/node ^25.9.3 -> ^26.0.0, obsidian-integration-testing ^4.2.6 -> ^4.3.0,
obsidian-test-mocks ^3.1.1 -> ^3.3.0. Ran npm audit fix (resolved 1 high
severity advisory, 0 remaining).

Wire 'obsidian-dev-utils/vitest-setup' into the unit-tests project setupFiles
after the two obsidian-test-mocks setup entries.
2026-06-21 14:38:09 -06:00
Michael Naumov
f7d382a4fa chore: update obsidian-dev-utils to 74.2.0 2026-06-21 12:43:42 -06:00
Michael Naumov
14b711cda5 test: cleanup 2026-06-21 01:19:18 -06:00
Michael Naumov
f21aea996b chore: unify tsconfig 2026-06-21 01:19:03 -06:00
Michael Naumov
31a0711331 chore: release 1.6.6 2026-06-20 21:51:10 -06:00
Michael Naumov
b3f3c329bf test: restore component tests to real dev-utils base classes
Rewrite backlink-full-path-component.test.ts to drive the real
LayoutReadyComponent lifecycle and add real-MonkeyAroundComponent tests
for the extracted patch components, replacing the stale, mock-based suite
left behind by the monkey-around refactor. Restores 100% coverage.
2026-06-20 21:39:43 -06:00
Michael Naumov
ee1b609a54 refactor: monkey around 2026-06-19 20:46:11 -06:00
Michael Naumov
191e7150d7 chore: release 1.6.5 2026-06-18 22:32:59 -06:00
Michael Naumov
3c2c7c44e1 docs: remove resolved file-protocol Known Issue
Lint, format, and spellcheck no longer fail: obsidian-dev-utils is now
installed from npm (^72.0.0) rather than via the `file:` protocol, so the
transitive tool dependencies are hoisted correctly. All three checks pass.
2026-06-18 21:19:16 -06:00
Michael Naumov
fcf4e4e242 test: migrate component tests to new template architecture and restore 100% coverage
The "new template" refactor moved the backlink logic out of `Plugin` into
`BacklinkFullPathComponent`, but the tests still targeted the old `Plugin` API,
leaving 46 failing tests and broken coverage. Realign the suite with the
component architecture and the canonical template test layout.

- rewrite plugin.test.ts to verify onloadImpl wiring (mocked children)
- add backlink-full-path-component.test.ts covering the moved backlink logic
- add plugin-settings-component.test.ts
- rewrite plugin-settings-tab.test.ts using the bind-spy pattern

Coverage: 100% statements/branches/functions/lines.
2026-06-18 21:07:14 -06:00
Michael Naumov
6f0f2b8d4e refactor: new template 2026-06-18 20:04:43 -06:00
Michael Naumov
338744bd46 chore: update version script 2026-06-11 13:35:43 -06:00
Michael Naumov
ec9771fc2d chore: release 1.6.4 2026-06-11 02:59:50 -06:00
Michael Naumov
33ecbba68c chore: update libs 2026-06-11 02:56:54 -06:00
Michael Naumov
882e886710 chore: release 1.6.3 2026-06-09 03:51:51 -06:00
Michael Naumov
65a395e9f1 chore: update libs 2026-06-09 03:47:02 -06:00
Michael Naumov
d6ba377125 chore: upgrade dependencies and green up all checks 2026-06-07 13:45:28 -06:00
Michael Naumov
01d8c5116f chore: update libs 2026-06-06 22:38:18 -06:00
Michael Naumov
5e8cf579b1 docs: fix 2026-05-26 17:07:11 -06:00
Michael Naumov
fe25a77d33 chore: update template 2026-05-26 12:39:14 -06:00
Michael Naumov
cc483ff1a1 chore: update libs 2026-05-18 00:37:14 -06:00
Michael Naumov
e63ca64041 refactor: migrate to @obsidian-typings/obsidian-public-latest
- Replace obsidian-typings with @obsidian-typings/obsidian-public-latest
- Update vitest config: replace ssr.noExternal with server.deps.inline
- Add DOM.Iterable to tsconfig lib
- Remove obsolete overrides (@antfu/utils, boolean, dompurify)
- Upgrade dependencies via npm-check-updates
2026-05-16 23:55:19 -06:00
Michael Naumov
c0abac4352 refactor: simplify PluginSettingsComponent constructor to accept plugin directly 2026-04-22 11:27:42 -06:00
Michael Naumov
a3a129d30b refactor: pass pluginSettingsComponent instead of pluginSettings getter 2026-04-22 11:09:41 -06:00
Michael Naumov
c58962f247 chore: update package-lock.json 2026-04-22 09:27:01 -06:00
Michael Naumov
b4eb22208e fix: remove unnecessary PluginSettingsTabBase cast after TS 6 upgrade 2026-04-22 08:59:41 -06:00
Michael Naumov
f32bbb7b58 refactor!: migrate to obsidian-dev-utils v2 component architecture
- Delete PluginTypes.ts and remove generic from PluginBase
- Convert PluginSettingsManager to PluginSettingsComponent with DI params
- Use constructor registerComponent() pattern for settings and settings tab
- Replace onSaveSettings override with event subscription on settings component
- Rename all source files to kebab-case (Plugin.ts -> plugin.ts, etc.)
- Add vitest test infrastructure with jsdom environment and obsidian-test-mocks
- Add 14 tests covering settings defaults, component creation, and plugin construction
- Update tsconfig: remove allowJs, svelte types; add vitest.config.ts to include
- Add obsidian-test-mocks, sass-embedded, vitest, jsdom as dev dependencies

BREAKING CHANGE: Requires obsidian-dev-utils v2 (component architecture).
2026-04-22 02:31:42 -06:00
Michael Naumov
ea3e30ce0c chore: update libs 2026-04-08 08:34:04 -06:00
Michael Naumov
e2f185364d chore: update template 2026-04-08 08:00:53 -06:00
Michael Naumov
f9daadb768 chore: update libs 2026-04-06 17:09:39 -06:00
Michael Naumov
c57c994b5e chore: add @total-typescript/ts-reset, better-typescript-lib, and libReplacement 2026-04-06 16:36:57 -06:00
Michael Naumov
386c4c4cbe chore: unify rules 2026-04-06 09:29:21 -06:00
Michael Naumov
8f0d2102a8 chore: add English language requirement to issue templates 2026-03-07 18:02:32 -06:00
Michael Naumov
7a5b69860d chore: release 1.6.2 2026-03-06 17:01:33 -06:00
Michael Naumov
d38d9dc891 chore: update template 2026-03-06 16:54:55 -06:00
Michael Naumov
aada502563 chore: release 1.6.1 2026-01-12 11:53:51 -06:00
Michael Naumov
baf19079f5 chore: update libs 2026-01-12 11:49:45 -06:00
Michael Naumov
a2879efdc5 chore: release 1.6.0 2025-12-29 11:02:41 -06:00
Michael Naumov
b547fd2ba3 feat: add root paths
re #7
2025-12-29 11:00:38 -06:00
Michael Naumov
36af248428 chore: update libs 2025-12-29 11:00:15 -06:00
Michael Naumov
60ddf8f448 chore: release 1.5.19 2025-12-24 00:21:37 -06:00
Michael Naumov
c9f940129d chore: update libs 2025-12-24 00:05:34 -06:00
Michael Naumov
9d185c9dc9 chore: release 1.5.18 2025-11-24 12:04:43 -06:00
Michael Naumov
c623e45d35 chore: update libs 2025-11-24 12:00:27 -06:00
Michael Naumov
70c0bb254c chore: update libs 2025-11-20 15:15:21 -06:00
Michael Naumov
f3ee4c6055 chore: release 1.5.17 2025-10-17 00:35:50 -06:00
Michael Naumov
897a034db2 chore: update libs 2025-10-17 00:29:25 -06:00
Michael Naumov
cf5dbed479 chore: lint 2025-10-07 10:21:14 -06:00
Michael Naumov
94bbc2a691 chore: enable markdownlint 2025-10-05 19:54:40 -06:00
Michael Naumov
72ad010966 chore: release 1.5.16 2025-10-01 23:42:27 -06:00
Michael Naumov
c22162794b fix: build 2025-10-01 23:41:17 -06:00
Michael Naumov
5263752200 fix: build 2025-10-01 23:07:50 -06:00
Michael Naumov
694f2c0cf8 chore: update libs 2025-10-01 22:29:06 -06:00
Michael Naumov
3238445234 1.5.15 2025-09-29 22:18:08 -06:00
Michael Naumov
82d47433c9 chore: enable conventional commits 2025-09-29 22:08:04 -06:00
Michael Naumov
ffdcd65b25 1.5.14 2025-09-17 02:10:17 -06:00
Michael Naumov
81026f438d Update libs 2025-09-17 02:02:37 -06:00
Michael Naumov
69403e8996 1.5.13 2025-09-16 16:03:01 -06:00
Michael Naumov
2002cbd1ac Update libs 2025-09-16 15:50:30 -06:00
Michael Naumov
a4e3228c56 1.5.12 2025-09-14 14:47:43 -06:00
Michael Naumov
bf4c03f9a2 Update libs 2025-09-14 14:38:40 -06:00
Michael Naumov
b3599a496a 1.5.11 2025-09-03 20:49:55 -06:00
Michael Naumov
cb09095766 Update libs 2025-09-03 20:40:29 -06:00
Michael Naumov
d7aa9a61d7 1.5.10 2025-09-02 15:25:09 -06:00
Michael Naumov
dbd47450c4 Update libs 2025-09-02 15:17:50 -06:00
Michael Naumov
3426e20cca 1.5.9 2025-09-01 21:32:33 -06:00
Michael Naumov
451cef6953 Update libs 2025-09-01 21:27:07 -06:00
Michael Naumov
e0cdb41fea 1.5.8 2025-09-01 01:52:32 -06:00
Michael Naumov
339db7e612 Update libs 2025-09-01 01:28:59 -06:00
Michael Naumov
532ed2d13c 1.5.7 2025-08-28 14:40:33 -06:00
Michael Naumov
ad3197de89 Update libs 2025-08-28 14:34:00 -06:00
Michael Naumov
5db4cdaf1a 1.5.6 2025-08-19 09:42:19 -06:00
Michael Naumov
41eaa0fb59 Update libs 2025-08-19 09:38:18 -06:00
Michael Naumov
94392685c8 1.5.5 2025-08-15 22:00:16 -06:00
Michael Naumov
09c1f312d7 Update libs 2025-08-15 21:51:04 -06:00
Michael Naumov
066337f19f 1.5.4 2025-08-12 23:47:42 -06:00
Michael Naumov
45cf33a5e3 Update libs 2025-08-12 23:43:29 -06:00
Michael Naumov
01bb126a7e Update libs 2025-08-12 23:39:38 -06:00
Michael Naumov
1032cc4c73 1.5.3 2025-06-02 13:37:50 -06:00
Michael Naumov
ed8d44d73a Update libs 2025-06-02 12:55:02 -06:00
Michael Naumov
8c23368f2f 1.5.2 2025-05-26 19:35:11 -06:00
Michael Naumov
63814db25b Update libs 2025-05-26 17:44:53 -06:00
Michael Naumov
1aa4097109 Update libs 2025-05-26 14:04:51 -06:00
Michael Naumov
a9e88c2241 1.5.1 2025-05-18 22:42:56 -06:00
Michael Naumov
340ef23e9b Fix build 2025-05-18 22:42:15 -06:00
Michael Naumov
1451adf8ac Update libs 2025-05-18 22:19:02 -06:00
Michael Naumov
54e46f9841 Update link 2025-04-21 09:40:32 -06:00
Michael Naumov
85a2e4f819 1.5.0 2025-04-15 19:57:06 -06:00
Michael Naumov
04a67e84f8 Refactor to shadow-root
This keeps `textContent` value used by
https://github.com/mdelobelle/obsidian_supercharged_links

Fixes: https://github.com/mnaoumov/obsidian-backlink-full-path/issues/5
2025-04-15 19:54:33 -06:00
Michael Naumov
a36cab91ac 1.4.0 2025-04-10 12:27:12 -06:00
Michael Naumov
e4da485498 Restyle 2025-04-10 12:26:29 -06:00
Michael Naumov
d8daa16f0e shouldDisplayParentPathOnSeparateLine 2025-04-10 11:35:48 -06:00
90 changed files with 11789 additions and 4031 deletions

14
.github/FUNDING.yml vendored
View file

@ -1,15 +1,3 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
buy_me_a_coffee: mnaoumov

View file

@ -1,6 +1,6 @@
name: Bug report
description: Report a bug and help improve the plugin
title: "[BUG] Short description of the bug"
title: "[BUG] Don't forget to write a meaningful title"
labels: bug
assignees: mnaoumov
body:
@ -8,6 +8,12 @@ body:
attributes:
value: |
## Bug report
> [!WARNING]
>
> Both a video and a sample vault are required for bug reproduction. Incomplete, vague, or poorly described bug reports will be closed.
>
> The bug report and all attachments (screenshots, videos, sample vaults) must be in English. Exception: the bug is directly related to non-English language usage. Non-English bug reports will be closed.
- type: textarea
attributes:
label: Description
@ -16,44 +22,74 @@ body:
required: true
- type: textarea
attributes:
label: Steps to Reproduce
label: Steps to reproduce
description: Provide a step-by-step description.
value: |
placeholder: |
1. Go to '...'
2. Click on '...'
3. Notice that '...'
...
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
label: Expected behavior
description: What did you expect to happen?
validations:
required: true
- type: textarea
attributes:
label: Actual Behavior
label: Actual behavior
description: What actually happened? Include error messages if available.
validations:
required: true
- type: textarea
- type: input
attributes:
label: Environment Information
description: Environment Information
value: |
- **Plugin Version**: [e.g., 1.0.0]
- **Obsidian Version**: [e.g., v1.3.2]
- **Operating System**: [e.g., Windows 10]
label: Plugin version
description: ⚠️ Must be the latest version. If not, explain why above.
placeholder: 1.0.0
validations:
required: true
- type: input
attributes:
label: Obsidian version
description: ⚠️ Must be the latest version. If not, explain why above.
placeholder: 1.3.2
validations:
required: true
- type: input
attributes:
label: Operating system
placeholder: Windows 10
validations:
required: true
- type: textarea
attributes:
label: Attachments
description: Required for bug reproduction
value: |
- Please attach a video showing the bug. It is not mandatory, but might be very helpful to speed up the bug fix
- Please attach a sample vault where the bug can be reproduced. It is not mandatory, but might be very helpful to speed up the bug fix
description: Attach a video and a sample vault.
placeholder: Drag and drop files here.
- type: dropdown
attributes:
label: Video
options:
- "Not attached. ⚠️ The bug report will be closed if the maintainer is unable to reproduce it without the attachment."
- "Attached"
validations:
required: true
- type: dropdown
attributes:
label: Sample vault
options:
- "Not attached. ⚠️ The bug report will be closed if the maintainer is unable to reproduce it without the attachment."
- "Attached"
validations:
required: true
- type: dropdown
attributes:
label: Bug isolation
description: Did you verify the bug in isolation from other plugins?
options:
- "No, only with other plugins enabled (provide details in the description field). ⚠️ Sample vault required or the bug report will be closed."
- "Yes, I could reproduce the bug in the vault with only this plugin enabled."
validations:
required: true
- type: checkboxes
@ -61,11 +97,5 @@ body:
label: Confirmations
description: Ensure the following conditions are met
options:
- label: I attached a video showing the bug, or it is not necessary
required: true
- label: I attached a sample vault where the bug can be reproduced, or it is not necessary
required: true
- label: I have tested the bug with the latest version of the plugin
required: true
- label: I have checked GitHub for existing bugs
- label: I have checked the plugin's GitHub repository and haven't found any similar bugs
required: true

View file

@ -1,13 +1,19 @@
name: Feature request
description: Request a feature and help improve the plugin
title: "[FR] Short description of the feature"
title: "[FR] Don't forget to write a meaningful title"
labels: enhancement
assignees: mnaoumov
body:
- type: markdown
attributes:
value: |
## Feature Request
## Feature request
> [!WARNING]
>
> Incomplete, vague, or poorly described requests will be closed.
>
> The feature request and all attachments (screenshots, videos, sample vaults) must be in English. Exception: the request is directly related to non-English language usage. Non-English feature requests will be closed.
- type: textarea
attributes:
label: Description
@ -16,34 +22,55 @@ body:
required: true
- type: textarea
attributes:
label: Details
description: Provide a step-by-step description.
value: |
1. Go to '...'
2. Click on '...'
3. Notice that '...'
...
label: Use case
description: Describe a scenario where this feature would be useful.
validations:
required: true
- type: textarea
attributes:
label: Desired Behavior
label: Desired behavior
description: What do you want to happen?
validations:
required: true
- type: textarea
attributes:
label: Current Behavior
label: Current behavior
description: What actually happens?
validations:
required: true
- type: input
attributes:
label: Plugin version
description: ⚠️ Must be the latest version. If not, explain why above.
placeholder: 1.0.0
validations:
required: true
- type: input
attributes:
label: Obsidian version
description: ⚠️ Must be the latest version. If not, explain why above.
placeholder: 1.3.2
validations:
required: true
- type: textarea
attributes:
label: Attachments
description: Required for feature investigation
value: |
- Please attach a video showing the current behavior. It is not mandatory, but might be very helpful to speed up the feature implementation
- Please attach a sample vault where the desired Feature Request could be applied. It is not mandatory, but might be very helpful to speed up the feature implementation
description: Attach a video and a sample vault.
placeholder: Drag and drop files here.
- type: dropdown
attributes:
label: Video
options:
- "Not attached. ⚠️ Providing a video helps the maintainer understand the request and speeds up implementation."
- "Attached"
validations:
required: true
- type: dropdown
attributes:
label: Sample vault
options:
- "Not attached. ⚠️ Providing a sample vault helps the maintainer understand the request and speeds up implementation."
- "Attached"
validations:
required: true
- type: checkboxes
@ -51,11 +78,5 @@ body:
label: Confirmations
description: Ensure the following conditions are met
options:
- label: I attached a video showing the current behavior, or it is not necessary
required: true
- label: I attached a sample vault where the desired Feature Request could be applied, or it is not necessary
required: true
- label: I have tested the absence of the requested feature with the latest version of the plugin
required: true
- label: I have checked GitHub for existing Feature Requests
- label: I have checked the plugin's GitHub repository and haven't found any similar feature requests
required: true

View file

@ -0,0 +1,24 @@
name: Attest Release Assets
on:
release:
types: [published]
permissions:
id-token: write
contents: read
attestations: write
jobs:
attest:
runs-on: ubuntu-latest
steps:
- name: Download release assets
env:
GH_TOKEN: ${{ github.token }}
run: gh release download "${{ github.event.release.tag_name }}" --repo "${{ github.repository }}" --dir assets
- name: Generate artifact attestation
uses: actions/attest@v4
with:
subject-path: assets/*

4
.gitignore vendored
View file

@ -21,6 +21,8 @@ data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
coverage
dist
.env
/tsconfig.tsbuildinfo
/tsconfig.tsbuildinfo
.claude/settings.local.json

1
.husky/commit-msg Normal file
View file

@ -0,0 +1 @@
npx --no -- commitlint --edit $1

1
.husky/pre-commit Normal file
View file

@ -0,0 +1 @@
npx --no -- nano-staged

4
.markdownlint-cli2.mjs Normal file
View file

@ -0,0 +1,4 @@
import { createJiti } from 'jiti';
const jiti = createJiti(import.meta.url);
const { config } = await jiti.import('./scripts/markdownlint-cli2-config.ts');
export default config;

5
.nano-staged.mjs Normal file
View file

@ -0,0 +1,5 @@
import { createJiti } from 'jiti';
const jiti = createJiti(import.meta.url);
const { config } = await jiti.import('./scripts/nano-staged-config.ts');
export default config;

1
AGENTS.md Normal file
View file

@ -0,0 +1 @@
# AGENTS.md

View file

@ -1,16 +1,171 @@
# CHANGELOG
## 1.6.11
- chore: update libs
## 1.6.10
- chore: update libs
- chore(demo-vault): drop committed Invocables placeholder
- fix(demo-vault): export invoke() from startup script; add Invocables folder
## 1.6.9
- docs: standardize demo-vault README
- docs: drop per-plugin demo-vault setup notes (bootstrap covered by ODU harness)
- docs: unnumber demo-vault setup notes
- Merge branch 'T107-renumber': number Backlink Full Path demo vault example notes (S2)
- Merge branch 'T107': create the Backlink Full Path demo vault (S2)
- chore: update libs
- docs: update
- docs: migrate to AGENTS.md
## 1.6.8
- chore: update libs
- chore: update obsidian-dev-utils to 85.0.0
- build: lock typescript to 6.0.3
- test: wire integration-testing vitest-setup into integration projects
- chore: update libs
- chore: sort tsconfig types
## 1.6.7
- refactor: new template
## 1.6.6
- test: restore component tests to real dev-utils base classes
- refactor: monkey around
## 1.6.5
- docs: remove resolved file-protocol
- test: migrate component tests to new template architecture and restore 100% coverage
- refactor: new template
- chore: update version script
## 1.6.4
- chore: update libs
## 1.6.3
- chore: update template
## 1.6.2
- chore: update template
## 1.6.1
- chore: update libs
## 1.6.0
- feat: add root paths re #7
- chore: update libs
## 1.5.19
- chore: update libs
## 1.5.18
- chore: update libs
## 1.5.17
- chore: update libs
- chore: lint
- chore: enable markdownlint
## 1.5.16
- fix: build
- chore: update libs
## 1.5.15
- chore: enable conventional commits
## 1.5.14
- Minor changes
## 1.5.13
- Minor changes
## 1.5.12
- Minor changes
## 1.5.11
- Minor changes
## 1.5.10
- Minor changes
## 1.5.9
- Minor changes
## 1.5.8
- Minor changes
## 1.5.7
- Minor changes
## 1.5.6
- Minor changes
## 1.5.5
- Minor changes
## 1.5.4
- Minor changes
## 1.5.3
- Minor changes
## 1.5.2
- Minor changes
## 1.5.1
- Minor changes
## 1.5.0
- Refactor to shadow-root.
- This keeps `textContent` value used by [Supercharged Links](https://github.com/mdelobelle/obsidian_supercharged_links) (#5)
## 1.4.0
- shouldDisplayParentPathOnSeparateLine
## 1.3.4
- https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/24.1.1
- Minor changes
## 1.3.3
- https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/24.1.0
- Minor changes
## 1.3.2
- https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/24.0.1
- Minor changes
## 1.3.1
@ -28,7 +183,6 @@
- Add setting: Path depth
- Add setting: Should include extension
- https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/20.2.1
## 1.0.1

71
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,71 @@
# Contributing
Contributions are welcome! Here's how to get started.
## Prerequisites
- [Node.js](https://nodejs.org/) (latest LTS recommended)
- npm (comes with Node.js)
## Setup
```bash
git clone https://github.com/mnaoumov/obsidian-backlink-full-path.git
cd obsidian-backlink-full-path
npm install
```
## Development Workflow
### Build
```bash
npm run build
```
### Dev Mode
```bash
npm run dev
```
### Commit
This project uses [Conventional Commits](https://www.conventionalcommits.org/). Use the interactive commit prompt:
```bash
npm run commit
```
### Lint
```bash
npm run lint
npm run lint:fix
```
### Format
```bash
npm run format:check
npm run format
```
### Spellcheck
```bash
npm run spellcheck
```
### Test
```bash
npm run test
npm run test:coverage
```
## Pull Requests
- Base your PR on the `master` branch.
- Ensure all checks pass (`lint`, `format:check`, `spellcheck`, `test`).
- Use [Conventional Commits](https://www.conventionalcommits.org/) for your commit messages.

View file

@ -1,16 +1,29 @@
# Backlink Full Path
[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/mnaoumov)
[![GitHub release](https://img.shields.io/github/v/release/mnaoumov/obsidian-backlink-full-path)](https://github.com/mnaoumov/obsidian-backlink-full-path/releases)
[![GitHub downloads](https://img.shields.io/github/downloads/mnaoumov/obsidian-backlink-full-path/total)](https://github.com/mnaoumov/obsidian-backlink-full-path/releases)
[![Coverage: 100%](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/mnaoumov/obsidian-backlink-full-path)
This is a plugin for [Obsidian](https://obsidian.md/) that shows the backlink's full path in the backlinks panel.
By default, Obsidian just shows the note name
![](./images/just-name.png)
![Just name](./images/just-name.png)
Which might be confusing when you have notes with the same name in different folders.
This plugin shows the full name helping to identify the backlinks better
![](./images/full-path.png)
![Full path](./images/full-path.png)
## Demo vault
A demo vault with usage examples ships with every release. You can access it via any of the following:
1. Running the **Backlink Full Path: Open demo vault** command.
2. Downloading `backlink-full-path.demo-vault.zip` from the [Releases](https://github.com/mnaoumov/obsidian-backlink-full-path/releases).
3. Browsing its source in [`demo-vault/`](./demo-vault/README.md) in this repository.
## Installation
@ -34,11 +47,19 @@ To show them, run the following command:
window.DEBUG.enable('backlink-full-path');
```
For more details, refer to the [documentation](https://github.com/mnaoumov/obsidian-dev-utils?tab=readme-ov-file#debugging).
For more details, refer to the [documentation](https://mnaoumov.dev/obsidian-dev-utils/guides/debugging/).
## Support
<a href="https://www.buymeacoffee.com/mnaoumov" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;"></a>
<!-- markdownlint-disable MD033 -->
<a href="https://www.buymeacoffee.com/mnaoumov" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="60" width="217"></a>
<!-- markdownlint-enable MD033 -->
## My other Obsidian resources
[See my other Obsidian resources](https://github.com/mnaoumov/obsidian-resources).
## License

3
commitlint.config.ts Normal file
View file

@ -0,0 +1,3 @@
import { config } from './scripts/commitlint-config.ts';
export default config;

View file

@ -1,6 +1,7 @@
{
"version": "0.2",
"ignorePaths": [
"coverage",
"dist",
"node_modules",
"tsconfig.tsbuildinfo"
@ -8,12 +9,18 @@
"dictionaryDefinitions": [],
"dictionaries": [],
"words": [
"Invocables",
"unnumber",
"backlink",
"backlinks",
"dblclick",
"jsdom",
"lcov",
"mnaoumov",
"Naumov",
"tsbuildinfo"
"noopAsync",
"tsbuildinfo",
"unsanitized"
],
"ignoreWords": [],
"import": [],

13
demo-vault/.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
# Churny / runtime-generated Obsidian state.
.obsidian/workspace.json
.obsidian/hotkeys.json
# Built plugins are release-injected by archivePluginDemoVault at release time —
# both the plugin under test AND the obsidian-dev-utils-owned Demo Vault Helper
# bootstrap plugin (which installs, configures, and enables CodeScript Toolkit at
# runtime). Nothing helper-related is committed; commit only the baked settings
# file for the plugin under test.
.obsidian/plugins/*
!.obsidian/plugins/backlink-full-path/
.obsidian/plugins/backlink-full-path/*
!.obsidian/plugins/backlink-full-path/data.json

View file

@ -0,0 +1,12 @@
{
// Scoped markdownlint config for the demo vault. The notes are an Obsidian
// vault, so two default rules are inherently incompatible and disabled here;
// every other rule still applies so the notes stay clean.
"config": {
// Notes intentionally open with a `[Docs](...)` link, not a heading.
"MD041": false,
// `[[wikilinks]]` are Obsidian's native link syntax; markdownlint parses
// them as undefined shortcut reference links.
"MD052": false
}
}

8
demo-vault/.obsidian/app.json vendored Normal file
View file

@ -0,0 +1,8 @@
{
"attachmentFolderPath": "_assets/attachments",
"newLinkFormat": "shortest",
"useMarkdownLinks": false,
"showUnsupportedFiles": true,
"defaultViewMode": "source",
"livePreview": true
}

View file

@ -0,0 +1,4 @@
[
"demo-vault-helper",
"backlink-full-path"
]

8
demo-vault/00 Start.md Normal file
View file

@ -0,0 +1,8 @@
Welcome to the [Backlink Full Path](https://github.com/mnaoumov/obsidian-backlink-full-path/) demo vault. Obsidian's core **Backlinks** pane shows only each linking note's file name - so several notes that share a name (three different `Meeting` notes, say) look identical. **Backlink Full Path** adds each note's folder path to that pane so you can tell them apart, with plenty of options for how the path is shown.
**How to see it:** open [[Shared topic]], then open the **Backlinks** pane (the link icon in the right sidebar, or the "Backlinks" view). Three notes all named `Meeting` link here - with the plugin enabled, each backlink shows its full folder path.
## Feature
- [[01 Backlink full path]]
- [[02 Settings]]

View file

@ -0,0 +1,18 @@
[Docs](https://github.com/mnaoumov/obsidian-backlink-full-path/)
# Backlink full path
Open [[Shared topic]] and look at its **Backlinks** pane. Three different notes link to it, and all three are named `Meeting`:
- [[Projects/Alpha/Meeting]]
- [[Projects/Beta/Meeting]]
- [[Notes/Meeting]]
Without this plugin, the Backlinks pane lists three identical `Meeting` entries. With it enabled, each one shows its folder path (`Projects/Alpha`, `Projects/Beta`, `Notes`) so you know which is which.
## Try it
1. Open [[Shared topic]].
2. Open the **Backlinks** pane (right sidebar link icon, or the command **Backlinks: Show backlinks**).
3. Notice each backlink now carries its full path.
4. Head to [[02 Settings]] and tweak how the path is rendered - depth, highlighting, extension, ordering, and more.

20
demo-vault/02 Settings.md Normal file
View file

@ -0,0 +1,20 @@
[Docs](https://github.com/mnaoumov/obsidian-backlink-full-path/)
# Settings
Open **Settings -> Community plugins -> Backlink Full Path** to configure the pane. Each option below lists the setting key stored in the plugin's `data.json`.
## Which path to show
- `pathDepth` - how many parent folders to include (0 shows the whole path).
- `rootPaths` - folders to treat as roots; paths under them are shown relative to the root.
- `shouldReversePathParts` - render the path from the file outwards instead of from the root inwards.
- `shouldShowEllipsisForSkippedPathParts` - show an ellipsis where parts were trimmed by `pathDepth` or a root.
## How it looks
- `shouldHighlightFileName` - visually emphasize the file name within the full path.
- `shouldIncludeExtension` - keep the `.md` (or other) extension in the shown name.
- `shouldDisplayParentPathOnSeparateLine` - put the folder path on its own line above the file name.
Change any of these and watch the Backlinks pane of [[Shared topic]] update live.

View file

@ -0,0 +1,7 @@
[Docs](https://github.com/mnaoumov/obsidian-backlink-full-path/)
# Meeting (general notes)
A general meeting note. Related to [[Shared topic]].
The third note named `Meeting`; the Backlinks pane shows its path as `Notes`.

View file

@ -0,0 +1,7 @@
[Docs](https://github.com/mnaoumov/obsidian-backlink-full-path/)
# Meeting (Project Alpha)
Notes from the Alpha project meeting. Related to [[Shared topic]].
This note is named `Meeting`, like two others in the vault - the Backlinks pane of [[Shared topic]] shows its full path, `Projects/Alpha`, to keep it distinct.

View file

@ -0,0 +1,7 @@
[Docs](https://github.com/mnaoumov/obsidian-backlink-full-path/)
# Meeting (Project Beta)
Notes from the Beta project meeting. Related to [[Shared topic]].
Also named `Meeting`; in the Backlinks pane of [[Shared topic]] it is shown as `Projects/Beta` so you can tell it apart from the others.

8
demo-vault/README.md Normal file
View file

@ -0,0 +1,8 @@
This is an [Obsidian](https://obsidian.md/) vault demonstrating features of the [Backlink Full Path](https://github.com/mnaoumov/obsidian-backlink-full-path) plugin.
## Opening this vault
- **If you already have the plugin installed**, run the **Backlink Full Path: Open demo vault** command — it downloads and opens the latest demo vault automatically.
- **Otherwise**, download `backlink-full-path.demo-vault.zip` from the [plugin releases](https://github.com/mnaoumov/obsidian-backlink-full-path/releases), unzip it, and in Obsidian choose `Manage vaults...``Open folder as vault` → select the unzipped folder.
On first open, Obsidian treats the vault as untrusted — choose **Trust author and enable plugins** (or enable Community plugins manually), then open [00 Start](<./00 Start.md>).

View file

@ -0,0 +1,5 @@
[Docs](https://github.com/mnaoumov/obsidian-backlink-full-path/)
# Shared topic
This is the note whose backlinks the demo inspects. Open the **Backlinks** pane and look at the three `Meeting` notes that link here - each one shows its full folder path thanks to [[01 Backlink full path]].

View file

@ -0,0 +1,17 @@
// Per-vault startup script, run by the universal Demo Vault Helper plugin (via CodeScript
// Toolkit's require) once CodeScript Toolkit is installed and enabled. This is where each
// plugin's demo vault does its own startup setup. Here it just opens the landing note.
import type { App } from 'obsidian';
const START_NOTE_PATH = '00 Start.md';
// Run by CodeScript Toolkit on load (its `startupScriptPath` setting, which the Demo Vault Helper
// points here). CST calls the exported `invoke` — a top-level script with no `invoke` export throws
// `this.startupScript.invoke is not a function`.
export async function invoke(app: App): Promise<void> {
const startNote = app.vault.getFileByPath(START_NOTE_PATH);
if (startNote) {
await app.workspace.getLeaf(false).openFile(startNote);
}
}

10
dprint.json Normal file
View file

@ -0,0 +1,10 @@
{
"extends": "./node_modules/obsidian-dev-utils/dist/templates/dprint.json",
"excludes": [
"dist",
"**/node_modules",
"**/*-lock.json",
"**/*.md",
"demo-vault"
]
}

View file

@ -1,4 +1,3 @@
import { configs } from 'obsidian-dev-utils/ScriptUtils/ESLint/eslint.config';
import { configs } from './scripts/eslint-config.ts';
// eslint-disable-next-line import-x/no-default-export
export default configs;

View file

@ -6,6 +6,6 @@
"id": "backlink-full-path",
"isDesktopOnly": false,
"name": "Backlink Full Path",
"version": "1.3.4",
"minAppVersion": "1.8.9"
"version": "1.6.11",
"minAppVersion": "1.12.7"
}

13159
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,34 +1,71 @@
{
"name": "backlink-full-path",
"version": "1.3.4",
"version": "1.6.11",
"description": "Shows the backlink's full path in the backlinks panel.",
"keywords": [],
"homepage": "https://github.com/mnaoumov/obsidian-backlink-full-path#readme",
"bugs": "https://github.com/mnaoumov/obsidian-backlink-full-path/issues",
"repository": "git+https://github.com/mnaoumov/obsidian-backlink-full-path.git",
"license": "MIT",
"author": "mnaoumov",
"type": "module",
"main": "src/main.ts",
"scripts": {
"build": "obsidian-dev-utils build",
"build:clean": "obsidian-dev-utils build:clean",
"build:compile": "obsidian-dev-utils build:compile",
"build:compile:svelte": "obsidian-dev-utils build:compile:svelte",
"build:compile:typescript": "obsidian-dev-utils build:compile:typescript",
"dev": "obsidian-dev-utils dev",
"format": "obsidian-dev-utils format",
"format:check": "obsidian-dev-utils format:check",
"lint": "obsidian-dev-utils lint",
"lint:fix": "obsidian-dev-utils lint:fix",
"spellcheck": "obsidian-dev-utils spellcheck",
"version": "obsidian-dev-utils version"
},
"devDependencies": {
"@tsconfig/strictest": "^2.0.5",
"@types/node": "^22.14.0",
"jiti": "^2.4.2",
"obsidian": "^1.8.7",
"obsidian-dev-utils": "^24.1.1",
"obsidian-typings": "^2.41.4"
"build": "jiti scripts/build.ts",
"build:clean": "jiti scripts/build-clean.ts",
"build:compile": "jiti scripts/build-compile.ts",
"build:compile:typescript": "jiti scripts/build-compile-typescript.ts",
"commit": "jiti scripts/commit.ts",
"dev": "jiti scripts/dev.ts",
"find-overexposed": "jiti scripts/find-overexposed.ts",
"find-overexposed:fix": "jiti scripts/find-overexposed-fix.ts",
"format": "jiti scripts/format.ts",
"format:check": "jiti scripts/format-check.ts",
"lint": "jiti scripts/lint.ts",
"lint:fix": "jiti scripts/lint-fix.ts",
"lint:md": "jiti scripts/lint-md.ts",
"lint:md:fix": "jiti scripts/lint-md-fix.ts",
"prepare": "jiti scripts/prepare.ts",
"spellcheck": "jiti scripts/spellcheck.ts",
"test": "jiti scripts/test.ts",
"test:coverage": "jiti scripts/test-coverage.ts",
"test:integration": "jiti scripts/test-integration.ts",
"test:integration:android": "jiti scripts/test-integration-android.ts",
"test:integration:desktop": "jiti scripts/test-integration-desktop.ts",
"test:integration:desktop:performance": "jiti scripts/test-integration-desktop-performance.ts",
"test:integration:no-app": "jiti scripts/test-integration-no-app.ts",
"test:watch": "jiti scripts/test-watch.ts",
"version": "jiti scripts/version.ts"
},
"overrides": {
"boolean": "npm:dry-uninstall"
"@vitest/coverage-v8": "$@vitest/coverage-v8",
"eslint": "$eslint",
"js-yaml": "^4.2.0",
"markdown-it": "^14.2.0",
"type-fest": "^5.6.0",
"typescript": "6.0.3"
},
"devDependencies": {
"@commitlint/cli": "^21.2.1",
"@commitlint/config-conventional": "^21.2.0",
"@commitlint/types": "^21.2.0",
"@obsidian-typings/obsidian-public-latest": "^6.25.0",
"@total-typescript/ts-reset": "^0.6.1",
"@tsconfig/strictest": "^2.0.8",
"@types/node": "^26.1.1",
"@vitest/coverage-v8": "^4.1.10",
"better-typescript-lib": "^2.12.0",
"czg": "^1.13.1",
"eslint": "^10.7.0",
"husky": "^9.1.7",
"jiti": "^2.7.0",
"jsdom": "^29.1.1",
"nano-staged": "^1.0.2",
"obsidian": "^1.13.1",
"obsidian-dev-utils": "^87.3.0",
"obsidian-integration-testing": "^9.1.0",
"obsidian-test-mocks": "^3.6.0",
"sass-embedded": "^1.100.0",
"vitest": "^4.1.10"
}
}

4
scripts/build-clean.ts Normal file
View file

@ -0,0 +1,4 @@
import { buildClean } from 'obsidian-dev-utils/script-utils/build';
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
await wrapCliTask(() => buildClean());

View file

@ -0,0 +1,4 @@
import { buildCompileTypeScript } from 'obsidian-dev-utils/script-utils/build';
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
await wrapCliTask(() => buildCompileTypeScript());

4
scripts/build-compile.ts Normal file
View file

@ -0,0 +1,4 @@
import { buildCompileTypeScript } from 'obsidian-dev-utils/script-utils/build';
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
await wrapCliTask(() => buildCompileTypeScript());

4
scripts/build.ts Normal file
View file

@ -0,0 +1,4 @@
import { build } from 'obsidian-dev-utils/script-utils/bundlers/esbuild';
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
await wrapCliTask(() => build());

3
scripts/commit.ts Normal file
View file

@ -0,0 +1,3 @@
import { exec } from 'obsidian-dev-utils/script-utils/exec';
await exec('czg');

View file

@ -0,0 +1,3 @@
import { obsidianDevUtilsConfig } from 'obsidian-dev-utils/script-utils/commitlint-config';
export const config = obsidianDevUtilsConfig;

4
scripts/dev.ts Normal file
View file

@ -0,0 +1,4 @@
import { dev } from 'obsidian-dev-utils/script-utils/bundlers/esbuild';
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
await wrapCliTask(() => dev());

3
scripts/eslint-config.ts Normal file
View file

@ -0,0 +1,3 @@
import { defineEslintConfigs } from 'obsidian-dev-utils/script-utils/linters/eslint-config';
export const configs = defineEslintConfigs({});

View file

@ -0,0 +1,25 @@
import process from 'node:process';
import { parseArgs } from 'node:util';
import {
CliTaskResult,
wrapCliTask
} from 'obsidian-dev-utils/script-utils/cli-utils';
import {
findOverExposure,
formatOverExposureFindings
} from 'obsidian-dev-utils/script-utils/linters/over-exposure';
const [, , ...args] = process.argv;
const { values } = parseArgs({
args,
options: {
force: { type: 'boolean' }
}
});
await wrapCliTask(() => {
const findings = findOverExposure({ projectFolder: process.cwd(), shouldFix: true, shouldForce: values.force ?? false });
process.stdout.write(formatOverExposureFindings(findings, { baseFolder: process.cwd() }));
return CliTaskResult.Success(findings.every((finding) => finding.wasFixed));
});

View file

@ -0,0 +1,15 @@
import process from 'node:process';
import {
CliTaskResult,
wrapCliTask
} from 'obsidian-dev-utils/script-utils/cli-utils';
import {
findOverExposure,
formatOverExposureFindings
} from 'obsidian-dev-utils/script-utils/linters/over-exposure';
await wrapCliTask(() => {
const findings = findOverExposure({ projectFolder: process.cwd() });
process.stdout.write(formatOverExposureFindings(findings, { baseFolder: process.cwd() }));
return CliTaskResult.Success(findings.length === 0);
});

7
scripts/format-check.ts Normal file
View file

@ -0,0 +1,7 @@
import process from 'node:process';
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { format } from 'obsidian-dev-utils/script-utils/formatters/dprint';
const [, , ...paths] = process.argv;
await wrapCliTask(() => format({ paths, rewrite: false }));

7
scripts/format.ts Normal file
View file

@ -0,0 +1,7 @@
import process from 'node:process';
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { format } from 'obsidian-dev-utils/script-utils/formatters/dprint';
const [, , ...paths] = process.argv;
await wrapCliTask(() => format({ paths }));

7
scripts/lint-fix.ts Normal file
View file

@ -0,0 +1,7 @@
import process from 'node:process';
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { lint } from 'obsidian-dev-utils/script-utils/linters/eslint';
const [, , ...paths] = process.argv;
await wrapCliTask(() => lint({ paths, shouldFix: true }));

7
scripts/lint-md-fix.ts Normal file
View file

@ -0,0 +1,7 @@
import process from 'node:process';
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { lint } from 'obsidian-dev-utils/script-utils/linters/markdownlint';
const [, , ...paths] = process.argv;
await wrapCliTask(() => lint({ paths, shouldFix: true }));

7
scripts/lint-md.ts Normal file
View file

@ -0,0 +1,7 @@
import process from 'node:process';
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { lint } from 'obsidian-dev-utils/script-utils/linters/markdownlint';
const [, , ...paths] = process.argv;
await wrapCliTask(() => lint({ paths }));

7
scripts/lint.ts Normal file
View file

@ -0,0 +1,7 @@
import process from 'node:process';
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { lint } from 'obsidian-dev-utils/script-utils/linters/eslint';
const [, , ...paths] = process.argv;
await wrapCliTask(() => lint({ paths }));

View file

@ -0,0 +1,3 @@
import { obsidianDevUtilsConfig } from 'obsidian-dev-utils/script-utils/linters/markdownlint-cli2-config';
export const config = obsidianDevUtilsConfig;

View file

@ -0,0 +1,3 @@
import { getNanoStagedConfig } from 'obsidian-dev-utils/script-utils/nano-staged-config';
export const config = getNanoStagedConfig();

3
scripts/prepare.ts Normal file
View file

@ -0,0 +1,3 @@
import { execFromRoot } from 'obsidian-dev-utils/script-utils/root';
await execFromRoot('husky');

7
scripts/spellcheck.ts Normal file
View file

@ -0,0 +1,7 @@
import process from 'node:process';
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { spellcheck } from 'obsidian-dev-utils/script-utils/linters/cspell';
const [, , ...paths] = process.argv;
await wrapCliTask(() => spellcheck({ paths }));

11
scripts/test-coverage.ts Normal file
View file

@ -0,0 +1,11 @@
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { testCoverage } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
const FULL_COVERAGE_PERCENT = 100;
await wrapCliTask(() =>
testCoverage({
minCoverageInPercents: FULL_COVERAGE_PERCENT,
projects: ['unit-tests']
})
);

View file

@ -0,0 +1,8 @@
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { test } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
await wrapCliTask(() =>
test({
projects: ['integration-tests:android']
})
);

View file

@ -0,0 +1,8 @@
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { test } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
await wrapCliTask(() =>
test({
projects: ['integration-tests:desktop-performance']
})
);

View file

@ -0,0 +1,8 @@
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { test } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
await wrapCliTask(() =>
test({
projects: ['integration-tests:desktop']
})
);

View file

@ -0,0 +1,8 @@
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { test } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
await wrapCliTask(() =>
test({
projects: ['integration-tests:no-app']
})
);

View file

@ -0,0 +1,14 @@
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { test } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
await wrapCliTask(async () => {
await test({
projects: ['integration-tests:no-app']
});
await test({
projects: ['integration-tests:android']
});
await test({
projects: ['integration-tests:desktop']
});
});

8
scripts/test-watch.ts Normal file
View file

@ -0,0 +1,8 @@
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { testWatch } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
await wrapCliTask(() =>
testWatch({
projects: ['unit-tests']
})
);

8
scripts/test.ts Normal file
View file

@ -0,0 +1,8 @@
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import { test } from 'obsidian-dev-utils/script-utils/test-runners/vitest';
await wrapCliTask(() =>
test({
projects: ['unit-tests']
})
);

10
scripts/version.ts Normal file
View file

@ -0,0 +1,10 @@
import process from 'node:process';
import { wrapCliTask } from 'obsidian-dev-utils/script-utils/cli-utils';
import {
parseVersionArgs,
updateVersion
} from 'obsidian-dev-utils/script-utils/version';
const [, , ...args] = process.argv;
const { options, versionUpdateType } = parseVersionArgs(args);
await wrapCliTask(() => updateVersion(versionUpdateType, options));

104
scripts/vitest-config.ts Normal file
View file

@ -0,0 +1,104 @@
import { defineConfig } from 'vitest/config';
const SHARED_EXCLUDE = ['node_modules', 'dist'];
const BIG_TIMEOUT_IN_MILLISECONDS = 30_000;
const ANDROID_TIMEOUT_IN_MILLISECONDS = 60_000;
const PERFORMANCE_TIMEOUT_IN_MILLISECONDS = 600_000;
const HOOK_TIMEOUT_MULTIPLIER = 4;
export const config = defineConfig({
test: {
coverage: {
exclude: [
'src/**/*.test.ts'
],
include: ['src/**/*.ts'],
provider: 'v8',
reporter: ['text', 'lcov', 'html'],
reportsDirectory: './coverage'
},
exclude: ['node_modules', 'dist'],
globals: false,
include: ['src/**/*.test.ts'],
passWithNoTests: true,
projects: [
{
resolve: {
alias: {
obsidian: 'obsidian-test-mocks/obsidian'
}
},
test: {
environment: 'jsdom',
exclude: [...SHARED_EXCLUDE, 'src/**/*.integration.test.ts'],
execArgv: ['--no-webstorage'],
include: ['src/**/*.test.ts'],
name: 'unit-tests',
server: {
deps: {
inline: ['@obsidian-typings', 'obsidian-dev-utils']
}
},
setupFiles: [
'obsidian-test-mocks/vitest-setup',
'obsidian-test-mocks/obsidian-typings/vitest-setup',
'obsidian-dev-utils/vitest-setup'
]
}
},
{
test: {
environment: 'node',
fileParallelism: false,
hookTimeout: BIG_TIMEOUT_IN_MILLISECONDS * HOOK_TIMEOUT_MULTIPLIER,
include: ['src/**/*.no-app.integration.test.ts'],
name: 'integration-tests:no-app',
testTimeout: BIG_TIMEOUT_IN_MILLISECONDS
}
},
{
test: {
environment: 'node',
fileParallelism: false,
globalSetup: ['obsidian-integration-testing/vitest-global-setup-plugin'],
hookTimeout: BIG_TIMEOUT_IN_MILLISECONDS * HOOK_TIMEOUT_MULTIPLIER,
include: ['src/**/*.desktop.integration.test.ts'],
name: 'integration-tests:desktop',
setupFiles: ['obsidian-integration-testing/vitest-setup'],
testTimeout: BIG_TIMEOUT_IN_MILLISECONDS
}
},
{
test: {
environment: 'node',
fileParallelism: false,
globalSetup: ['obsidian-integration-testing/vitest-global-setup-plugin'],
hookTimeout: PERFORMANCE_TIMEOUT_IN_MILLISECONDS,
include: ['src/**/*.desktop-performance.integration.test.ts'],
name: 'integration-tests:desktop-performance',
setupFiles: ['obsidian-integration-testing/vitest-setup'],
testTimeout: PERFORMANCE_TIMEOUT_IN_MILLISECONDS
}
},
{
test: {
environment: 'node',
environmentOptions: {
obsidianTransport: {
appiumUrl: 'http://localhost:4723',
avdName: 'obsidian_test',
type: 'obsidian-android-appium'
}
},
fileParallelism: false,
globalSetup: ['obsidian-integration-testing/vitest-global-setup-plugin'],
hookTimeout: ANDROID_TIMEOUT_IN_MILLISECONDS * HOOK_TIMEOUT_MULTIPLIER,
include: ['src/**/*.android.integration.test.ts'],
name: 'integration-tests:android',
setupFiles: ['obsidian-integration-testing/vitest-setup'],
testTimeout: ANDROID_TIMEOUT_IN_MILLISECONDS
}
}
]
}
});

View file

@ -1,173 +0,0 @@
import type { TFile } from 'obsidian';
import type {
BacklinkPlugin,
BacklinkView,
ResultDom,
ResultDomResult,
TreeDom
} from 'obsidian-typings';
import {
MarkdownView,
setTooltip
} from 'obsidian';
import { invokeAsyncSafely } from 'obsidian-dev-utils/Async';
import { getPrototypeOf } from 'obsidian-dev-utils/Object';
import { registerPatch } from 'obsidian-dev-utils/obsidian/MonkeyAround';
import { PluginBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginBase';
import {
InternalPluginName,
ViewType
} from 'obsidian-typings/implementations';
import type { PluginTypes } from './PluginTypes.ts';
import { PluginSettingsManager } from './PluginSettingsManager.ts';
import { PluginSettingsTab } from './PluginSettingsTab.ts';
type AddResultFn = TreeDom['addResult'];
export class Plugin extends PluginBase<PluginTypes> {
public override async onSaveSettings(): Promise<void> {
await this.refreshBacklinkPanels();
}
protected override createPluginSettingsTab(): null | PluginSettingsTab {
return new PluginSettingsTab(this);
}
protected override createSettingsManager(): PluginSettingsManager {
return new PluginSettingsManager(this);
}
protected override async onLayoutReady(): Promise<void> {
const backlinksCorePlugin = this.app.internalPlugins.getPluginById(InternalPluginName.Backlink);
if (!backlinksCorePlugin) {
return;
}
// eslint-disable-next-line consistent-this,@typescript-eslint/no-this-alias
const plugin = this;
registerPatch(this, getPrototypeOf(backlinksCorePlugin.instance), {
onUserEnable: (next: () => void) =>
function onUserEnablePatched(this: BacklinkPlugin): void {
next.call(this);
plugin.onBacklinksCorePluginEnable();
}
});
if (backlinksCorePlugin.enabled) {
await this.patchBacklinksPane();
await this.refreshBacklinkPanels();
}
this.register(() => {
invokeAsyncSafely(async () => {
await this.refreshBacklinkPanels();
});
});
}
private addResult(next: AddResultFn, treeDom: TreeDom, file: TFile, result: ResultDomResult, content: string, shouldShowTitle?: boolean): ResultDom {
const resultDom = next.call(treeDom, file, result, content, shouldShowTitle);
const fileNameCaptionEl = resultDom.el.querySelector('.tree-item-inner');
if (fileNameCaptionEl) {
fileNameCaptionEl.empty();
fileNameCaptionEl.appendChild(this.generateBacklinkTitle(file));
}
return resultDom;
}
private generateBacklinkTitle(file: TFile): HTMLDivElement {
const fileNamePart = this.settings.shouldIncludeExtension ? file.name : file.basename;
const parentPathParts = file.path.split('/');
parentPathParts[parentPathParts.length - 1] = '';
if (this.settings.pathDepth > 0) {
const partsToSkipCount = Math.max(0, parentPathParts.length - this.settings.pathDepth);
if (partsToSkipCount > 0) {
parentPathParts.splice(0, partsToSkipCount);
if (this.settings.shouldShowEllipsisForSkippedPathParts) {
parentPathParts.unshift('...');
}
}
}
if (this.settings.shouldReversePathParts) {
parentPathParts.reverse();
}
if (parentPathParts.length === 1) {
parentPathParts.pop();
}
const pathSeparator = this.settings.shouldReversePathParts ? ' ← ' : '/';
const parentStr = parentPathParts.join(pathSeparator);
const container = createDiv();
setTooltip(container, file.path);
container.appendText(parentStr);
container.createEl('span', {
cls: this.settings.shouldHighlightFileName ? 'backlink-full-path file-name' : '',
prepend: this.settings.shouldReversePathParts,
text: fileNamePart
});
return container;
}
private async getBacklinkView(): Promise<BacklinkView | null> {
const backlinksLeaf = this.app.workspace.getLeavesOfType(ViewType.Backlink)[0];
if (!backlinksLeaf) {
return null;
}
await backlinksLeaf.loadIfDeferred();
return backlinksLeaf.view as BacklinkView;
}
private onBacklinksCorePluginEnable(): void {
invokeAsyncSafely(() => this.patchBacklinksPane());
}
private async patchBacklinksPane(): Promise<void> {
const backlinkView = await this.getBacklinkView();
if (!backlinkView) {
return;
}
// eslint-disable-next-line consistent-this,@typescript-eslint/no-this-alias
const plugin = this;
registerPatch(this, getPrototypeOf(backlinkView.backlink.backlinkDom), {
addResult: (next: AddResultFn): AddResultFn =>
function addResultPatched(this: TreeDom, file, result, content, shouldShowTitle?) {
return plugin.addResult(next, this, file, result, content, shouldShowTitle);
}
});
}
private async refreshBacklinkPanels(): Promise<void> {
await this.reloadBacklinksView();
for (const leaf of this.app.workspace.getLeavesOfType(ViewType.Markdown)) {
if (!(leaf.view instanceof MarkdownView)) {
continue;
}
if (!leaf.view.backlinks) {
continue;
}
leaf.view.backlinks.recomputeBacklink(leaf.view.backlinks.file);
}
}
private async reloadBacklinksView(): Promise<void> {
const backlinkView = await this.getBacklinkView();
if (!backlinkView) {
return;
}
if (backlinkView.file) {
backlinkView.backlink.recomputeBacklink(backlinkView.file);
}
}
}

View file

@ -1,11 +0,0 @@
import { PluginSettingsManagerBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginSettingsManagerBase';
import type { PluginTypes } from './PluginTypes.ts';
import { PluginSettings } from './PluginSettings.ts';
export class PluginSettingsManager extends PluginSettingsManagerBase<PluginTypes> {
protected override createDefaultSettings(): PluginSettings {
return new PluginSettings();
}
}

View file

@ -1,13 +0,0 @@
import type { PluginTypesBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginTypesBase';
import type { Plugin } from './Plugin.ts';
import type { PluginSettings } from './PluginSettings.ts';
import type { PluginSettingsManager } from './PluginSettingsManager.ts';
import type { PluginSettingsTab } from './PluginSettingsTab.ts';
export interface PluginTypes extends PluginTypesBase {
plugin: Plugin;
pluginSettings: PluginSettings;
pluginSettingsManager: PluginSettingsManager;
pluginSettingsTab: PluginSettingsTab;
}

View file

@ -0,0 +1,327 @@
import type { BacklinkView } from '@obsidian-typings/obsidian-public-latest';
import type {
App,
TFile,
WorkspaceLeaf
} from 'obsidian';
import type { Mock } from 'vitest';
import { ViewType } from '@obsidian-typings/obsidian-public-latest/implementations';
import { MarkdownView } from 'obsidian';
import { castTo } from 'obsidian-dev-utils/object-utils';
import { strictProxy } from 'obsidian-dev-utils/strict-proxy';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import type { PluginSettingsComponent } from './plugin-settings-component.ts';
import { BacklinkFullPathComponent } from './backlink-full-path-component.ts';
import { ResultDomAddResultPatchComponent } from './patches/result-dom-add-result-patch-component.ts';
import { PluginSettings } from './plugin-settings.ts';
interface ComponentInternals {
getBacklinkView(): Promise<BacklinkView | null>;
patchBacklinksPane(): Promise<void>;
refreshBacklinkPanels(): Promise<void>;
reloadBacklinksView(): Promise<void>;
}
interface CorePlugin {
enabled: boolean;
instance: object;
}
interface PushBacklinkViewParams {
readonly file: null | TFile;
readonly recomputeBacklink: Mock<(backlinkFile: null | TFile) => void>;
}
interface TestContext {
app: App;
backlinkLeaves: WorkspaceLeaf[];
component: BacklinkFullPathComponent;
getPluginById: ReturnType<typeof vi.fn>;
markdownLeaves: WorkspaceLeaf[];
on: ReturnType<typeof vi.fn>;
}
describe('BacklinkFullPathComponent', () => {
let context: TestContext;
beforeEach(() => {
context = createTestContext();
});
afterEach(() => {
vi.useRealTimers();
});
describe('onLayoutReady', () => {
it('should register a saveSettings handler that refreshes panels', async () => {
context.getPluginById.mockReturnValue(undefined);
const refreshSpy = vi.spyOn(internals(context.component), 'refreshBacklinkPanels').mockResolvedValue(undefined);
await triggerLayoutReady();
expect(context.on).toHaveBeenCalledWith('saveSettings', expect.any(Function));
const handler = castTo<() => Promise<void>>(context.on.mock.calls[0]?.[1]);
await handler();
expect(refreshSpy).toHaveBeenCalled();
});
it('should not patch the pane when the backlinks core plugin is not found', async () => {
context.getPluginById.mockReturnValue(undefined);
const patchSpy = vi.spyOn(internals(context.component), 'patchBacklinksPane').mockResolvedValue(undefined);
await triggerLayoutReady();
expect(patchSpy).not.toHaveBeenCalled();
});
it('should add the onUserEnable patch child when the core plugin is found', async () => {
context.getPluginById.mockReturnValue(createCorePlugin(false));
vi.spyOn(internals(context.component), 'patchBacklinksPane').mockResolvedValue(undefined);
const addChildSpy = vi.spyOn(context.component, 'addChild');
await triggerLayoutReady();
expect(addChildSpy).toHaveBeenCalled();
});
it('should patch the pane and refresh panels when the plugin is enabled', async () => {
context.getPluginById.mockReturnValue(createCorePlugin(true));
const patchSpy = vi.spyOn(internals(context.component), 'patchBacklinksPane').mockResolvedValue(undefined);
const refreshSpy = vi.spyOn(internals(context.component), 'refreshBacklinkPanels').mockResolvedValue(undefined);
await triggerLayoutReady();
expect(patchSpy).toHaveBeenCalled();
expect(refreshSpy).toHaveBeenCalled();
});
it('should not patch the pane when the plugin is disabled', async () => {
context.getPluginById.mockReturnValue(createCorePlugin(false));
const patchSpy = vi.spyOn(internals(context.component), 'patchBacklinksPane').mockResolvedValue(undefined);
await triggerLayoutReady();
expect(patchSpy).not.toHaveBeenCalled();
});
it('should register an unload callback that refreshes panels', async () => {
context.getPluginById.mockReturnValue(createCorePlugin(false));
const refreshSpy = vi.spyOn(internals(context.component), 'refreshBacklinkPanels').mockResolvedValue(undefined);
const registerSpy = vi.spyOn(context.component, 'register');
await triggerLayoutReady();
const unloadCallback = registerSpy.mock.calls.at(-1)?.[0];
unloadCallback?.();
expect(refreshSpy).toHaveBeenCalled();
});
});
describe('onBacklinksCorePluginEnable', () => {
it('should patch the backlinks pane', () => {
const patchSpy = vi.spyOn(internals(context.component), 'patchBacklinksPane').mockResolvedValue(undefined);
context.component.onBacklinksCorePluginEnable();
expect(patchSpy).toHaveBeenCalled();
});
});
describe('getBacklinkView', () => {
it('should return null when no backlink leaf exists', async () => {
const view = await internals(context.component).getBacklinkView();
expect(view).toBeNull();
});
it('should load the deferred leaf and return its view', async () => {
const backlinkView = strictProxy<BacklinkView>({});
const loadIfDeferred = vi.fn().mockResolvedValue(undefined);
context.backlinkLeaves.push(castTo<WorkspaceLeaf>({
loadIfDeferred,
view: backlinkView
}));
const view = await internals(context.component).getBacklinkView();
expect(loadIfDeferred).toHaveBeenCalled();
expect(view).toBe(backlinkView);
});
});
describe('patchBacklinksPane', () => {
it('should do nothing when no backlink view exists', async () => {
const addChildSpy = vi.spyOn(context.component, 'addChild');
await internals(context.component).patchBacklinksPane();
expect(addChildSpy).not.toHaveBeenCalled();
});
it('should add a result-dom patch child when a backlink view exists', async () => {
const backlinkDomProto = { addResult: vi.fn() };
const backlinkView = strictProxy<BacklinkView>({
backlink: strictProxy({
backlinkDom: Object.create(backlinkDomProto)
})
});
context.backlinkLeaves.push(castTo<WorkspaceLeaf>({
loadIfDeferred: vi.fn().mockResolvedValue(undefined),
view: backlinkView
}));
const addChildSpy = vi.spyOn(context.component, 'addChild');
await internals(context.component).patchBacklinksPane();
expect(addChildSpy).toHaveBeenCalledWith(expect.any(ResultDomAddResultPatchComponent));
});
});
describe('reloadBacklinksView', () => {
it('should do nothing when no backlink view exists', async () => {
await expect(internals(context.component).reloadBacklinksView()).resolves.toBeUndefined();
});
it('should not recompute when the backlink view has no file', async () => {
const recomputeBacklink = vi.fn<(backlinkFile: null | TFile) => void>();
pushBacklinkView({ file: null, recomputeBacklink });
await internals(context.component).reloadBacklinksView();
expect(recomputeBacklink).not.toHaveBeenCalled();
});
it('should recompute when the backlink view has a file', async () => {
const file = createMockFile('note.md');
const recomputeBacklink = vi.fn<(backlinkFile: null | TFile) => void>();
pushBacklinkView({ file, recomputeBacklink });
await internals(context.component).reloadBacklinksView();
expect(recomputeBacklink).toHaveBeenCalledWith(file);
});
});
describe('refreshBacklinkPanels', () => {
beforeEach(() => {
vi.spyOn(internals(context.component), 'reloadBacklinksView').mockResolvedValue(undefined);
});
it('should skip leaves that are not MarkdownView instances', async () => {
context.markdownLeaves.push(castTo<WorkspaceLeaf>({ view: {} }));
await expect(internals(context.component).refreshBacklinkPanels()).resolves.toBeUndefined();
});
it('should skip MarkdownView leaves without backlinks', async () => {
context.markdownLeaves.push(createMarkdownLeaf(undefined));
await expect(internals(context.component).refreshBacklinkPanels()).resolves.toBeUndefined();
});
it('should recompute backlinks for MarkdownView leaves with backlinks', async () => {
const file = createMockFile('note.md');
const recomputeBacklink = vi.fn();
context.markdownLeaves.push(createMarkdownLeaf({ file, recomputeBacklink }));
await internals(context.component).refreshBacklinkPanels();
expect(recomputeBacklink).toHaveBeenCalledWith(file);
});
});
function pushBacklinkView(backlink: PushBacklinkViewParams): void {
const backlinkView = strictProxy<BacklinkView>({
backlink: strictProxy({ recomputeBacklink: backlink.recomputeBacklink }),
file: backlink.file
});
context.backlinkLeaves.push(castTo<WorkspaceLeaf>({
loadIfDeferred: vi.fn().mockResolvedValue(undefined),
view: backlinkView
}));
}
async function triggerLayoutReady(): Promise<void> {
vi.useFakeTimers();
context.component.load();
await vi.runAllTimersAsync();
vi.useRealTimers();
}
});
function createCorePlugin(enabled: boolean): CorePlugin {
return {
enabled,
instance: Object.create({ onUserEnable: vi.fn() })
};
}
function createMarkdownLeaf(backlinks: unknown): WorkspaceLeaf {
return castTo<WorkspaceLeaf>({
view: Object.assign(Object.create(MarkdownView.prototype), { backlinks })
});
}
function createMockFile(path: string): TFile {
return strictProxy<TFile>({ path });
}
function createTestContext(): TestContext {
const backlinkLeaves: WorkspaceLeaf[] = [];
const markdownLeaves: WorkspaceLeaf[] = [];
const getPluginById = vi.fn();
const on = vi.fn();
const app = strictProxy<App>({
internalPlugins: {
getPluginById
},
workspace: {
getLeavesOfType: vi.fn().mockImplementation((type: string) => {
if (type === ViewType.Backlink) {
return backlinkLeaves;
}
if (type === ViewType.Markdown) {
return markdownLeaves;
}
return [];
}),
onLayoutReady: vi.fn().mockImplementation((callback: () => void) => {
callback();
})
}
});
const pluginSettingsComponent = strictProxy<PluginSettingsComponent>({
on,
settings: new PluginSettings()
});
const component = new BacklinkFullPathComponent({
app,
pluginSettingsComponent
});
return {
app,
backlinkLeaves,
component,
getPluginById,
markdownLeaves,
on
};
}
function internals(component: BacklinkFullPathComponent): ComponentInternals {
return castTo<ComponentInternals>(component);
}

View file

@ -0,0 +1,112 @@
import type { BacklinkView } from '@obsidian-typings/obsidian-public-latest';
import type { App } from 'obsidian';
import {
InternalPluginName,
ViewType
} from '@obsidian-typings/obsidian-public-latest/implementations';
import { MarkdownView } from 'obsidian';
import { invokeAsyncSafely } from 'obsidian-dev-utils/async';
import { LayoutReadyComponent } from 'obsidian-dev-utils/obsidian/components/layout-ready-component';
import { BacklinkPluginInstanceOnUserEnablePatchComponent } from './patches/backlink-plugin-instance-on-user-enable-patch-component.ts';
import { ResultDomAddResultPatchComponent } from './patches/result-dom-add-result-patch-component.ts';
import { PluginSettingsComponent } from './plugin-settings-component.ts';
interface BacklinkFullPathComponentConstructorParams {
readonly app: App;
readonly pluginSettingsComponent: PluginSettingsComponent;
}
export class BacklinkFullPathComponent extends LayoutReadyComponent {
private readonly pluginSettingsComponent: PluginSettingsComponent;
public constructor(params: BacklinkFullPathComponentConstructorParams) {
super(params.app);
this.pluginSettingsComponent = params.pluginSettingsComponent;
}
public onBacklinksCorePluginEnable(): void {
invokeAsyncSafely(() => this.patchBacklinksPane());
}
protected override async onLayoutReady(): Promise<void> {
this.pluginSettingsComponent.on('saveSettings', async () => {
await this.refreshBacklinkPanels();
});
const backlinksCorePlugin = this.app.internalPlugins.getPluginById(InternalPluginName.Backlink);
if (!backlinksCorePlugin) {
return;
}
this.addChild(
new BacklinkPluginInstanceOnUserEnablePatchComponent({
backlinkFullPathComponent: this,
backlinkPluginInstance: backlinksCorePlugin.instance
})
);
if (backlinksCorePlugin.enabled) {
await this.patchBacklinksPane();
await this.refreshBacklinkPanels();
}
this.register(() => {
invokeAsyncSafely(async () => {
await this.refreshBacklinkPanels();
});
});
}
private async getBacklinkView(): Promise<BacklinkView | null> {
const backlinksLeaf = this.app.workspace.getLeavesOfType(ViewType.Backlink)[0];
if (!backlinksLeaf) {
return null;
}
await backlinksLeaf.loadIfDeferred();
return backlinksLeaf.view as BacklinkView;
}
private async patchBacklinksPane(): Promise<void> {
const backlinkView = await this.getBacklinkView();
if (!backlinkView) {
return;
}
this.addChild(
new ResultDomAddResultPatchComponent({
pluginSettingsComponent: this.pluginSettingsComponent,
resultDom: backlinkView.backlink.backlinkDom
})
);
}
private async refreshBacklinkPanels(): Promise<void> {
await this.reloadBacklinksView();
for (const leaf of this.app.workspace.getLeavesOfType(ViewType.Markdown)) {
if (!(leaf.view instanceof MarkdownView)) {
continue;
}
if (!leaf.view.backlinks) {
continue;
}
leaf.view.backlinks.recomputeBacklink(leaf.view.backlinks.file);
}
}
private async reloadBacklinksView(): Promise<void> {
const backlinkView = await this.getBacklinkView();
if (!backlinkView) {
return;
}
if (backlinkView.file) {
backlinkView.backlink.recomputeBacklink(backlinkView.file);
}
}
}

View file

@ -0,0 +1,21 @@
import process from 'node:process';
import { registerDemoVaultCoverageSuite } from 'obsidian-dev-utils/script-utils/demo-vault-coverage';
import { getRootFolder } from 'obsidian-dev-utils/script-utils/root';
// Keeps the in-repo `demo-vault/` in sync with the plugin's public surface WITHOUT
// Launching Obsidian: it reflects the real config from source and asserts every
// Setting is documented in a note, and that the guard note/member still exist
// (rename drift). Backlink Full Path enhances the core Backlinks pane and exposes
// No public API interface, so only the PluginSettings config class is reflected;
// The plugin's runtime behavior is covered by the other integration tests.
registerDemoVaultCoverageSuite({
configInterfaces: [{ interfaceName: 'PluginSettings', sourcePath: 'src/plugin-settings.ts' }],
interfaces: [],
nonTrivialGuard: {
expectDemoNote: '02 Settings.md',
expectMember: 'pathDepth',
interfaceName: 'PluginSettings',
sourcePath: 'src/plugin-settings.ts'
},
rootFolder: getRootFolder() ?? process.cwd()
});

15
src/main.test.ts Normal file
View file

@ -0,0 +1,15 @@
import {
describe,
expect,
it
} from 'vitest';
// eslint-disable-next-line import-x/no-rename-default -- Renamed to avoid collision with the named `Plugin` import.
import mainExport from './main.ts';
import { Plugin } from './plugin.ts';
describe('main', () => {
it('should export Plugin as default export', () => {
expect(mainExport).toBe(Plugin);
});
});

View file

@ -1,5 +1,4 @@
import './styles/main.scss';
import { Plugin } from './Plugin.ts';
import { Plugin } from './plugin.ts';
// eslint-disable-next-line import-x/no-default-export
export default Plugin;

View file

@ -0,0 +1,75 @@
import type { BacklinkPluginInstance } from '@obsidian-typings/obsidian-public-latest';
import type { Mock } from 'vitest';
import { castTo } from 'obsidian-dev-utils/object-utils';
import { strictProxy } from 'obsidian-dev-utils/strict-proxy';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import type { BacklinkFullPathComponent } from '../backlink-full-path-component.ts';
import { BacklinkPluginInstanceOnUserEnablePatchComponent } from './backlink-plugin-instance-on-user-enable-patch-component.ts';
interface BacklinkPluginInstanceProto {
onUserEnable(): void;
}
describe('BacklinkPluginInstanceOnUserEnablePatchComponent', () => {
let onBacklinksCorePluginEnable: Mock<() => void>;
let backlinkFullPathComponent: BacklinkFullPathComponent;
let originalOnUserEnable: Mock<() => void>;
let instanceProto: BacklinkPluginInstanceProto;
let backlinkPluginInstance: BacklinkPluginInstance;
beforeEach(() => {
onBacklinksCorePluginEnable = vi.fn<() => void>();
backlinkFullPathComponent = strictProxy<BacklinkFullPathComponent>({
onBacklinksCorePluginEnable
});
originalOnUserEnable = vi.fn<() => void>();
instanceProto = { onUserEnable: originalOnUserEnable };
backlinkPluginInstance = castTo<BacklinkPluginInstance>(Object.create(instanceProto));
});
function createComponent(): BacklinkPluginInstanceOnUserEnablePatchComponent {
return new BacklinkPluginInstanceOnUserEnablePatchComponent({
backlinkFullPathComponent,
backlinkPluginInstance
});
}
it('should register a single method patch on load', () => {
const component = createComponent();
const registerMethodPatchSpy = vi.spyOn(component, 'registerMethodPatch');
component.load();
expect(registerMethodPatchSpy).toHaveBeenCalledTimes(1);
});
it('should call the original onUserEnable and notify the component when the patched method runs', () => {
const component = createComponent();
component.load();
instanceProto.onUserEnable.call(backlinkPluginInstance);
expect(originalOnUserEnable).toHaveBeenCalled();
expect(onBacklinksCorePluginEnable).toHaveBeenCalled();
});
it('should remove the patch on unload', () => {
const component = createComponent();
component.load();
component.unload();
instanceProto.onUserEnable.call(backlinkPluginInstance);
expect(originalOnUserEnable).toHaveBeenCalled();
expect(onBacklinksCorePluginEnable).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,35 @@
import type { BacklinkPluginInstance } from '@obsidian-typings/obsidian-public-latest';
import { getPrototypeOf } from 'obsidian-dev-utils/object-utils';
import { MonkeyAroundComponent } from 'obsidian-dev-utils/obsidian/components/monkey-around-component';
import type { BacklinkFullPathComponent } from '../backlink-full-path-component.ts';
interface BacklinkPluginInstanceOnUserEnablePatchComponentConstructorParams {
readonly backlinkFullPathComponent: BacklinkFullPathComponent;
readonly backlinkPluginInstance: BacklinkPluginInstance;
}
export class BacklinkPluginInstanceOnUserEnablePatchComponent extends MonkeyAroundComponent {
private readonly backlinkFullPathComponent: BacklinkFullPathComponent;
private readonly backlinkPluginInstance: BacklinkPluginInstance;
public constructor(params: BacklinkPluginInstanceOnUserEnablePatchComponentConstructorParams) {
super();
this.backlinkPluginInstance = params.backlinkPluginInstance;
this.backlinkFullPathComponent = params.backlinkFullPathComponent;
}
public override onload(): void {
this.registerMethodPatch({
methodName: 'onUserEnable',
obj: getPrototypeOf(this.backlinkPluginInstance),
patchHandler: ({
fallback
}) => {
fallback();
this.backlinkFullPathComponent.onBacklinksCorePluginEnable();
}
});
}
}

View file

@ -0,0 +1,231 @@
import type {
ResultDom,
ResultDomItem,
ResultDomResult
} from '@obsidian-typings/obsidian-public-latest';
import type { TFile } from 'obsidian';
import { castTo } from 'obsidian-dev-utils/object-utils';
import { strictProxy } from 'obsidian-dev-utils/strict-proxy';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import type { PluginSettingsComponent } from '../plugin-settings-component.ts';
import { PluginSettings } from '../plugin-settings.ts';
import { ResultDomAddResultPatchComponent } from './result-dom-add-result-patch-component.ts';
interface ResultDomProto {
addResult(file: TFile, result: ResultDomResult, content: string, shouldShowTitle: boolean): ResultDomItem;
}
describe('ResultDomAddResultPatchComponent', () => {
let settings: PluginSettings;
let pluginSettingsComponent: PluginSettingsComponent;
let resultDomProto: ResultDomProto;
let resultDom: ResultDom;
beforeEach(() => {
settings = new PluginSettings();
pluginSettingsComponent = strictProxy<PluginSettingsComponent>({
settings
});
resultDomProto = {
addResult: vi.fn().mockImplementation(() => createOriginalItem(true))
};
resultDom = castTo<ResultDom>(Object.create(resultDomProto));
});
it('should register a single method patch on load', () => {
const component = createComponent();
const registerMethodPatchSpy = vi.spyOn(component, 'registerMethodPatch');
component.load();
expect(registerMethodPatchSpy).toHaveBeenCalledTimes(1);
});
it('should leave the result untouched when there is no tree-item-inner', () => {
resultDomProto.addResult = vi.fn().mockImplementation(() => createOriginalItem(false));
const component = createComponent();
component.load();
const item = resultDomProto.addResult(createMockFile('note.md'), strictProxy<ResultDomResult>({}), 'content', true);
expect(item.el.querySelector('.tree-item-inner')).toBeNull();
});
it('should remove the patch on unload', () => {
const component = createComponent();
component.load();
component.unload();
const item = resultDomProto.addResult(createMockFile('note.md'), strictProxy<ResultDomResult>({}), 'content', true);
expect(item.el.querySelector('.tree-item-inner')?.textContent).toBe('Original');
});
describe('generated backlink title', () => {
it('should replace the tree-item-inner content with the generated title', () => {
const container = generateContainer('folder/note.md');
expect(container.classList.contains('backlink-full-path')).toBe(true);
});
it('should include the extension when shouldIncludeExtension is true', () => {
settings.shouldIncludeExtension = true;
const container = generateContainer('folder/note.md');
expect(getPart(container, 'file-name')?.textContent).toBe('note.md');
});
it('should exclude the extension when shouldIncludeExtension is false', () => {
settings.shouldIncludeExtension = false;
const container = generateContainer('folder/note.md');
expect(getPart(container, 'file-name')?.textContent).toBe('note');
});
it('should show no parent path for a file at the root', () => {
const container = generateContainer('note.md');
expect(getPart(container, 'parent-path')).toBeNull();
});
it('should show the parent path with a trailing separator', () => {
settings.shouldReversePathParts = false;
settings.shouldDisplayParentPathOnSeparateLine = false;
const container = generateContainer('folder/note.md');
expect(getPart(container, 'parent-path')?.textContent).toBe('folder/');
});
it('should strip a root path prefix', () => {
settings.rootPaths = ['folder'];
const container = generateContainer('folder/subfolder/note.md');
expect(getPart(container, 'parent-path')?.textContent).toBe('subfolder/');
});
it('should strip a nested root path prefix', () => {
settings.rootPaths = ['folder/subfolder'];
const container = generateContainer('folder/subfolder/deep/note.md');
expect(getPart(container, 'parent-path')?.textContent).toBe('deep/');
});
it('should limit depth with the pathDepth setting', () => {
settings.pathDepth = 2;
settings.shouldShowEllipsisForSkippedPathParts = false;
const container = generateContainer('a/b/c/note.md');
expect(getPart(container, 'parent-path')?.textContent).toBe('c/');
});
it('should add an ellipsis for skipped parts when enabled', () => {
settings.pathDepth = 2;
settings.shouldShowEllipsisForSkippedPathParts = true;
const container = generateContainer('a/b/c/note.md');
expect(getPart(container, 'parent-path')?.textContent).toBe('.../c/');
});
it('should not add an ellipsis when pathDepth does not truncate', () => {
settings.pathDepth = 5;
settings.shouldShowEllipsisForSkippedPathParts = true;
const container = generateContainer('a/b/note.md');
expect(getPart(container, 'parent-path')?.textContent).toBe('a/b/');
});
it('should reverse the path parts when shouldReversePathParts is true', () => {
settings.shouldReversePathParts = true;
settings.shouldDisplayParentPathOnSeparateLine = false;
const container = generateContainer('a/b/note.md');
expect(getPart(container, 'parent-path')?.textContent).toBe(' ← b ← a');
});
it('should display the parent path on a separate line without a separator', () => {
settings.shouldDisplayParentPathOnSeparateLine = true;
settings.shouldReversePathParts = false;
const container = generateContainer('folder/note.md');
expect(getPart(container, 'parent-path')?.textContent).toBe('folder');
});
it('should set dataset attributes for highlighting', () => {
settings.shouldHighlightFileName = true;
settings.shouldDisplayParentPathOnSeparateLine = false;
const container = generateContainer('note.md');
expect(container.dataset['shouldHighlightFileName']).toBe('true');
expect(container.dataset['shouldDisplayParentPathOnSeparateLine']).toBe('false');
});
it('should include a full-path span with the file path', () => {
const container = generateContainer('folder/note.md');
expect(container.querySelector('.full-path')?.textContent).toBe('folder/note.md');
});
it('should prepend the parent path when not reversed and not on a separate line', () => {
settings.shouldReversePathParts = false;
settings.shouldDisplayParentPathOnSeparateLine = false;
const container = generateContainer('folder/note.md');
expect(container.shadowRoot?.firstElementChild?.getAttribute('part')).toBe('parent-path');
});
it('should append the parent path when reversed and not on a separate line', () => {
settings.shouldReversePathParts = true;
settings.shouldDisplayParentPathOnSeparateLine = false;
const container = generateContainer('folder/note.md');
expect(container.shadowRoot?.lastElementChild?.getAttribute('part')).toBe('parent-path');
});
it('should append the parent path when displaying on a separate line', () => {
settings.shouldReversePathParts = false;
settings.shouldDisplayParentPathOnSeparateLine = true;
const container = generateContainer('folder/note.md');
expect(container.shadowRoot?.lastElementChild?.getAttribute('part')).toBe('parent-path');
});
});
function createComponent(): ResultDomAddResultPatchComponent {
return new ResultDomAddResultPatchComponent({
pluginSettingsComponent,
resultDom
});
}
function generateContainer(filePath: string): HTMLDivElement {
const component = createComponent();
component.load();
const item = resultDomProto.addResult(createMockFile(filePath), strictProxy<ResultDomResult>({}), 'content', true);
const inner = item.el.querySelector('.tree-item-inner');
const container = inner?.firstElementChild;
if (!(container instanceof HTMLDivElement)) {
throw new Error('Expected a generated backlink-full-path container');
}
return container;
}
});
function createMockFile(path: string): TFile {
const parts = path.split('/');
const name = parts.at(-1) ?? '';
const dotIndex = name.lastIndexOf('.');
const basename = dotIndex >= 0 ? name.slice(0, dotIndex) : name;
const extension = dotIndex >= 0 ? name.slice(dotIndex + 1) : '';
return strictProxy<TFile>({
basename,
extension,
name,
path
});
}
function createOriginalItem(hasTreeItemInner: boolean): ResultDomItem {
const el = createDiv();
if (hasTreeItemInner) {
const inner = el.createDiv({ cls: 'tree-item-inner' });
inner.textContent = 'Original';
}
return strictProxy<ResultDomItem>({ el });
}
function getPart(container: HTMLDivElement, part: string): Element | null {
return container.shadowRoot?.querySelector(`[part="${part}"]`) ?? null;
}

View file

@ -0,0 +1,110 @@
import type { ResultDom } from '@obsidian-typings/obsidian-public-latest';
import {
setTooltip,
TFile
} from 'obsidian';
import { getPrototypeOf } from 'obsidian-dev-utils/object-utils';
import { MonkeyAroundComponent } from 'obsidian-dev-utils/obsidian/components/monkey-around-component';
import type { PluginSettingsComponent } from '../plugin-settings-component.ts';
interface ResultDomAddResultPatchComponentConstructorParams {
readonly pluginSettingsComponent: PluginSettingsComponent;
readonly resultDom: ResultDom;
}
export class ResultDomAddResultPatchComponent extends MonkeyAroundComponent {
private readonly pluginSettingsComponent: PluginSettingsComponent;
private readonly resultDom: ResultDom;
public constructor(params: ResultDomAddResultPatchComponentConstructorParams) {
super();
this.resultDom = params.resultDom;
this.pluginSettingsComponent = params.pluginSettingsComponent;
}
public override onload(): void {
this.registerMethodPatch({
methodName: 'addResult',
obj: getPrototypeOf(this.resultDom),
patchHandler: ({
fallback,
originalArgs: [file]
}) => {
const resultDomItem = fallback();
const fileNameCaptionEl = resultDomItem.el.querySelector('.tree-item-inner');
if (fileNameCaptionEl) {
fileNameCaptionEl.empty();
fileNameCaptionEl.appendChild(this.generateBacklinkTitle(file));
}
return resultDomItem;
}
});
}
private generateBacklinkTitle(file: TFile): HTMLDivElement {
const fileNamePart = this.pluginSettingsComponent.settings.shouldIncludeExtension ? file.name : file.basename;
let parentPathParts = file.path.split('/').slice(0, -1);
for (let length = parentPathParts.length; length >= 1; length--) {
const rootPath = parentPathParts.slice(0, length).join('/');
if (this.pluginSettingsComponent.settings.rootPaths.includes(rootPath)) {
parentPathParts = parentPathParts.slice(length);
break;
}
}
if (this.pluginSettingsComponent.settings.pathDepth > 0) {
const partsToSkipCount = Math.max(0, parentPathParts.length - this.pluginSettingsComponent.settings.pathDepth + 1);
if (partsToSkipCount > 0) {
parentPathParts.splice(0, partsToSkipCount);
if (this.pluginSettingsComponent.settings.shouldShowEllipsisForSkippedPathParts) {
parentPathParts.unshift('...');
}
}
}
if (this.pluginSettingsComponent.settings.shouldReversePathParts) {
parentPathParts.reverse();
}
const pathSeparator = this.pluginSettingsComponent.settings.shouldReversePathParts ? ' \u2190 ' : '/';
const parentStr = parentPathParts.join(pathSeparator);
const container = createDiv({
cls: ['backlink-full-path', 'backlink-control']
});
container.dataset['shouldHighlightFileName'] = this.pluginSettingsComponent.settings.shouldHighlightFileName.toString();
container.dataset['shouldDisplayParentPathOnSeparateLine'] = this.pluginSettingsComponent.settings.shouldDisplayParentPathOnSeparateLine.toString();
container.createSpan({
cls: 'full-path',
text: file.path
});
const shadowRoot = container.attachShadow({ mode: 'open' });
setTooltip(container, file.path);
shadowRoot.createSpan({
attr: {
part: 'file-name'
},
text: fileNamePart
});
if (parentStr) {
let text = parentStr;
if (!this.pluginSettingsComponent.settings.shouldDisplayParentPathOnSeparateLine) {
text = this.pluginSettingsComponent.settings.shouldReversePathParts ? pathSeparator + text : text + pathSeparator;
}
shadowRoot.createSpan({
attr: {
part: 'parent-path'
},
prepend: !this.pluginSettingsComponent.settings.shouldReversePathParts && !this.pluginSettingsComponent.settings.shouldDisplayParentPathOnSeparateLine,
text
});
}
return container;
}
}

View file

@ -0,0 +1,26 @@
import type { DataHandler } from 'obsidian-dev-utils/obsidian/data-handler';
import type { PluginEventSource } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source';
import { strictProxy } from 'obsidian-dev-utils/strict-proxy';
import {
describe,
expect,
it
} from 'vitest';
import { PluginSettingsComponent } from './plugin-settings-component.ts';
import { PluginSettings } from './plugin-settings.ts';
describe('PluginSettingsComponent', () => {
it('should create with PluginSettings class', () => {
const dataHandler = strictProxy<DataHandler>({});
const pluginEventSource = strictProxy<PluginEventSource>({});
const component = new PluginSettingsComponent({
dataHandler,
pluginEventSource
});
expect(component.settings).toBeInstanceOf(PluginSettings);
});
});

View file

@ -0,0 +1,20 @@
import type { DataHandler } from 'obsidian-dev-utils/obsidian/data-handler';
import type { PluginEventSource } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source';
import { PluginSettingsComponentBase } from 'obsidian-dev-utils/obsidian/components/plugin-settings-component';
import { PluginSettings } from './plugin-settings.ts';
interface PluginSettingsComponentConstructorParams {
readonly dataHandler: DataHandler;
readonly pluginEventSource: PluginEventSource;
}
export class PluginSettingsComponent extends PluginSettingsComponentBase<PluginSettings> {
public constructor(params: PluginSettingsComponentConstructorParams) {
super({
...params,
pluginSettingsClass: PluginSettings
});
}
}

View file

@ -0,0 +1,95 @@
import type { DataHandler } from 'obsidian-dev-utils/obsidian/data-handler';
import type { PluginEventMap } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source';
import { AsyncEvents } from 'obsidian-dev-utils/async-events';
import { noopAsync } from 'obsidian-dev-utils/function';
import { strictProxy } from 'obsidian-dev-utils/strict-proxy';
import {
App,
DropdownComponent as DropdownComponentClass,
TextComponent as TextComponentClass,
ToggleComponent as ToggleComponentClass
} from 'obsidian-test-mocks/obsidian';
import {
beforeAll,
describe,
expect,
it
} from 'vitest';
import type { Plugin } from './plugin.ts';
import { PluginSettingsComponent } from './plugin-settings-component.ts';
import { PluginSettingsTab } from './plugin-settings-tab.ts';
class MockDataHandler implements DataHandler {
public async loadData(): Promise<unknown> {
await noopAsync();
return {};
}
public async saveData(): Promise<void> {
await noopAsync();
}
}
async function createTab(): Promise<PluginSettingsTab> {
const app = App.createConfigured__();
const pluginSettingsComponent = new PluginSettingsComponent({
dataHandler: new MockDataHandler(),
pluginEventSource: new AsyncEvents<PluginEventMap>()
});
// The component must be loaded before its settings can be edited; obsidian-dev-utils.
// Makes setProperty/editAndSave throw when the component is not loaded.
await pluginSettingsComponent.loadWithPromises();
const plugin = strictProxy<Plugin>({ app: app.asOriginalType__() });
const tab = new PluginSettingsTab({
plugin,
pluginSettingsComponent
});
tab.displayLegacy();
return tab;
}
function getSettingNames(tab: PluginSettingsTab): string[] {
const names: string[] = [];
for (const settingEl of Array.from(tab.containerEl.children)) {
const infoEl = settingEl.children[1];
const nameEl = infoEl?.children[0];
if (nameEl?.textContent) {
names.push(nameEl.textContent);
}
}
return names;
}
beforeAll(() => {
// Obsidian-dev-utils' bind() probes setPlaceholderValue to detect text-based components.
for (const proto of [ToggleComponentClass.prototype, DropdownComponentClass.prototype, TextComponentClass.prototype]) {
if (!('setPlaceholderValue' in proto)) {
Object.defineProperty(proto, 'setPlaceholderValue', { value: undefined });
}
}
});
describe('PluginSettingsTab', () => {
it('should be constructable', async () => {
const tab = await createTab();
expect(tab).toBeInstanceOf(PluginSettingsTab);
});
it('should render all settings bound to the correct properties', async () => {
const tab = await createTab();
const names = getSettingNames(tab);
expect(names).toStrictEqual([
'Include extension',
'Path depth',
'Show ellipsis for skipped path parts',
'Highlight file name',
'Reverse path parts',
'Display parent path on separate line',
'Root paths'
]);
});
});

View file

@ -1,20 +1,19 @@
import { Setting } from 'obsidian';
import { appendCodeBlock } from 'obsidian-dev-utils/HTMLElement';
import { PluginSettingsTabBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginSettingsTabBase';
import { SettingEx } from 'obsidian-dev-utils/obsidian/SettingEx';
import { appendCodeBlock } from 'obsidian-dev-utils/obsidian/html-element';
import { PluginSettingsTabBase } from 'obsidian-dev-utils/obsidian/plugin/plugin-settings-tab';
import { SettingEx } from 'obsidian-dev-utils/obsidian/setting-ex';
import type { PluginTypes } from './PluginTypes.ts';
import type { PluginSettings } from './plugin-settings.ts';
export class PluginSettingsTab extends PluginSettingsTabBase<PluginTypes> {
public override display(): void {
super.display();
this.containerEl.empty();
export class PluginSettingsTab extends PluginSettingsTabBase<PluginSettings> {
public override displayLegacy(): void {
super.displayLegacy();
new Setting(this.containerEl)
.setName('Include extension')
.setDesc('Whether to include file extension in backlinks.')
.addToggle((toggle) => {
this.bind(toggle, 'shouldIncludeExtension');
this.bind({ propertyName: 'shouldIncludeExtension', valueComponent: toggle });
});
new SettingEx(this.containerEl)
@ -43,7 +42,7 @@ export class PluginSettingsTab extends PluginSettingsTabBase<PluginTypes> {
f.appendText(' - for unlimited depth.');
}))
.addNumber((numberComponent) => {
this.bind(numberComponent, 'pathDepth')
this.bind({ propertyName: 'pathDepth', valueComponent: numberComponent })
.setMin(0);
});
@ -61,14 +60,14 @@ export class PluginSettingsTab extends PluginSettingsTabBase<PluginTypes> {
f.appendText('.');
}))
.addToggle((toggle) => {
this.bind(toggle, 'shouldShowEllipsisForSkippedPathParts');
this.bind({ propertyName: 'shouldShowEllipsisForSkippedPathParts', valueComponent: toggle });
});
new Setting(this.containerEl)
.setName('Highlight file name')
.setDesc('Whether to highlight the file name.')
.addToggle((toggle) => {
this.bind(toggle, 'shouldHighlightFileName');
this.bind({ propertyName: 'shouldHighlightFileName', valueComponent: toggle });
});
new Setting(this.containerEl)
@ -84,7 +83,21 @@ export class PluginSettingsTab extends PluginSettingsTabBase<PluginTypes> {
f.appendText('.');
}))
.addToggle((toggle) => {
this.bind(toggle, 'shouldReversePathParts');
this.bind({ propertyName: 'shouldReversePathParts', valueComponent: toggle });
});
new Setting(this.containerEl)
.setName('Display parent path on separate line')
.setDesc('Whether to display the parent path on a separate line.')
.addToggle((toggle) => {
this.bind({ propertyName: 'shouldDisplayParentPathOnSeparateLine', valueComponent: toggle });
});
new SettingEx(this.containerEl)
.setName('Root paths')
.setDesc('The paths to be treated as root paths.')
.addMultipleText((multipleText) => {
this.bind({ propertyName: 'rootPaths', valueComponent: multipleText });
});
}
}

View file

@ -0,0 +1,44 @@
import {
describe,
expect,
it
} from 'vitest';
import { PluginSettings } from './plugin-settings.ts';
describe('PluginSettings', () => {
it('should have correct default pathDepth', () => {
const settings = new PluginSettings();
expect(settings.pathDepth).toBe(0);
});
it('should have correct default rootPaths', () => {
const settings = new PluginSettings();
expect(settings.rootPaths).toEqual([]);
});
it('should have correct default shouldDisplayParentPathOnSeparateLine', () => {
const settings = new PluginSettings();
expect(settings.shouldDisplayParentPathOnSeparateLine).toBe(false);
});
it('should have correct default shouldHighlightFileName', () => {
const settings = new PluginSettings();
expect(settings.shouldHighlightFileName).toBe(true);
});
it('should have correct default shouldIncludeExtension', () => {
const settings = new PluginSettings();
expect(settings.shouldIncludeExtension).toBe(true);
});
it('should have correct default shouldReversePathParts', () => {
const settings = new PluginSettings();
expect(settings.shouldReversePathParts).toBe(false);
});
it('should have correct default shouldShowEllipsisForSkippedPathParts', () => {
const settings = new PluginSettings();
expect(settings.shouldShowEllipsisForSkippedPathParts).toBe(true);
});
});

View file

@ -1,7 +1,15 @@
export class PluginSettings {
public pathDepth = 0;
public rootPaths: string[] = [];
public shouldDisplayParentPathOnSeparateLine = false;
public shouldHighlightFileName = true;
public shouldIncludeExtension = true;
public shouldReversePathParts = false;
public shouldShowEllipsisForSkippedPathParts = true;
}

View file

@ -0,0 +1,13 @@
import { getTempVault } from 'obsidian-integration-testing/vitest-global-setup-plugin';
import {
describe,
expect,
it
} from 'vitest';
describe('Smoke test', () => {
it('should load plugin on Android', () => {
const vault = getTempVault();
expect(vault.path).toBeTruthy();
});
});

View file

@ -0,0 +1,13 @@
import { getTempVault } from 'obsidian-integration-testing/vitest-global-setup-plugin';
import {
describe,
expect,
it
} from 'vitest';
describe('Smoke test', () => {
it('should load plugin on Desktop', () => {
const vault = getTempVault();
expect(vault.path).toBeTruthy();
});
});

131
src/plugin.test.ts Normal file
View file

@ -0,0 +1,131 @@
/* eslint-disable @typescript-eslint/no-extraneous-class -- Test mocks of the plugin's own sibling modules need constructor-only classes. */
import type {
App as AppOriginal,
PluginManifest
} from 'obsidian';
import { Component } from 'obsidian';
import { castTo } from 'obsidian-dev-utils/object-utils';
import { App } from 'obsidian-test-mocks/obsidian';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
interface AppGlobal {
app: AppOriginal;
}
interface SettingTabsHolder {
settingTabs__: unknown[];
}
const STRICT_PROXY_TARGET_SYMBOL = Symbol.for('strictProxyTarget');
// --- Mocks for the plugin's OWN sibling modules (allowed: not obsidian-dev-utils / obsidian-test-mocks) ---
const hoisted = vi.hoisted(() => ({
backlinkFullPathComponentConstructor: vi.fn(),
pluginSettingsComponentConstructor: vi.fn(),
pluginSettingsTabConstructor: vi.fn()
}));
vi.mock('./plugin-settings-component.ts', () => ({
// Extends the real obsidian-test-mocks Component so the real addChild lifecycle can load it.
PluginSettingsComponent: class extends Component {
public constructor(params: unknown) {
super();
hoisted.pluginSettingsComponentConstructor(params);
}
}
}));
vi.mock('./plugin-settings-tab.ts', () => ({
PluginSettingsTab: class {
public constructor(params: unknown) {
hoisted.pluginSettingsTabConstructor(params);
}
}
}));
vi.mock('./backlink-full-path-component.ts', () => ({
// Extends the real obsidian-test-mocks Component so the real addChild lifecycle can load it.
BacklinkFullPathComponent: class extends Component {
public constructor(params: unknown) {
super();
hoisted.backlinkFullPathComponentConstructor(params);
}
}
}));
// eslint-disable-next-line import-x/first, import-x/imports-first -- vi.mock must precede imports.
import { Plugin } from './plugin.ts';
const manifest = castTo<PluginManifest>({
author: 'test',
description: 'test',
id: 'backlink-full-path',
minAppVersion: '1.0.0',
name: 'Backlink Full Path',
version: '1.0.0'
});
let app: AppOriginal;
async function createLoadedPlugin(): Promise<Plugin> {
const plugin = new Plugin(app, manifest);
// PluginBase.onload is async, and the synchronous mock Component.load() would not await it, so the real async load path is driven directly (as the obsidian-dev-utils reference test does).
await plugin.onload();
return plugin;
}
function seedOnRawTarget(strictProxiedObject: object, key: string, value: unknown): void {
const proxyWithTarget = castTo<Partial<Record<symbol, object>>>(strictProxiedObject);
const rawTarget = proxyWithTarget[STRICT_PROXY_TARGET_SYMBOL] ?? strictProxiedObject;
castTo<Record<string, unknown>>(rawTarget)[key] = value;
}
describe('Plugin', () => {
beforeEach(() => {
vi.clearAllMocks();
const appMock = App.createConfigured__();
appMock.workspace.onLayoutReady = vi.fn((cb: () => void) => {
cb();
});
app = appMock.asOriginalType__();
// Seed the obsidianDevUtilsState holder on the raw target behind the strict-proxy App so the real getObsidianDevUtilsState can read/write it (the proxy throws on first access to an unassigned property).
seedOnRawTarget(app, 'obsidianDevUtilsState', {});
// Expose the app as the global instance so dev-utils helpers that resolve shared state without an explicit app argument read/write the same seeded holder.
castTo<AppGlobal>(window).app = app;
});
it('should load the plugin without throwing', async () => {
const plugin = await createLoadedPlugin();
expect(plugin).toBeInstanceOf(Plugin);
});
it('should wire up all components in onloadImpl', async () => {
await createLoadedPlugin();
expect(hoisted.pluginSettingsComponentConstructor).toHaveBeenCalledOnce();
expect(hoisted.pluginSettingsTabConstructor).toHaveBeenCalledOnce();
expect(hoisted.backlinkFullPathComponentConstructor).toHaveBeenCalledOnce();
});
it('should register the settings tab with the plugin', async () => {
const plugin = await createLoadedPlugin();
expect(castTo<SettingTabsHolder>(plugin).settingTabs__).toHaveLength(1);
});
it('should register the open demo vault command', async () => {
const plugin = new Plugin(app, manifest);
const addCommandSpy = vi.spyOn(plugin, 'addCommand');
await plugin.onload();
expect(addCommandSpy).toHaveBeenCalledWith(expect.objectContaining({ id: 'open-demo-vault' }));
});
});
/* eslint-enable @typescript-eslint/no-extraneous-class -- End of test file. */

46
src/plugin.ts Normal file
View file

@ -0,0 +1,46 @@
import { OpenDemoVaultCommandHandler } from 'obsidian-dev-utils/obsidian/command-handlers/open-demo-vault-command-handler';
import { PluginSettingsTabComponent } from 'obsidian-dev-utils/obsidian/components/plugin-settings-tab-component';
import { PluginDataHandler } from 'obsidian-dev-utils/obsidian/data-handler';
import { PluginBase } from 'obsidian-dev-utils/obsidian/plugin/plugin';
import { PluginEventSourceImpl } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source';
import { BacklinkFullPathComponent } from './backlink-full-path-component.ts';
import { PluginSettingsComponent } from './plugin-settings-component.ts';
import { PluginSettingsTab } from './plugin-settings-tab.ts';
export class Plugin extends PluginBase {
protected override onloadImpl(): void {
const pluginSettingsComponent = this.addChild(
new PluginSettingsComponent({
dataHandler: new PluginDataHandler(this),
pluginEventSource: new PluginEventSourceImpl(this)
})
);
this.addChild(
new PluginSettingsTabComponent({
plugin: this,
pluginSettingsTab: new PluginSettingsTab({
plugin: this,
pluginSettingsComponent
})
})
);
this.addChild(
new BacklinkFullPathComponent({
app: this.app,
pluginSettingsComponent
})
);
this.commandHandlerComponent.registerCommandHandlers([
new OpenDemoVaultCommandHandler({
app: this.app,
pluginId: this.manifest.id,
pluginNoticeComponent: this.pluginNoticeComponent,
pluginVersion: this.manifest.version
})
]);
}
}

View file

@ -1,6 +1,16 @@
.backlink-full-path {
&.file-name {
font-size: var(--font-ui-large);
font-weight: bold;
&.backlink-control {
.full-path {
display: none;
}
&[data-should-highlight-file-name=true]::part(file-name) {
font-size: var(--font-ui-large);
font-weight: bold;
}
&[data-should-display-parent-path-on-separate-line=true]::part(parent-path) {
display: block;
}
}
}

1
src/styles/scss.d.ts vendored Normal file
View file

@ -0,0 +1 @@
declare module '*.scss';

View file

@ -3,7 +3,6 @@
"compilerOptions": {
"allowArbitraryExtensions": true,
"allowImportingTsExtensions": true,
"allowJs": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"importHelpers": true,
@ -11,25 +10,28 @@
"inlineSources": true,
"lib": [
"DOM",
"ES2024"
"DOM.Iterable",
"ES2022"
],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"libReplacement": true,
"module": "node16",
"moduleResolution": "node16",
"noEmit": true,
"skipLibCheck": false,
"target": "ES2024",
"skipLibCheck": true,
"target": "es2022",
"types": [
"node",
"obsidian-typings",
"svelte"
"@obsidian-typings/obsidian-public-latest",
"@total-typescript/ts-reset",
"node"
],
"verbatimModuleSyntax": true
},
"include": [
"./eslint.config.*ts",
"./src/**/*.svelte",
"./.markdownlint-cli2.mts",
"./commitlint.config.ts",
"./eslint.config.mts",
"./scripts/**/*.ts",
"./src/**/*.ts",
"./src/**/*.tsx",
"./scripts/**/*.ts"
"./vitest.config.ts"
]
}

View file

@ -7,5 +7,38 @@
"1.3.1": "1.8.9",
"1.3.2": "1.8.9",
"1.3.3": "1.8.9",
"1.3.4": "1.8.9"
"1.3.4": "1.8.9",
"1.4.0": "1.8.9",
"1.5.0": "1.8.9",
"1.5.1": "1.8.10",
"1.5.2": "1.8.10",
"1.5.3": "1.8.10",
"1.5.4": "1.8.10",
"1.5.5": "1.8.10",
"1.5.6": "1.9.10",
"1.5.7": "1.9.12",
"1.5.8": "1.9.12",
"1.5.9": "1.9.12",
"1.5.10": "1.9.12",
"1.5.11": "1.9.12",
"1.5.12": "1.9.12",
"1.5.13": "1.9.12",
"1.5.14": "1.9.12",
"1.5.15": "1.9.12",
"1.5.16": "1.9.14",
"1.5.17": "1.9.14",
"1.5.18": "1.10.3",
"1.5.19": "1.10.6",
"1.6.0": "1.10.6",
"1.6.1": "1.11.4",
"1.6.2": "1.12.4",
"1.6.3": "1.12.7",
"1.6.4": "1.12.7",
"1.6.5": "1.12.7",
"1.6.6": "1.12.7",
"1.6.7": "1.12.7",
"1.6.8": "1.12.7",
"1.6.9": "1.12.7",
"1.6.10": "1.12.7",
"1.6.11": "1.12.7"
}

3
vitest.config.ts Normal file
View file

@ -0,0 +1,3 @@
import { config } from './scripts/vitest-config.ts';
export default config;