Skip to content

feat: allow a transformer plugin to override slug generation - #2555

Open
Shagshag wants to merge 2 commits into
jackyzha0:v5from
Shagshag:feat/pluggable-slugify
Open

Shagshag wants to merge 2 commits into
jackyzha0:v5from
Shagshag:feat/pluggable-slugify

Conversation

@Shagshag

Copy link
Copy Markdown

Summary

  • Adds an optional slugify field to QuartzTransformerPluginInstance, letting a single transformer plugin override how file paths become slugs (and thus URLs). Today the only way to change this is to patch the internals of @quartz-community/utils's slugifyPath, which the engine calls directly rather than through any transformer/filter/emitter hook — not just unsupported, but genuinely hard to do reliably, since that function ends up inlined into several plugins' own bundles as well as re-exported from the package itself.
  • Resolved once per build via resolveSlugify (new quartz/util/slugify.ts) and stored on BuildCtx.slugify; falls back to the built-in slugifyFilePath when no plugin defines one. Throws a clear configuration error if more than one enabled transformer plugin defines slugify, rather than silently picking one.
  • Updated every internal call site to go through ctx.slugify instead of importing slugifyFilePath directly: processors/parse.ts, both ctx.allSlugs computations in build.ts, and both call sites in plugins/emitters/assets.ts. worker.ts resolves its own copy per thread since functions can't cross the worker boundary (WorkerSerializableBuildCtx now also omits slugify).
  • Documented in docs/advanced/making plugins.md and docs/advanced/paths.md.

Motivation

The default slugifier doesn't transliterate accented characters (they end up percent-encoded in the URL) and leaves punctuation like backticks, apostrophes, and commas in the slug as-is — which trips up some tools/editors that try to auto-link the URL, and generally isn't what every site wants. There's currently no supported way to customize this from a plugin.

Test plan

  • npm test (167 tests, including new quartz/util/slugify.test.ts covering resolution + the multiple-override error)
  • npx tsc --noEmit
  • npx prettier . --check on touched files
  • Manual end-to-end check: a transformer plugin providing slugify is correctly picked up by processors/parse.ts and produces the expected slug
  • npx quartz build and npx quartz build --concurrency 2 (exercises the worker-thread ctx reconstruction path) both produce correct output

🤖 Generated with Claude Code

`slugifyFilePath` (@quartz-community/utils) is called directly by the
engine (parse.ts, build.ts, assets.ts) rather than through any
transformer/filter/emitter hook, so there was previously no supported
way for a plugin to change how file paths become slugs/URLs — e.g. to
transliterate accented characters instead of leaving them to be
percent-encoded, or to change how punctuation is handled.

Add an optional `slugify` field to `QuartzTransformerPluginInstance`.
It's resolved once per build (`resolveSlugify` in the new
quartz/util/slugify.ts) and stored on `BuildCtx.slugify`, falling back
to the built-in `slugifyFilePath` when no plugin defines one. Quartz
throws a configuration error at build time if more than one enabled
transformer plugin defines `slugify`, rather than silently picking one.

Every internal call site now goes through `ctx.slugify` instead of
importing `slugifyFilePath` directly: processors/parse.ts, both
`ctx.allSlugs` computations in build.ts, and both call sites in
plugins/emitters/assets.ts. worker.ts resolves its own copy per
thread since functions can't cross the worker boundary
(WorkerSerializableBuildCtx now also omits `slugify`).

Documented in docs/advanced/making plugins.md and docs/advanced/paths.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
built with Refined Cloudflare Pages Action

⚡ Cloudflare Pages Deployment

Name Status Preview Last Commit
quartz ✅ Ready (View Log) Visit Preview 18f9115

Shagshag added a commit to Shagshag/blog that referenced this pull request Sep 15, 2026
Same change as jackyzha0/quartz#2555 (upstream PR), applied here ahead
of it landing. Adds an optional `slugify` field to
`QuartzTransformerPluginInstance`, resolved once per build via
`resolveSlugify` (quartz/util/slugify.ts) and stored on
`BuildCtx.slugify`, falling back to the built-in `slugifyFilePath`
when no plugin defines one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Shagshag added a commit to Shagshag/blog that referenced this pull request Sep 15, 2026
…ream

The default slugifier doesn't transliterate accented characters (they
end up percent-encoded, e.g. %C3%A9) and leaves punctuation like
backticks, apostrophes and commas in the slug as-is, which several
editors don't reliably auto-link -- see the example that prompted
this. There's no supported hook for it yet (see jackyzha0/quartz#2555,
opened to add one), and the responsible function is duplicated across
~80 files: the root node_modules copy of @quartz-community/utils, a
separate copy each community plugin installs into its own
node_modules, and several plugins (crawl-links in particular, which
resolves [[wikilinks]] to hrefs) inline their own copy directly into
their dist bundle instead of importing the package at runtime.

scripts/patch-slugify.mjs finds every one of those copies by content
(the function's distinctive body, not a fixed path, since parameter
names and bundling vary) and patches `slugifyPath` in place to
transliterate accents and turn risky punctuation into a separator,
while deliberately leaving `slugifyPathPreserveCase` untouched -- the
alias-redirects plugin's case-preserving-redirect feature compares
that unpatched slug against the new clean canonical one and
auto-emits a redirect page whenever they differ, so every old URL
gets a redirect to its new clean URL for free, without touching any
content file (only active on a case-sensitive filesystem, i.e. the
Linux deploy target, not this Windows checkout).

Wired into package.json's postinstall/install-plugins scripts for
local convenience, and into quartz/cli/handlers.js's handleBuild --
the one call path guaranteed to run before any plugin module is
loaded regardless of which command installed it (`npx quartz plugin
install`, used by the Dockerfile, doesn't go through those npm
scripts). Verified by reverting three of the patched files and
rebuilding with no manual step: the hook re-patched them before the
build ran.

Once jackyzha0/quartz#2555 lands and this site's quartz/ is updated
from upstream, this can be replaced with a small transformer plugin
using the new `slugify` hook instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@saberzero1

saberzero1 commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator

Does this PR also handle all edge cases? E.g. 404 handling, old path redirect, Bases data, Canvas data, etc.

Not sure if this is the place to address this issue.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Loader validation must accept slugify-only transformer plugins before the documented feature works.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds transformer-plugin slugification overrides across builds, parsing, assets, workers, and documentation.

Changes:

  • Resolves one custom slugifier per build with conflict detection.
  • Routes slug generation through BuildCtx.slugify, including workers.
  • Adds tests and documents the new plugin API.

Review finding: slugify-only transformer plugins are rejected by loader validation (moderate, 3 votes), so the documented use case currently does not work.

File summaries
File Reviewed change
quartz/worker.ts Re-resolves slugification per worker.
quartz/util/slugify.ts Resolves custom or built-in slugifiers.
quartz/util/slugify.test.ts Tests resolver behavior.
quartz/util/ctx.ts Adds slugification to build context.
quartz/processors/parse.ts Uses contextual slugification.
quartz/plugins/types.ts Defines the new transformer hook.
quartz/plugins/emitters/assets.ts Applies custom slugs to assets.
quartz/build.ts Integrates custom slugification into builds.
docs/advanced/paths.md Documents slug customization.
docs/advanced/making plugins.md Documents the transformer API.
Review details
  • Files reviewed: 10/10 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread quartz/plugins/types.ts
A plugin whose only transformer hook is `slugify` -- the common case,
since it's usually the sole reason to write a slugify-overriding
plugin -- was rejected by the loader's category detection: both
places that check whether an already-instantiated plugin "looks like"
a transformer (validateCategory, used when a plugin declares its
category and gets checked post-instantiation, and
detectCategoryFromModule, the auto-detection fallback when it
doesn't) only tested for textTransform/markdownPlugins/htmlPlugins.
Add `slugify` to both, in config-loader.ts.

(quartz/plugins/loader/index.ts has a similarly-shaped check, but
that file's `resolvePlugins`/`instantiatePlugin` have no callers
anywhere in the actual build/CLI pipeline -- config loading goes
through config-loader.ts's loadQuartzConfig() exclusively -- so
touching it wouldn't do anything, and could misleadingly suggest it
was part of the fix.)

(Found by the Copilot review on the PR.)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Shagshag

Copy link
Copy Markdown
Author

Does this PR also handle all edge cases? E.g. 404 handling, old path redirect, Bases data, Canvas data, etc.

Not sure if this is the place to address this issue.

Hi,
This PR only adds the extension point. The redirects or handle 404s are left to a plugin layered on top (e.g. alias-redirects, or a small plugin I built for the _redirects-file case), since "what should happen to an old URL" is a per-site policy decision, not something the slug-generation hook itself should dictate.

About Bases data, Canvas data, etc. there is an issue. This PR wires ctx.slugify through every call site in quartz/ core, but @quartz-community/utils's own transformInternalLink/slugifyFilePath (used by crawl-links to resolve [[wikilinks]] to hrefs, and potentially by other community plugins that reference file paths. I haven't audited bases-page/canvas-page specifically) are not pluggable and call the stock algorithm internally. So a site adopting a slugify override gets correct page slugs, but any plugin that independently resolves paths via those utils functions would still resolve against the stock slugs :( . I hit this with crawl-links while building this (had to patch its bundled copy directly as a workaround on my own site, outside this PR). Fixing that fully needs a companion change in @quartz-community/utils to make its internal slugification injectable too.

@saberzero1

Copy link
Copy Markdown
Collaborator

It feels like you had an issue with the created slugs and opted to make it a new plugin category. I feel like it should instead be either fixed or made a configuration option.

I'll have to check the scope and cascading issues.

This branch was successfully deployed

1 active deployment
Branch Preview — 18f91157 Deployed Sep 15, 2026 by github-actions[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants