Skip to content

[Improvement] Docs: symlink Stimulus controllers to gem source instead of copying - #493

Merged
djalmaaraujo merged 4 commits into
mainfrom
refactor/docs-stimulus-symlinks
Aug 5, 2026
Merged

[Improvement] Docs: symlink Stimulus controllers to gem source instead of copying#493
djalmaaraujo merged 4 commits into
mainfrom
refactor/docs-stimulus-symlinks

Conversation

@djalmaaraujo

@djalmaaraujo djalmaaraujo commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Problem

`docs/app/javascript/controllers/ruby_ui/_controller.js` was a hand-maintained copy of `gem/lib/ruby_ui//_controller.js`, kept in sync by manually copying files. This drifted silently: the accordion Stimulus fix landed in the gem (commit 0897b2a) but the docs copy was never updated, so the live accordion preview at rubyui.com/docs/accordion broke (content never revealed on click). That fix is currently sitting in still-open PR #490.

The Ruby side of this exact problem is already solved — `docs/config/initializers/ruby_ui.rb` autoloads Phlex components straight from `gem/lib/ruby_ui` via Zeitwerk, no copy at all. Only the JS side still duplicated.

Fix

Replace all 37 docs controller files with relative symlinks into the corresponding gem source file, e.g.:

```
docs/app/javascript/controllers/ruby_ui/accordion_controller.js
-> ../../../../../gem/lib/ruby_ui/accordion/accordion_controller.js
```

The gem subdirectory doesn't always match the controller basename (e.g. `checkbox_group_controller.js` lives under `gem/lib/ruby_ui/checkbox/`, `select_item_controller.js` under `gem/lib/ruby_ui/select/`) — the mapping was built from a real `find` over both trees, not guessed.

Files with genuinely stale content pre-migration (diffed docs copy vs. gem source before symlinking):

  • `accordion_controller.js` — missing the hidden-attribute handling (the bug fixed in PR [Bug Fix] Accordion: sync docs Stimulus controller with gem #490)
  • `avatar_controller.js` — missing a couple of defensive checks/comments
  • `combobox_controller.js` — missing minPopoverWidth/placement Stimulus values, hardcoded 'bottom-start' placement
  • `sheet_controller.js` — missing an open Stimulus value + connect() auto-open behavior

The other 33 were already byte-identical.

This PR supersedes #490 for `accordion_controller.js` specifically — the symlink now points at the gem's current (correct) source regardless of whether #490 merges. Not touching or closing #490; leaving its fate to a human.

New dev workflow for adding a component

`rake ruby_ui:sync_controller_symlinks` (new task, `docs/lib/tasks/ruby_ui.rake`) scans `gem/lib/ruby_ui/**/*_controller.js` and creates/repairs any missing symlink. Idempotent — safe to re-run. It replaces the old "copy the file by hand" step. It intentionally does not touch the Stimulus manifest — `bin/rails stimulus:manifest:update` is still a required separate step to register a brand-new controller in `controllers/index.js`.

esbuild wrinkle found along the way

esbuild's default symlink handling resolves a symlinked module's imports relative to its real path. Since `gem/` and `docs/` are sibling directories with no shared ancestor `node_modules`, this broke resolution of `@hotwired/stimulus`, `@floating-ui/dom`, and `motion` for every symlinked controller with an external import. Fixed by adding `--preserve-symlinks` to the `build` script in `docs/package.json`, which makes esbuild resolve relative to the symlink's own location instead.

Docs updated in this PR

  • Root `CLAUDE.md` — routing note on the symlink relationship + new rake task.
  • `gem/AGENTS.md` — JavaScript section notes docs symlinks straight to these files now.
  • `docs/CLAUDE.md` — new short section on the symlinked directory + workflow.
  • `.claude/skills/ruby-ui-stimulus/SKILL.md` — "Register & declare deps" step now mentions the symlink + sync task.

Test plan

Verified in the docs devcontainer (a fresh instance mounted against this branch, ports 3002/host to avoid clashing with any other running instance):

  • All 37 symlinks created; each verified individually with readlink -f resolving to the correct existing gem file (not just trusting the naming pattern).
  • bin/rails stimulus:manifest:update regenerates app/javascript/controllers/index.js byte-identical to before the migration (import paths are relative to app/javascript/controllers, unaffected by symlinks).
  • New rake ruby_ui:sync_controller_symlinks is idempotent on a fully-synced tree, and correctly recreates a symlink I deleted as a test (verified the recreated link's target).
  • pnpm build — failed initially on unresolved @hotwired/stimulus / @floating-ui/dom / motion imports (see esbuild wrinkle above); passes after adding --preserve-symlinks.
  • pnpm build:css — passes.
  • Grepped the built app/assets/builds/application.js for controller-specific code (not just import statements): found content.removeAttribute("hidden") / content.setAttribute("hidden", "") from the gem's fixed accordion controller, and computePosition from the real @floating-ui/dom package — confirms the bundle contains real gem-sourced content, not broken/empty imports.
  • Booted bin/rails server in the container and curl'd it directly: /docs/accordion, /docs/tooltip, /docs/popover, /docs/checkbox, /docs/select all return 200; fetched the served, fingerprinted application-*.js asset over HTTP and re-confirmed the same accordion/floating-ui strings are present in what the server actually serves.
  • bin/rails test (docs) — 75 runs, 107 assertions, 0 failures, 0 errors.
  • bundle exec standardrb (docs) — clean, exit 0.

Not verified: no real browser was available in this environment, so I could not click through the accordion/combobox/tooltip UI interactively. Confidence here rests on the four checks above (manifest diff, bundle content grep, HTTP 200 + asset content over curl, and the existing Rails test suite) rather than a driven browser session — flagging this explicitly rather than overclaiming.


Summary by cubic

Symlinked all docs’ Stimulus controllers to the gem source to eliminate drift and ensure previews always use the latest code. Fixes the broken accordion demo and updates the build/CI to support symlinks reliably.

  • Refactors

    • Replaced 37 files in docs/app/javascript/controllers/ruby_ui/ with symlinks to gem/lib/ruby_ui/<component>/….
    • Updated esbuild build script with --preserve-symlinks so imports from @hotwired/stimulus, @floating-ui/dom, and motion resolve correctly.
    • Added ruby_ui:sync_controller_symlinks task; it creates/repairs symlinks and aborts on duplicate controller basenames to prevent clobbering.
    • Pinned pnpm to node-linker=hoisted in docs/.npmrc so --preserve-symlinks works with pnpm’s layout and all imports resolve.
    • CI now builds docs assets (pnpm build && pnpm build:css) to fail fast on bundling issues.
  • Migration

    • For new components: run bin/rails ruby_ui:sync_controller_symlinks, then bin/rails rake stimulus:manifest:update.
    • No changes needed for existing components; docs now serve the gem controllers directly.

Written for commit 5da4d65. Summary will update on new commits.

Review in cubic

@djalmaaraujo
djalmaaraujo requested a review from cirdes as a code owner July 27, 2026 20:27

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 80 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread docs/lib/tasks/ruby_ui.rake
Comment thread .claude/skills/ruby-ui-stimulus/SKILL.md Outdated

cirdes commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

@djalmaaraujo, please take a look on cubic issues!

djalmaaraujo added a commit that referenced this pull request Jul 31, 2026
Address cubic review on #493:

- `ruby_ui:sync_controller_symlinks` now detects two gem controllers sharing a
  basename before creating any link and aborts listing the offenders. The docs
  controllers directory is flat, so the second symlink would silently clobber
  the first and one component would ship the wrong JS.
- Fix the missing `rake` prefix on `stimulus:manifest:update` in the
  ruby-ui-stimulus skill.
@djalmaaraujo
djalmaaraujo force-pushed the refactor/docs-stimulus-symlinks branch from 4ca0534 to 64d756a Compare July 31, 2026 18:26
@djalmaaraujo

Copy link
Copy Markdown
Contributor Author

@cirdes done — both cubic findings addressed in 64d756a, and the branch is rebased onto current main (the conflict was stale, resolved clean).

  • P2 (docs/lib/tasks/ruby_ui.rake) — valid. The docs controllers directory is flat, so two gem controllers sharing a basename would have the second symlink silently clobber the first. The task now detects collisions up front and aborts listing the offending paths, before touching any symlink. Verified by planting a duplicate tabs_controller.js under a second gem subdirectory.
  • P3 (.claude/skills/ruby-ui-stimulus/SKILL.md) — valid, applied the suggestion (rake stimulus:manifest:update).

Re-verified on a clean checkout (fresh pnpm install, no reused node_modules):

  • pnpm build — passes, 1.1mb bundle; grepped the output for content.removeAttribute("hidden") (gem accordion source) and computePosition (real @floating-ui/dom), both present, so --preserve-symlinks resolves through the symlinks correctly.
  • bin/rails db:test:prepare test (docs) — 75 runs, 107 assertions, 0 failures, 0 errors.
  • bundle exec standardrb (docs) — clean.
  • bundle exec rake (gem) — 281 runs, 1251 assertions, 0 failures; 402 files, no offenses.
  • rake ruby_ui:sync_controller_symlinks — idempotent, all 37 links report ok.

Same caveat as before: still no interactive browser click-through, confidence rests on the bundle-content checks above.

…d of copying

docs/app/javascript/controllers/ruby_ui/*_controller.js was a hand-maintained
copy of gem/lib/ruby_ui/<component>/*_controller.js. The two silently drifted:
the gem's accordion fix (0897b2a) never landed in the docs copy, breaking the
live accordion preview. Replace all 37 copies with relative symlinks into the
gem source, matching how docs already autoloads Phlex components straight
from gem/ (config/initializers/ruby_ui.rb) with no copy step.

- accordion_controller.js, avatar_controller.js, combobox_controller.js, and
  sheet_controller.js had genuinely stale docs content pre-migration (beyond
  just accordion) - the symlink now serves the gem's current, correct version.
- esbuild's default symlink handling resolves module imports relative to the
  symlink's real (gem/) path, which breaks node_modules resolution since gem/
  and docs/ are siblings with no shared ancestor node_modules. Build with
  --preserve-symlinks so imports resolve against the symlink location instead.
- Add `rake ruby_ui:sync_controller_symlinks` (docs/lib/tasks/ruby_ui.rake):
  scans gem/lib/ruby_ui for *_controller.js and creates/repairs the matching
  symlink. Idempotent. Replaces the old "copy the file by hand" step for new
  components; stimulus:manifest:update is still required separately to
  register a brand-new controller in the manifest.
- Update CLAUDE.md, docs/CLAUDE.md, gem/AGENTS.md, and the ruby-ui-stimulus
  skill to describe the symlink relationship and new workflow.

Rebased onto main after PR #490 and PR #495 merged. Both edited a docs
controller copy that this branch replaces with a symlink (accordion, popover),
so both conflicted as modify/delete. Resolved in favour of the symlink: on
main the docs copy and the gem source are byte-identical for each of the two,
so pointing at the gem loses neither fix, and #490's docs-side change becomes
moot now that the file is a symlink.
Address cubic review on #493:

- `ruby_ui:sync_controller_symlinks` now detects two gem controllers sharing a
  basename before creating any link and aborts listing the offenders. The docs
  controllers directory is flat, so the second symlink would silently clobber
  the first and one component would ship the wrong JS.
- Fix the missing `rake` prefix on `stimulus:manifest:update` in the
  ruby-ui-stimulus skill.
`--preserve-symlinks` is what makes the symlinked controllers resolve their
external imports (`@hotwired/stimulus`, `motion`, `maska`, `chart.js`) against
`docs/node_modules` instead of the unreachable `gem/` real path. But it also
stops esbuild from following pnpm's *own* symlinks, so package-internal
requires break on a plain `pnpm install`:

    ✘ [ERROR] Could not resolve "@hotwired/turbo"
        node_modules/@hotwired/turbo-rails/app/javascript/turbo/index.js:3:23
    ✘ [ERROR] Could not resolve "framer-motion/dom"
        node_modules/motion/dist/es/index.mjs:1:14

pnpm keeps those transitive deps inside `.pnpm/<pkg>/node_modules`, reachable
only by following the symlink the flag just disabled.

`node-linker=hoisted` gives a flat `node_modules` of real directories, so the
only symlinks left in the tree are the intentional controller ones and both
halves of resolution work. Verified: `pnpm build` succeeds, the bundle carries
the gem-sourced controller code, and Stimulus is bundled exactly once (no
duplicated copy that would silently fail to register controllers).
The docs job installed dependencies and then went straight to standardrb and
the Rails suite, so `pnpm build` never ran in CI. A bundle that fails to
resolve its imports was invisible — which is how the `--preserve-symlinks` /
pnpm-symlink collision fixed in the previous commit got a green checkmark.

Now that a controller edit in `gem/` is what the docs site actually serves,
esbuild resolution is part of the contract this job should be checking.
@djalmaaraujo
djalmaaraujo force-pushed the refactor/docs-stimulus-symlinks branch from 64d756a to 5da4d65 Compare August 5, 2026 18:11
@djalmaaraujo

Copy link
Copy Markdown
Contributor Author

Rebased onto main (43bc039) and force-pushed. Also fixed a build problem this branch had that CI could not see — details below.

Conflicts

Merge base was ab0e420 (#491). Three conflicts:

File Conflict Resolution
docs/.../accordion_controller.js distinct types (regular file vs symlink) symlink
docs/.../toaster_controller.js distinct types symlink
docs/package.json content both sides: motion@12.43.0 + --preserve-symlinks

Before discarding the two docs copies from main I diffed each against the gem file the symlink points at — byte-identical, so nothing was lost and the symlinks resolve to exactly what main serves today (height = "auto" from #500 and initialize() from #499 are both present in the targets).

Post-rebase state: 37 symlinks / 37 gem controllers, none broken, no regular files left in the directory, every entry mode 120000 in the index, controllers/index.js byte-identical to main, and no ~HEAD conflict artifacts tracked.

What caused the duplication in the first place

The copies came in with #358 (Unify gem and docs into a monorepo), which brought the docs app in carrying its own ~30 controllers. Every component added afterwards replicated the pair: #387 (avatar), #389 (toast + toaster), #386 (command_dialog), #392 (toggle + toggle_group), #447 (message_scroller), #456 (input_otp).

Then there's the class of PRs that exist only because of the duplication — #490 (accordion), #458 (dialog), #378 (header/command) were literally "sync the docs copy with the gem", and #495, #440, #438, #393, #388, #466 all had to edit both sides.

The two that conflicted here, #499 and #500, are the same pattern one more time: each edited both copies plus mcp/data/registry.json. #497 (motion bump) is what touched docs/package.json.

pnpm build was broken on this branch

On a plain pnpm install, before this push:

✘ [ERROR] Could not resolve "@hotwired/turbo"
    node_modules/@hotwired/turbo-rails/app/javascript/turbo/index.js:3:23
✘ [ERROR] Could not resolve "framer-motion/dom"
    node_modules/motion/dist/es/index.mjs:1:14

--preserve-symlinks is genuinely required — without it esbuild resolves a symlinked controller's imports from its real path under gem/, which has no reachable node_modules, and @hotwired/stimulus / motion / maska / chart.js all fail. I reproduced both halves. The catch is that the same flag stops esbuild following pnpm's own symlinks, and pnpm keeps transitive deps inside .pnpm/<pkg>/node_modules — reachable only through the symlink the flag just disabled. The two requirements cancel out.

Two commits on top:

  • 3b84960docs/.npmrc with node-linker=hoisted, so node_modules is a flat tree of real directories and the only symlinks left are the intentional controller ones. Both halves of resolution then work. docs/CLAUDE.md gained a section explaining why the flag and the .npmrc are a matched pair, since changing either alone breaks the build.
  • 5da4d65 — the docs CI job now runs pnpm build && pnpm build:css. It previously went pnpm installstandardrbrails test and never built, which is exactly how a bundle that could not resolve its imports collected a green checkmark. Now that a controller edit in gem/ is what the docs site serves, esbuild resolution belongs in this job.

Verified after the fix: both build commands pass, the bundle contains the gem-sourced controller code (maska, chart.js, computePosition, toaster, accordion), and Stimulus is bundled exactly once — I checked for TargetObserver2 / Application2 duplicates specifically, because two copies of Stimulus would leave controllers silently unregistered.

Worth noting for whoever reviews: node-linker=hoisted trades pnpm's strict isolation for a flat tree, so a phantom dependency (importing something that is only a transitive dep) would no longer be caught at install time. That is the cost of keeping --preserve-symlinks. The alternative is dropping the symlinks and generating the copies with a CI drift check instead — happy to go that way if the isolation matters more than the single source of truth.

@djalmaaraujo
djalmaaraujo merged commit 7275167 into main Aug 5, 2026
8 checks passed
@djalmaaraujo
djalmaaraujo deleted the refactor/docs-stimulus-symlinks branch August 5, 2026 18:24
@djalmaaraujo djalmaaraujo mentioned this pull request Aug 5, 2026
djalmaaraujo added a commit that referenced this pull request Aug 5, 2026
Bump RubyUI to 1.6.0 (minor: new component + new component options since v1.5.0).

- gem/lib/ruby_ui.rb → 1.6.0; regenerate gem/ and docs/ Gemfile.lock
- docs home hero badge → headline features (InputOtp, Combobox placement)
- rebuild mcp/data/registry.json

Highlights since v1.5.0:
- New component: InputOtp (#456)
- Combobox: configurable popover placement (#480), CheckboxGroup reuse for required ComboboxCheckbox (#479)
- DataTable: custom label + initial column visibility in DataTableColumnToggle (#466)
- Popover: data-state/data-side, clear closeTimeout on disconnect, close on Escape (#495)
- Accordion: no longer clips content that grows after opening (#500, #490)
- Toast: toaster state initialized in initialize() so server-rendered toasts don't throw (#499)
- Docs: Stimulus controllers symlinked to gem source, no more hand-copied drift (#493)
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.

2 participants