External Publication
Visit Post

Project-level plugins and config for Apex

Brett Terpstra [Unofficial] January 28, 2026
Source

If you use Apex for more than one project, you’ve probably hit the point where a single global setup doesn’t quite cut it. Maybe your book project wants a different plugin set than your docs site, or one repo has stricter defaults than everything else on your machine.

As a personal example, most of my app documentation has always been written in MultiMarkdown, where header ids get generated with no spaces or dashes, so all of my cross-references link to #thissection type of anchors. My blog and my Jekyll-based documentation sites have always used Kramdown, so header ids and cross references are #this-section. I needed an easy way to always have Apex use the right header format for the current project.

I also have special plugins for different destinations. For example, my Marked documentation has special Liquid-style tags like prefpane that generates nice HTML for referencing Preference panes with x-marked URLs that will open a preference pane directly in Marked. I don’t need or want a plugin to do that universally, the output it generates is very specific to Marked.

So I added project-scoped plugins and configurations to Apex. This allows me to get the settings just right for a project, then save them into a local directory and be able to just run apex without a bunch of command line flags to remember.

You also get a cleaner way to “shadow” plugins you don’t want with a local noop plugin.

I also added ++insert++ syntax for adding <ins>insert</ins> tags, but that’s just a little one-off addition.

Project-scoped plugins in .apex/plugins

Plugins used to be purely global: Apex would only look in your XDG config dir:

  • $XDG_CONFIG_HOME/apex/plugins, or
  • ~/.config/apex/plugins

Now there’s a proper project scope , searched in this order:

  1. ./.apex/plugins (current working directory)
  2. BASE/.apex/plugins when you run with --base-dir BASE
  3. <git-root>/.apex/plugins when you’re inside a Git work tree
  4. Global: $XDG_CONFIG_HOME/apex/plugins or ~/.config/apex/plugins

Each of those directories can hold Apex plugins in the usual format:

.apex/ plugins/ my-plugin/ plugin.yml whatever-script-you-like

When Apex builds the plugin list, earlier locations win by id. If a plugin with id footnotes-plus exists in both .apex/plugins and your global config dir, the project version is the one that runs.

No-op shadowing: turning off plugins per project

That id-based precedence also gives you a neat trick: no-op shadowing.

If there’s a global plugin you usually like, but you don’t want it in a specific project, you can “shadow” it by dropping an empty or no-op plugin with the same id into .apex/plugins. For example:

.apex/ plugins/ kbd/ plugin.yml noop.sh

plugin.yml might look like:

id: kbd title: KBD Noop

Because the project copy of kbd is discovered first, it shadows the global one. You can also do the same trick with purely declarative regex plugins: define a plugin with the same id that simply doesn’t match anything meaningful, and the global behavior is effectively disabled for that project.

--list-plugins now understands projects

To make this discoverable, apex --list-plugins was updated to use the same resolution rules as the runtime plugin loader.

When you run:

apex --list-plugins

you’ll see:

  • An “Installed Plugins” section that includes plugins from:
    • ./.apex/plugins
    • BASE/.apex/plugins (if --base-dir was used)
    • <git-root>/.apex/plugins
    • global config plugins
  • An “Available Plugins” section from the remote directory, filtered so remote entries are hidden when you already have a plugin with the same id installed anywhere (project or global).

If a project plugin shadows a global one, you’ll only see the project entry in the installed list, and the remote listing won’t try to “helpfully” re-offer the same id.

Project-level config in .apex/config.yml

Plugins aren’t the only thing that benefit from scoping. Apex’s configuration system now has an explicit project layer , alongside the existing global and per-document metadata.

Config is now read from these places:

  1. Global config
    • $XDG_CONFIG_HOME/apex/config.yml, or
    • ~/.config/apex/config.yml
  2. Project config
    • ./.apex/config.yml
    • BASE/.apex/config.yml when using --base-dir BASE
    • <git-root>/.apex/config.yml when inside a Git work tree
  3. Explicit metadata file
    • Any file you pass with --meta-file FILE
  4. Per-document metadata
    • YAML front matter, MultiMarkdown metadata, or Pandoc title blocks
  5. Command-line metadata and flags
    • --meta KEY=VALUE, --mode, --pretty, --no-tables, and so on

The merge order matters:

  • Global config.yml (lowest file precedence)
  • Project .apex/config.yml
  • --meta-file FILE
  • Document metadata
  • --meta and CLI flags (highest precedence)

So if you put this in your global config:

mode: unified pretty: true wikilinks: false

and then in your project .apex/config.yml:

wikilinks: true indices: true

you’ll end up with:

  • mode: unified
  • pretty: true
  • wikilinks: true # project overrides global
  • indices: true # project-only addition

Any --meta-file you pass on the command line layers on top of both, and document/CLI overrides still win last.

A quick example project layout

Here’s what a repo might look like with all of this wired up:

my-book/ .git/ .apex/ config.yml plugins/ figures/ plugin.yml figures.py kbd/ plugin.yml chapters/ 01-intro.md 02-deep-dive.md

Running:

cd my-book apex chapters/01-intro.md --plugins

will:

  • Load config from:
    • global config.yml (if any),
    • then ./.apex/config.yml,
    • then any --meta-file you pass,
  • Run plugins from:
    • ./.apex/plugins first,
    • then fall back to global plugins,
  • Apply per-document metadata and CLI overrides on top.

You get per-repo behavior without having to constantly remember the right --meta-file or a long list of flags.

A quick note on ++insert++

While we were in here, another small but handy syntax has been added: ++insert++.

++insert++ gives you a lightweight way to add an <ins>text</ins> tag to your document. It’s just a little shorter and easier than typing out the tags manually. I try not to add too much esoteric markup to the syntax, but I’ve seen ++ a couple of places and thought it a worthwhil addition.

Wrapping up

To recap:

  • Plugins can now live in .apex/plugins at the project level, and they override global plugins by id.
  • --list-plugins shows the actual set of plugins Apex will run for your current project, including overrides.
  • Config can now live in .apex/config.yml, layered on top of your global config.yml and below any explicit --meta-file, document metadata, and flags.
  • ++insertion++ gives you <ins> tags.

If you’ve been juggling different shell aliases or wrapper scripts for each project, you can probably simplify a lot of that now by letting Apex’s own project-aware behavior do the heavy lifting.

Like or share this post on Mastodon, Bluesky, or Twitter.


BrettTerpstra.com is supported by readers like you. Click here if you'd like to help out.

Find Brett on Mastodon, Bluesky, GitHub, and everywhere else.

Discussion in the ATmosphere

Loading comments...