Skip to content

fix: make gitlabPlugin conform to Docusaurus PluginModule - #55

Merged
ebuildy merged 6 commits into
mainfrom
fix/plugin-module-type-conformance
Sep 1, 2026
Merged

ebuildy merged 6 commits into
mainfrom
fix/plugin-module-type-conformance

Conversation

@ebuildy

@ebuildy ebuildy commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Problem

The default export is not assignable to Docusaurus's PluginModule, and its return value is not a valid Plugin. As a result the registration form documented in README.md and used by examples/gitlab — the function form, which is what Docusaurus actually type-checks against PluginConfig — fails to compile in any TypeScript Docusaurus site:

error TS2322: Type '[(context: unknown, options: PluginOptions) => Promise<{...}>, {...}]'
  is not assignable to type 'PluginConfig'.

Two causes

1. mergeStrategy widened to string. mergeStrategy: { "module.rules": "append" } inferred the literal as string, which is not a webpack-merge CustomizeRuleString, so the configureWebpack result violated ConfigureWebpackResult. The runtime value was always correct — only the type was wrong. It went unnoticed because the factory has no return-type annotation, so its shape was inferred and never compared against Plugin. Docusaurus's own plugins annotate Promise<Plugin<Content>> explicitly and would have caught this.

2. options: PluginOptions fails contravariantly against PluginModule's options: unknown. Worth noting this one is not specific to this package — I checked @docusaurus/plugin-sitemap, declared (context: LoadContext, options: PluginOptions), and it fails identically. Widening both parameters holds a stricter line than first-party so the documented form type-checks.

context: unknown was already fine — unknown is a supertype of LoadContext, so it passes contravariantly. The hand-rolled CliLike shim is also structurally compatible with Commander's CommanderStatic.

Why nothing caught it

@docusaurus/types was not a dependency at any level — not dep, devDep, or peer — and it is not even resolvable from examples/gitlab under pnpm. Neither example site had a tsconfig.json, so the const config: Config annotation in docusaurus.config.ts was never checked. pnpm run typecheck structurally could not see the plugin contract.

The fix

Two lines in src/plugin/index.ts, each commented as load-bearing, plus two guards:

  • src/plugin/types.test.ts — asserts assignability to PluginModule, Plugin, and PluginConfig. tsconfig.build.json excludes *.test.ts, so the @docusaurus/types import never reaches dist/. This matters: a .d.ts importing it would fail to resolve for pnpm consumers, who do not get the package hoisted — examples/gitlab has to declare it explicitly, which is the proof.
  • examples/gitlab/tsconfig.json + a typecheck script — the consumer-side guard, checking the real registration against the real Config type. Scoped to docusaurus.config.ts and sidebars.ts: src/theme/* swizzles import @theme-original/*, a webpack alias that only resolves against a generated .docusaurus/ dir, so including it would demand a full site build just to type-check.

Verification

Both guards were written first and confirmed failing, then passing. I also verified they actually bite rather than passing vacuously — reverting only the as const and rebuilding fails both, the example one pointing straight at the documented line:

docusaurus.config.ts(35,14): error TS2322: Type '(context: unknown, rawOptions: unknown) => ...'
  is not assignable to type 'PluginModule<unknown> | DeepPartial<PluginOptions>'.

With the fix in place:

  • typecheck clean (root and examples/gitlab)
  • 678 tests across 59 files pass, including the e2e Docusaurus build in both v3 and future.v4 variants
  • lint 0 errors, markdownlint 0 issues
  • grep -rn "@docusaurus/types" dist/ returns nothing — no devDependency leak into the published package

Note for reviewers

This changes the exported signature to (context: unknown, options: unknown). It is source-compatible for every documented usage — the string form, the function form, and direct calls are all unaffected (widening a parameter is assignment-safe), and the narrowing back to PluginOptions happens on the first line where resolveOptions validates with Joi regardless.

An earlier version of this note claimed callers lose option checking on the second argument. That was wrong — they never had it. Docusaurus's tuple slot is its own PluginOptions = {id?: string} & {[key: string]: unknown}, an index signature that accepts anything, so [[gitlabPlugin, { strcit: true }]] compiled clean before this PR too. An overload preserving the narrow signature was tested and rejected: a typo'd literal silently falls through to the wide overload. What does give consumers real checking is satisfies PluginOptions on the options object in the README snippet and examples/gitlab — worth a follow-up.

Review round

Reviewed from three angles (impact, security, code style). Fixes applied in 3664021:

  • The runtime it() could not catch its own bug. Types are erased, so removing as const left it passing, and it duplicated index.test.ts:69. Replaced the whole file with two satisfies lines — the idiom this repo already uses 8× in gitlab/fetchers.ts. Verified both regressions are still caught independently.
  • The test polluted the repo working tree, creating static/gitlab-assets/ in the root on every pnpm test (no assetDir, so the eager assets.sync() used the relative default). Confirmed by bisection. Dropping the runtime half removed the cause rather than patching it.
  • The consumer-side guard ran nowhereci.yml only ran the root typecheck, whose tsconfig covers just src and test, and mise.toml had no task. Now wired into both; the CI step sits in the test job, which already builds dist/.
  • rawOptions renamed back to options; the parameter name is public API surface visible in dist/plugin/index.d.ts.
  • Documented that ?? {} is load-bearing (it converts a raw TypeError on opts.host into the branded validation error).

Out of scope, raised separately: the security review surfaced a pre-existing HIGH finding unrelated to this PR — plugin options are serialized verbatim into the client bundle via Docusaurus's siteConfig, so a configured token ships to the browser. Independently reproduced. Not addressed here.

The default export was not assignable to `PluginModule`, and its return
value was not a valid `Plugin`, so the registration form documented in
README.md and examples/gitlab — `plugins: [[gitlabPlugin, opts]]` — failed
to type-check in any TypeScript Docusaurus site:

    error TS2322: Type '[(context: unknown, options: PluginOptions) => ...]'
      is not assignable to type 'PluginConfig'.

Two causes:

1. `mergeStrategy: { "module.rules": "append" }` inferred the literal as
   `string`, which is not a webpack-merge `CustomizeRuleString`, so the
   configureWebpack result violated `ConfigureWebpackResult`. The runtime
   value was always correct; only the type was wrong. It went unnoticed
   because the factory has no return-type annotation, so the shape was
   inferred and never compared against `Plugin`.

2. `options: PluginOptions` is narrower than `PluginModule`'s
   `options: unknown` and fails contravariantly. Docusaurus's own plugins
   (e.g. @docusaurus/plugin-sitemap) share this and are likewise not
   assignable; widening both parameters holds a stricter line than
   first-party so the documented function form type-checks.

Neither was detectable before: @docusaurus/types was not a dependency at
any level, and neither example site had a tsconfig.json, so `typecheck`
could not see the plugin contract at all.

Add two guards:

- src/plugin/types.test.ts asserts assignability to `PluginModule`,
  `Plugin`, and `PluginConfig`. tsconfig.build.json excludes *.test.ts, so
  the @docusaurus/types import never reaches dist/ — a .d.ts importing it
  would fail to resolve for pnpm consumers, who do not get the package
  hoisted (examples/gitlab has to declare it explicitly).
- examples/gitlab gains a tsconfig.json and a `typecheck` script, so the
  consumer-side registration is checked against the real `Config` type.
  Scoped to docusaurus.config.ts and sidebars.ts: src/theme/* swizzles
  import `@theme-original/*`, a webpack alias that only resolves against a
  generated .docusaurus/ dir.

Verified both guards fail when the fix is reverted, the example one
pointing at the documented line in docusaurus.config.ts.
Three reviewers (impact, security, code style) converged on the same set of
issues. All verified before and after.

Test pollution (security + style, PR-introduced): types.test.ts called the
factory without an assetDir, so `resolveOptions` defaulted it to the relative
"static/gitlab-assets" and the eager `ctx.assets.sync()` created that directory
in the repo root on every `pnpm test`. .gitignore:23 only covers
examples/*/static/. Confirmed by bisection: the full suite with this file
removed creates nothing; the file alone creates it. Both sibling tests avoid
this deliberately and say so (index.test.ts:15, packaging.test.ts).

The runtime `it()` could not catch the bug it claimed to. Types are erased, so
removing `as const` left the assertion passing, and it duplicated
index.test.ts:69, which asserts the same thing with the webpack-merge rationale.
Dropped it. vitest.config.ts sets passWithNoTests, so a pure type-assertion file
is a clean pass — which removes the assetDir problem entirely rather than
patching it.

Switched to `satisfies`, the idiom this repo already uses (8× in
gitlab/fetchers.ts); `void _x` / `const _x` placeholders had no precedent
anywhere in src/ or test/, and eslint's varsIgnorePattern "^_" made the `void`
statements redundant regardless. The separate `_returnsPlugin` assertion was
subsumed by the module-level one, which already pins the return type. Verified
the two remaining lines still catch both regressions independently: reverting
`as const` and re-narrowing the options parameter each produce 2 errors.

Renamed `rawOptions` back to `options` with a `pluginOptions` local, matching
the `context`/`loadContext` pattern one line below. The parameter name is
public API surface — it shows in dist/plugin/index.d.ts and in consumers'
editor tooltips.

Wired the consumer-side guard into CI and mise. It was documentation before:
ci.yml ran only the root typecheck, whose tsconfig includes just src and test,
and mise.toml had no task. The new CI step goes in the `test` job, which
already builds dist/ — the example's workspace link resolves to it, so the
`lint` job could not run this.

Trimmed the contravariance explanation, which had been written three times in
near-identical words; CLAUDE.md keeps the canonical copy. Documented instead
that `?? {}` is load-bearing: without it `resolveOptions(undefined)` passes
Joi's object schema and dies on `opts.host` with a raw TypeError rather than
the branded "host is required".

Gate: build, typecheck, example typecheck, lint (0 errors), 677 tests across
59 files, no stray static/, and dist/ still free of @docusaurus/types.
@ebuildy
ebuildy merged commit 0cfaef7 into main Sep 1, 2026
6 of 7 checks passed
@ebuildy
ebuildy deleted the fix/plugin-module-type-conformance branch September 1, 2026 16:48
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.

1 participant