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
9 changes: 7 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ npm-debug.log*
/result
/result-*

# Obsidian plugin data, present when the repository is symlinked into a vault
# for testing.
# Obsidian plugin data. `make link` links the plugin's files individually, so a
# vault writes its settings beside them rather than here; this covers a vault
# whose whole plugin folder is a symlink to the checkout, which `make unlink`
# copies across.
/data.json

# The local development runbook: session state and working notes, not user
Expand All @@ -25,3 +27,6 @@ npm-debug.log*
*.swp
/.idea/
/.vscode/

# The local-only plan directory
tmp/
291 changes: 207 additions & 84 deletions Makefile

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Obsidian's link completer is not exposed, so Fluidity achieves what it does by b
and **patching it**. It finds the built-in suggester at runtime, wraps the method that turns your
choice into text, and then adjusts the choice before handing it to Obsidian's code to insert. The
insertion itself is never reimplemented, which is why the result respects your link-format settings
and why undo puts the note back in a single step.
and why undoing a link costs no more than undoing any other completion.

The cost of doing it this way is simply that an Obsidian update can trivially break the plugin.
Fluidity is designed to fail gracefully when this happens, not installing the patch and reporting
Expand Down
75 changes: 45 additions & 30 deletions docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ project's toolchain. CI runs inside the same shell, executing the same `make` ta
git clone https://github.com/iamrecursion/fluidity.git
cd fluidity
nix develop # or: make shell
make deps # npm ci
make deps # npm ci, plus the esbuild binary for every platform sharing the checkout
make build # type-check and bundle main.js
make check # exactly what CI runs
```
Expand All @@ -31,17 +31,17 @@ 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 into `$DEV_VAULT_PATH` instead of copying |
| `make unlink` | swap that symlink 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 |
| `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 All @@ -68,34 +68,45 @@ looks for. The target refuses a path with no `.obsidian` directory in it, and ch
building rather than after. Obsidian does not notice the new files on its own, so you will need to
reload the app (or toggle the plugin off and back on).

For rapid development, `make link` symlinks the repository into `<vault>/.obsidian/plugins/` instead
of copying into it. Obsidian follows the symlink, so a rebuild is live in the vault with no second
step, which pairs well with leaving `make dev` running.
For rapid development, `make link` fills `<vault>/.obsidian/plugins/<id>/` with symlinks to this
checkout's `main.js`, `manifest.json` and `styles.css` instead of copying them. Obsidian follows
each one, so a rebuild is live in the vault with no second step, which pairs well with leaving
`make dev` running.

```sh
export DEV_VAULT_PATH=~/vaults/dev
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, 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 real folder there, `make link` tells you to
delete it yourself, because that folder may hold your `data.json`. Once linked, the settings
Obsidian writes land in the checkout itself, which the `.gitignore` already accounts for. Reloading
is still on you as Obsidian does not watch the file for changes.

`make unlink` is the way back. It builds, removes the symlink, and copies the same three files in
its place, leaving the vault with an ordinary install:
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
Obsidian writes land in the vault folder beside the links. Reloading is still on you, as Obsidian
does not watch the files for changes.

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
that property: `rm -rf <folder>/`, carrying the trailing slash that shell completion appends for
you, deletes the contents of the checkout it points at, silently and with nothing reported.

`make unlink` is the way back. It replaces the links with the files they point at, leaving the vault
with an ordinary install:

```sh
export DEV_VAULT_PATH=~/vaults/dev
make unlink
```

The build happens **before** the symlink goes, so a build that fails leaves the vault with the
plugin it already had rather than an empty folder. The checkout's `data.json` is copied across if
there is one, since that is where the linked plugin has been keeping its settings.
Its job is to get a vault off a checkout, so a build it cannot run does not stop it: with a
`main.js` already in the checkout it installs that one and says so, and only a destination with
nothing to copy at all is an error. That matters on a machine which only runs Obsidian, where the
toolchain may not work and where failing would leave a hand-written `rm` as the only way out.

A folder that is itself a symlink to a whole checkout is converted too, and that checkout's
`data.json` is copied across, since that is where a plugin linked that way keeps its settings.

### What to Check by Hand

Expand All @@ -106,8 +117,11 @@ Any change to what gets inserted should be exercised against at least this much:
2. A **multi-word** fluent title, and one of its **aliases**.
3. A note with no `fluent` property, and one with `fluent: false`, which should both be completely
untouched.
4. **Undo**, which must put the note back in one step. If it takes two, the plugin is rewriting text
after insertion somewhere, and that is a bug regardless of what the undo produces.
4. **Undo**, which must cost exactly as many steps as it does with the plugin disabled. Accepting
any completion takes two in stock Obsidian, one for the completion and one for the typing, so
count both ways rather than expecting one. A fluent insertion costing more than a control does
means the plugin is rewriting text after insertion, and that is a bug regardless of what the undo
produces.
5. Selection by **mouse click**, by **Enter**, and by **Tab**.
6. `#`, `^` and `|` completions, which must behave exactly as they do without the plugin.
7. **Use `[[Wikilinks]]` turned off**, where the same choice must produce a well-formed Markdown
Expand Down Expand Up @@ -183,7 +197,8 @@ pull request that breaks one of them will be sent back:
- **Read what you need before delegating** as the original clears the state you want to read in its
first statement.
- **Never reimplement the insertion.** Adjust the suggestion and hand it back. That is what makes
the result honor link-format settings, and what makes undo a single step.
the result honor link-format settings, and what keeps the insertion one editor transaction, so
undo costs no more than it does without the plugin.
- **Pass through what you do not handle**, untouched and by identity. Some suggestion types write to
files when selected, and intercepting one of those is how a plugin corrupts a note.
- **Fail quietly** if the patch cannot be installed, log one line naming the plugin, disable the
Expand Down
17 changes: 13 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ how it does so is a deliberate response to something that will otherwise go wron
`suggests: EditorSuggest[]`. Dispatch is **first non-null `onTrigger` wins, in array order**, which
is how one plugin's suggester displaces another's.

Because that singleton is built with the workspace, the registry is already populated by the time a
plugin's `onload` runs, which is why the patch is not deferred to `onLayoutReady`. Enabling a plugin
by hand happens long after startup, so a patch that needed the later hook would work every time it
was switched on and be dead on every cold start.

The built-in link suggester is `suggests[0]` at startup, but it **must be located by capability**:

```ts
Expand Down Expand Up @@ -177,8 +182,12 @@ control is handed to the original:
const ctx = this.context; // before old(), never after
```

This is the kind of thing that works perfectly in every test written against a fake and fails on the
first real keystroke.
This is the kind of thing that works perfectly against a fake whose `selectSuggestion` leaves the
context alone, and fails on the first real keystroke. The fake in
`test/integration/suggest/patch.test.ts` clears it, exactly as `close()` does: a wrapper that reads
the context anywhere but off the instance being called — when the patch is installed, say — then
sees the cleared value, stops adjusting anything. That file pins the rest of the rules in this
section the same way, each against a registry shaped like the real one.

### Only Some Suggestion Types May be Touched

Expand Down Expand Up @@ -208,8 +217,8 @@ silently write the wrong link — which is worth stating plainly, because "use t
otherwise the obviously correct advice.

Going through the built-in composer also inherits correct handling of both **Use [[Wikilinks]]** and
**New link format** for free, and keeps the insertion a single editor transaction, which is what
makes undo one step.
**New link format** for free, and keeps the insertion a single editor transaction, so undoing it
costs exactly what undoing any completion costs.

### A Fluent Note With no Alias Becomes an Alias Item

Expand Down
13 changes: 12 additions & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ single sentence answers most questions about scope:
the same in any other Markdown editor.
- It does not run on typing, on paste, or on save.
- It never rewrites text after Obsidian has inserted it. The link Obsidian writes is already the
right one, which is why **undo is a single step**.
right one, so **undoing it costs exactly what undoing any completion costs**.

## Fluent Note Titles

Expand Down Expand Up @@ -103,6 +103,10 @@ makes them readable in a list.

### Other Completions are Untouched

**Embeds are left alone.** `![[Interiority]]` renders the note rather than reading as prose, and
what follows the pipe in one is a display argument rather than text. An embed is inserted exactly as
Obsidian would insert it, whether or not the note is fluent.

Typing `#` for a heading, `^` for a block reference, or `|` for an alias inside a link behaves
exactly as it does without the plugin. So does `Shift+Enter`, and so does every suggestion type
Fluidity does not explicitly handle. In particular, block-reference completions are never
Expand Down Expand Up @@ -206,6 +210,10 @@ 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 |
Expand All @@ -222,3 +230,6 @@ the first thing to check when nothing seems to be happening: Fluidity works by p
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.

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.
30 changes: 30 additions & 0 deletions src/fluent/display.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* The casing rule: what a fluent note's link should actually say.
*
* Every casing decision in the plugin goes through this one function, which is what makes the rule
* replaceable. The roadmap's per-note override language changes this body and nothing else.
*
* It is separate from `suggest/transform` because that module decides *whether* a suggestion is
* ours to touch and this one decides *what the text becomes*; the first is about Obsidian's
* internals and the second is about English.
*/

/**
* The display text to insert for a fluent note, given the text Obsidian would have inserted.
*
* Whether the note is fluent at all is settled before this is called — that question belongs to
* `fluent/frontmatter`, and asking it again here would mean two modules answering it.
*
* The rule is blunt on purpose: it lowercases the whole display text, so
* `Object Oriented Programming` comes out right and `History of France` does not. That cost is
* accepted for the first version, and documented where users will meet it — the point of v1 is to
* establish that patching the completer is reliable, and a casing rule with no configuration
* surface keeps that question clean.
*
* Lowercasing is locale-aware, so non-ASCII scripts behave as the reader's locale expects rather
* than as ASCII would have it.
*/
export function fluentDisplay(displayText: string, atSentenceStart: boolean): string {
if (atSentenceStart) return displayText;
return displayText.toLocaleLowerCase();
}
26 changes: 26 additions & 0 deletions src/fluent/frontmatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Reading fluency out of a note's frontmatter.
*
* The property is read from a plain object rather than from a file, which keeps this module free
* of Obsidian and lets the caller decide where the frontmatter came from. In the plugin that is
* always the metadata cache, so there is no file read between a keypress and an insertion.
*
* It is separate from `fluent/display` because this one is about what a note says about itself and
* that one is about the text being written; the second feature reads the same frontmatter for a
* different purpose, and will read it through here.
*/

/**
* Is this note marked fluent — does its title name a common noun rather than a proper one?
*
* The value must be a real boolean. `fluent: "true"` is a string and does not mark the note, which
* is deliberate: Obsidian's property editor writes a real boolean for a checkbox property, so a
* string is a typo, and silently honoring one would mean a typo changing how links are written.
*
* `frontmatter` is typed loosely because that is what it is — a parsed YAML mapping, which may be
* absent, may be any shape, and is not ours to trust.
*/
export function isFluent(frontmatter: unknown, property: string): boolean {
if (typeof frontmatter !== "object" || frontmatter === null) return false;
return (frontmatter as Record<string, unknown>)[property] === true;
}
30 changes: 23 additions & 7 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
/**
* Fluidity's entry point.
*
* This module owns lifecycle and nothing else: loading settings, installing the completer patch,
* and registering the settings tab, delegating every decision to the modules beneath it. Keeping
* it thin is what lets the rest of the plugin stay testable outside Obsidian.
* This module owns the plugin lifecycle: installing the completer patch and handing its uninstaller
* to `register()`, so that disabling the plugin puts Obsidian back as it was. Every decision
* belongs to the modules beneath it, which is what lets them be tested without an editor.
*
* It is a placeholder for now — a plugin Obsidian will load and unload cleanly, so that the
* scaffold has something real to build, check and install. The lifecycle arrives with the
* fluent-titles feature.
* The fluent property is fixed here pending `settings/defs`, which will carry it along with the
* master toggle and the status line that reports what `onload` found.
*/

import { Plugin } from "obsidian";

export default class FluidityPlugin extends Plugin {}
import { installFluentTitles } from "./suggest/patch";

/** The frontmatter property that marks a note fluent. */
const FLUENT_PROPERTY = "fluent";

export default class FluidityPlugin extends Plugin {
override onload(): void {
const result = installFluentTitles(this.app, { property: FLUENT_PROPERTY });
if (result.installed) {
this.register(result.uninstall);
return;
}

// One line, naming the plugin: the completer keeps behaving exactly as it does without Fluidity
// installed. The settings tab will report this where a user can see it.
console.error(`Fluidity: ${result.reason} — fluent titles are off, completions are unchanged`);
}
}
Loading
Loading