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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 38 additions & 12 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -195,34 +195,60 @@ install: ## Build and install into the vault at $DEV_VAULT_PATH
# for you — deletes the contents of what it points at, silently, reporting nothing.
#
# It deliberately does not build: you link once and leave `make dev` running, so a fresh checkout
# has no main.js yet and its link dangles until the first build, hence the reminder. An existing
# real directory is never removed here — it is somebody's install, and possibly their settings —
# and deleting one to save a `rm` is not a trade this target gets to make.
# has no main.js yet and its link dangles until the first build, hence the reminder.
#
# `FORCE=1` takes over a destination this would otherwise refuse: a copied install, a whole-folder
# symlink, or links following a different checkout. It stays opt-in because those files are
# somebody's, and it is safe because of what it will not do. No directory is ever removed: a copied
# install loses the plugin's three files by name, which this checkout rebuilds in a second, and a
# whole-folder symlink loses the link itself, never what it points at. `data.json` is the one thing
# in that folder nobody can regenerate, so it is left where it lies — or carried across when the
# folder *was* the link and the settings are therefore sitting in the checkout. A destination that
# is not a plugin directory is refused either way: force is permission to replace this plugin's
# files, not a licence to guess at somebody else's.
.PHONY: link
link: ## Symlink this checkout's files into the vault at $DEV_VAULT_PATH (pairs with make dev)
link: ## Symlink this checkout's files into the vault at $DEV_VAULT_PATH (FORCE=1 to take one over)
$(vault-guard)
@$(vault-dest); \
here=$$(pwd -P); \
case $$($(dest-shape)) in \
copied) \
echo "make link: '$$dest' holds an installed copy of the plugin, and possibly its data.json." >&2; \
echo " Remove it yourself once you are sure, then re-run: rm -r '$$dest'" >&2; \
exit 1; \
if [ -z "$(FORCE)" ]; then \
echo "make link: '$$dest' holds an installed copy of the plugin, and possibly its data.json." >&2; \
echo " 'FORCE=1 make link' replaces the plugin's files with links and leaves data.json alone." >&2; \
echo " Or remove it yourself once you are sure, then re-run: rm -r '$$dest'" >&2; \
exit 1; \
fi; \
for f in $(PLUGIN_FILES); do rm -f "$$dest/$$f"; done; \
echo "Took over the copied install in $$dest; anything else there, data.json included, is untouched."; \
;; \
whole-link) \
echo "make link: '$$dest' is a symlink to a whole checkout, which this target no longer makes." >&2; \
echo " 'make unlink' replaces it safely, or remove it with no trailing slash: rm '$$dest'" >&2; \
echo " 'rm -r $$dest/' would instead delete the contents of the checkout it points at." >&2; \
exit 1; \
if [ -z "$(FORCE)" ]; then \
echo "make link: '$$dest' is a symlink to a whole checkout, which this target no longer makes." >&2; \
echo " 'FORCE=1 make link' replaces it with a folder of links, carrying data.json across." >&2; \
echo " 'make unlink' replaces it with a copied build, or remove it with no trailing slash: rm '$$dest'" >&2; \
echo " 'rm -r $$dest/' would instead delete the contents of the checkout it points at." >&2; \
exit 1; \
fi; \
was=$$($(dest-target)); \
rm "$$dest"; \
mkdir -p "$$dest"; \
if [ -n "$$was" ] && [ -f "$$was/data.json" ]; then \
cp "$$was/data.json" "$$dest/" || exit 1; \
echo "Carried data.json across from $$was, so the plugin keeps the settings it had while linked."; \
fi; \
echo "Replaced the whole-folder symlink at $$dest; $${was:-what it pointed at} is untouched."; \
;; \
other) \
echo "make link: '$$dest' exists and is not a plugin directory." >&2; \
echo " FORCE=1 does not reach this: it replaces this plugin's files, and will not guess at others'." >&2; \
exit 1; \
;; \
esac; \
target=$$($(dest-target)); \
if [ -n "$$target" ] && [ "$$target" != "$$here" ]; then \
if [ -n "$$target" ] && [ "$$target" != "$$here" ] && [ -z "$(FORCE)" ]; then \
echo "make link: '$$dest' links to '$$target', not this checkout." >&2; \
echo " 'FORCE=1 make link' re-points them here, deleting nothing: a symlink is replaced, not followed." >&2; \
echo " Its contents are symlinks, so removing it reaches no checkout: rm -r '$$dest'" >&2; \
exit 1; \
fi; \
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# Fluidity

Fluidity is a plugin for [Obsidian](https://obsidian.md) that finishes the links the link completer
starts while giving you control over exactly how itt's done!
starts while giving you control over exactly how it's done!

Obsidian's autocomplete is usually very good at working out _which note you meant_, and then hands
you a link that is _nearly_ right. The display text might be capitalized when the sentence wanted it
lowercase, or the linked pointing at the top of a note when you meant it to point at a section
lowercase, or the link pointing at the top of a note when you meant it to point at a section
half-way down. Both leave you editing a link that could have just been right the first time.

Fluidity changes what gets inserted, at the moment it gets inserted, so there is nothing to go back
Expand Down
53 changes: 39 additions & 14 deletions docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,18 @@ shell, so `make check` works from a bare terminal too while being a little slowe

`make help` lists every target, but the main ones you will use are these:

| Target | What it does |
| ---------------- | ----------------------------------------------------------------------- |
| `make build` | typecheck + bundle — a release `main.js` |
| `make dev` | rebuild `main.js` on change, with sourcemaps |
| `make install` | build, then copy the plugin into `$DEV_VAULT_PATH` |
| `make link` | symlink this checkout's files into `$DEV_VAULT_PATH` instead of copying |
| `make unlink` | swap those symlinks back for a copied build |
| `make check` | **everything CI checks**: format, typecheck, lint, all tests |
| `make test-unit` | the pure tests only — fast |
| `make format` | reformat Markdown, JSON, CSS and TypeScript with dprint |
| `make clean` | drop build output, keep `node_modules` |
| Target | What it Does |
| ------------------- | ----------------------------------------------------------------------- |
| `make build` | typecheck + bundle — a release `main.js` |
| `make dev` | rebuild `main.js` on change, with sourcemaps |
| `make install` | build, then copy the plugin into `$DEV_VAULT_PATH` |
| `make link` | symlink this checkout's files into `$DEV_VAULT_PATH` instead of copying |
| `FORCE=1 make link` | the same, taking over an install or a link to another checkout |
| `make unlink` | swap those symlinks back for a copied build |
| `make check` | **everything CI checks**: format, typecheck, lint, all tests |
| `make test-unit` | the pure tests only — fast |
| `make format` | reformat Markdown, JSON, CSS and TypeScript with dprint |
| `make clean` | drop build output, keep `node_modules` |

Building without Nix is possible as the toolchain is only Node, and `npm ci && npm run build` is
exactly what Obsidian's plugin review runs, so CI checks that path on every push. You will want
Expand Down Expand Up @@ -80,12 +81,22 @@ make link

It takes the same two guards as `make install` and deliberately does not build, since the intent is
that you link once and leave `make dev` running — so a fresh checkout has no `main.js` yet, its link
dangles, and the target says so rather than leaving you with a plugin Obsidian cannot load. It never
removes what is already at the destination: if `make install` has put a copied folder there,
`make link` tells you to delete it yourself, because that folder may hold your `data.json`. Settings
dangles, and the target says so rather than leaving you with a plugin Obsidian cannot load. Settings
Obsidian writes land in the vault folder beside the links. Reloading is still on you, as Obsidian
does not watch the files for changes.

By default it refuses a destination that is already occupied, because those files are somebody's.
`FORCE=1 make link` takes one over, but will never:

- **Remove the plugin directory.** A copied install loses the plugin's three files _by name_, which
this checkout rebuilds in a second. Anything else in the folder stays.
- **Follow a symlink.** A whole-folder link is removed with no trailing slash, so the checkout on
the other end is untouched.
- **Remove `data.json`.** It is the one thing in that folder nobody can regenerate, so it is left
where it lies.
- **Operate on a unrecognized destination.** Force is permission to replace this plugin's files, not
a licence to guess at somebody else's.

The plugin folder is a real directory and only its contents are links, which is what makes it safe
to remove. `rm` deletes a symlink rather than following it, so clearing the folder out costs three
links that `make link` rebuilds in a second. A folder that is _itself_ one symlink does not have
Expand Down Expand Up @@ -129,6 +140,20 @@ Any change to what gets inserted should be exercised against at least this much:
8. **Disabling the plugin**, after which the completer must behave as stock without any intervention
from the monkey patch.

Any change to settings should be exercised against this much:

1. The **status line**, which must read **Active** in green behind a checkmark on a working vault,
and **Inactive** in red behind a crossed octagon the moment the master toggle is turned off. A
missing icon means Obsidian's Lucide knows neither name `settings/tab` tries for it.
2. The **master toggle off**, after which a fluent note completes exactly as it does with the plugin
disabled — and **on again**, after which it adjusts once more without a reload.
3. A **renamed property**, which must take effect on the very next completion with no reload: the
note carrying the old property stops being adjusted, and one carrying the new one starts.
4. **Clearing the property field**, which means `fluent` and shows it as a placeholder.
5. **Reopening the tab**, and restarting Obsidian, after which both settings read back as they were
left.
6. Searching Obsidian's own **settings search** for `fluent`, which must find both settings.

## Tests

The tests are split across two suites, and the split is crucial to our testing strategy:
Expand Down
62 changes: 55 additions & 7 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,14 @@ settings tab. It makes no decisions of its own, which is what lets everything be
without it.

```
src/main.ts lifecycle only — load settings, install the patch, add the settings tab
src/settings/defs.ts settings interface, defaults, normalization (pure)
src/settings/tab.ts the settings tab
src/main.ts lifecycle only — load settings, add the tab, install/remove the patch
src/settings/defs.ts settings record, defaults, normalization of what was stored (pure)
src/settings/tab.ts the settings tab, declared for Obsidian 1.13 to render
src/suggest/patch.ts locate + patch the built-in suggester (the only internals-touching file)
src/suggest/item.ts the suggestion-item union and its type guards
src/suggest/transform.ts (item, context, settings) → item : the decision, thin and delegating
src/suggest/transform.ts (item, context, options) → item : the decision, thin and delegating
src/fluent/frontmatter.ts read fluency from a frontmatter-shaped object (pure)
src/fluent/display.ts (displayText, isFluent, atSentenceStart) → display text (pure)
src/fluent/display.ts (displayText, atSentenceStart) → display text (pure)
src/prose/sentence.ts is this offset a sentence start? (pure)
```

Expand Down Expand Up @@ -160,8 +160,9 @@ Obsidian's startup is one nobody can uninstall from inside Obsidian.
### Patching the Instance's Own Prototype

The patch goes on `builtin.constructor.prototype`, via
[`monkey-around`](https://github.com/pjeby/monkey-around), and the uninstaller it returns is handed
to `plugin.register()` so that disabling the plugin puts everything back.
[`monkey-around`](https://github.com/pjeby/monkey-around), and the uninstaller it returns is called
from `onunload`, so that disabling the plugin puts everything back — and from the master toggle, so
that switching the feature off does too.

It must not go on `EditorSuggest.prototype`. That is shared with the tag suggester, the footnote
suggester, and every suggester every other plugin has registered — patching it would have Fluidity
Expand Down Expand Up @@ -277,6 +278,53 @@ production-grade, it depends on nothing undocumented, and knowing it exists is w
current approach a considered choice rather than the only one anybody thought of. The
[roadmap](./roadmap.md) tracks it.

## Settings

Two settings, one read-only status line, and three decisions worth writing down.

### The Master Toggle Removes the Patch

Turning fluent titles off uninstalls the wrapper rather than making it inert. Both would leave
completions unchanged, so the difference only matters for the reason somebody reaches for the
switch: Fluidity's risk is that it patches a part of Obsidian that is not meant to be
user-accessible, and an off switch that leaves the patch in place does not retire that risk. Off
means the app is running the code it would run without Fluidity installed.

`main.ts` owns this. It keeps the current `PatchResult` and reconciles it against the setting;
`suggest/transform` is deliberately given a narrower record than `settings/defs` holds, so that the
decision cannot start answering a question that belongs to the lifecycle.

One consequence is free: toggling off and back on reinstalls, which is the easy retry for a failed
install. Retrying on every settings change instead would relog the same failure on every keystroke.

### The Property is Read Per Completion

`installFluentTitles` takes a **function** returning its options rather than a record, and calls it
inside the wrapper. That is what lets a rename take effect on the next completion.

### `data.json` is Not Trusted

Obsidian hands back whatever `JSON.parse` made of a file the user can open and edit, and which may
have been written by an older version of this plugin. The conventional
`Object.assign({}, DEFAULTS, await loadData())` accepts all of it: a `fluentProperty` of `null`
survives and is then used to read frontmatter under the key `"null"`, and a `fluentTitles` of
`"false"` is a truthy string that turns the feature on for somebody whose file says it is off.

A file that can be edited can also stop being JSON, and that is a separate failure: it happens
before any of this, in `loadData`. `main.ts` catches it, reports one line and starts on the
defaults, because `onload` is the one method here that must not fail: a plugin that breaks
Obsidian's startup is one nobody can disable from inside Obsidian.

Reading a file makes `onload` asynchronous, and Obsidian is free to unload a plugin while an `await`
inside one is still pending. That is why `main.ts` checks whether it has been unloaded before it
installs anything: a patch applied after the unload that would have removed it stays on the
completer until the app restarts, with nothing left running that knows it is there.

So `settings/defs` **rebuilds** the record field by field instead of merging, and each field that
does not hold its declared type falls back to its own default. Field by field rather than wholesale,
because the two settings are independent and one unusable value should not silently revert the
other.

## The Build

`esbuild` bundles `src/main.ts` to a single CommonJS `main.js`. The plugin is `main.js`,
Expand Down
23 changes: 8 additions & 15 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,26 +210,19 @@ See the [roadmap](./roadmap.md).

## Settings

**Not implemented yet.** There is no settings tab. The property is fixed as `fluent` in
`src/main.ts`, there is no master toggle, and changing either takes an edit and a rebuild. What this
section describes is the intended shape, and the [roadmap](./roadmap.md) tracks it.

**Settings → Fluidity**.

| Setting | Default | What it does |
| ------------------- | -------- | -------------------------------------------------------------- |
| **Fluent titles** | on | The master toggle. Off, nothing about a completion is changed. |
| **Fluent property** | `fluent` | Which frontmatter property marks a note fluent. |

Renaming the property takes effect immediately and does not migrate anything — notes still carrying
the old property simply stop being treated as fluent. It exists for vaults where `fluent` already
means something else.

Above these sits a **read-only status line** reporting whether the completer patch installed. It is
the first thing to check when nothing seems to be happening: Fluidity works by patching a part of
Obsidian that is not public API, and an Obsidian update is capable of moving what it attaches to. If
that happens the plugin declines to install the patch, says so here and once in the developer
console, and leaves the completer behaving exactly as it does without the plugin.
Turning **fluent titles** off does not leave a patched completer sitting idle but instead removes
the patch outright, so that off means Obsidian is running the code it would run without Fluidity
installed.

Until that line exists, the developer console is the only place the failure is reported, which is
why checking the plugin by hand starts by opening it.
Renaming the **property** takes effect on the next completion and does not migrate anything; notes
still carrying the old property simply stop being treated as fluent. It exists for vaults where
`fluent` already means something else. Leaving the field empty means `fluent`, which is what the
greyed-out placeholder is telling you; surrounding spaces are dropped, because a trailing one is
invisible in both this field and the property editor and would read as the plugin being broken.
7 changes: 4 additions & 3 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ Nothing here is committed to or has a date; it is a statement of intent.

- **Section-Aware Alias Links.** The second half of the plugin, described below. It is the reason
Fluidity exists as much as fluent titles are.
- **Real-Vault Coverage of the Settings Tab.** The settings path is exercised by type-checking and
the pure defaults test, not by anything that renders it. Every settings change needs a manual pass
until that is no longer true.
- **Real-Vault Coverage of the Settings Tab.** The normalizer is unit-tested and the tab is
type-checked, but nothing renders it outside a vault — and nothing can, since the controls are
declarations handed to Obsidian to draw. Every settings change needs a manual pass, which the
[contributing guide](./CONTRIBUTING.md#what-to-check-by-hand) lists.
- **Mobile.** The manifest says the plugin is not desktop-only, and nothing in the design should
care — but the completer is reached differently on the mobile toolbar's `[[` button, and that has
not been exercised. Until it has, "should work" is all that can honestly be claimed.
Expand Down
Loading
Loading