Skip to content

Remove svelte and use vanilla TS#3352

Open
RobbieTheWagner wants to merge 1 commit intomainfrom
no-frameworks
Open

Remove svelte and use vanilla TS#3352
RobbieTheWagner wants to merge 1 commit intomainfrom
no-frameworks

Conversation

@RobbieTheWagner
Copy link
Member

@RobbieTheWagner RobbieTheWagner commented Feb 7, 2026

Summary by CodeRabbit

Release Notes

  • Refactor

    • Removed Svelte framework dependency from the project
  • New Features

    • Added CommonJS build output for broader compatibility alongside existing ESM build
    • Introduced comprehensive CSS styling for all Shepherd components
  • Chores

    • Updated build configuration and package exports

@vercel
Copy link

vercel bot commented Feb 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
shepherd-docs Ready Ready Preview, Comment Feb 7, 2026 7:19pm
shepherd-landing Ready Ready Preview, Comment Feb 7, 2026 7:19pm

Request Review

@coderabbitai
Copy link

coderabbitai bot commented Feb 7, 2026

📝 Walkthrough

Walkthrough

This PR migrates Shepherd.js from Svelte components to vanilla TypeScript/JavaScript by removing all Svelte dependencies, converting component .svelte files into factory functions returning DOM elements, updating build configuration for CommonJS support, extracting CSS to a dedicated stylesheet, and rewriting tests to use the new factory-based API.

Changes

Cohort / File(s) Summary
Dependency & Config Cleanup
package.json, shepherd.js/eslint.config.js, shepherd.js/svelte.config.js, shepherd.js/vitest.config.ts
Removed Svelte-related devDependencies (eslint-plugin-svelte, prettier-plugin-svelte, svelte), adjusted lint globs from **/*.{js,svelte,ts} to **/*.{js,ts}, removed Svelte ESLint plugin configuration, deleted svelte.config.js, and removed Svelte plugin from Vitest config and test file patterns.
Build Configuration
shepherd.js/rollup.config.mjs
Removed Svelte and svelte preprocessing plugins, simplified module resolution extensions, tightened Babel config, introduced shared build config, and added CJS build target outputting to dist/cjs/ alongside ESM build, with corresponding post-build TS declaration handling.
Package.json Exports & Main Entry
shepherd.js/package.json
Updated main entry point from ./dist/js/shepherd.mjs to ./dist/cjs/shepherd.cjs, added CommonJS export mapping with types and default exports, and maintains existing ESM export mappings.
Component Migration: Factory Functions
shepherd.js/src/components/shepherd-*.ts (shepherd-button, shepherd-cancel-icon, shepherd-content, shepherd-element, shepherd-footer, shepherd-header, shepherd-modal, shepherd-text, shepherd-title)
Replaced Svelte components with imperative TypeScript factory functions (e.g., createShepherdButton, createShepherdElement) that construct and return DOM elements with event handlers, attributes, and conditional rendering logic previously handled reactively.
Component Removal: Svelte Files
shepherd.js/src/components/shepherd-*.svelte (8 files deleted)
Deleted all Svelte component implementations including shepherd-button, shepherd-cancel-icon, shepherd-content, shepherd-element, shepherd-footer, shepherd-header, shepherd-modal, shepherd-text, and shepherd-title.
Styling Extraction
shepherd.js/src/components/shepherd.css
Added comprehensive CSS stylesheet covering button, cancel icon, content, header, footer, text, title, element, arrow, modal, and visibility/transition styles previously embedded in Svelte components.
Core Integration Updates
shepherd.js/src/shepherd.ts, shepherd.js/src/step.ts, shepherd.js/src/tour.ts
Updated to use new factory functions (createShepherdElement, createShepherdModal) instead of Svelte legacy components; replaced component lifecycle with result objects containing cleanup methods; adjusted element access from getElement() to property access; imported CSS stylesheet.
DOM Utility Helpers
shepherd.js/src/utils/dom.ts
Added new module with h() and svgEl() helper functions for creating HTML/SVG elements with attributes, event listeners, and child elements.
Floating UI Integration
shepherd.js/src/utils/floating-ui.ts
Updated to safely access element via optional chaining (shepherdElementComponent?.element) instead of direct assertion when applying centered class.
Test Files: Component Tests
shepherd.js/test/unit/components/shepherd-*.spec.js (9 test files)
Rewrote all component tests to use new factory functions instead of Svelte Testing Library render(); introduced manual DOM container management with beforeEach/afterEach lifecycle; replaced component queries with DOM element queries.
Test Infrastructure
shepherd.js/test/unit/setupTests.js, shepherd.js/test/unit/test-helpers.js
Removed Svelte mounting shim (vi.doMock('svelte')) and deleted the renderComponent() test helper function that previously managed Svelte component mounting/unmounting.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • pnpm update #3324: Modifies Svelte-related devDependencies in package.json; this PR removes those same dependencies entirely, making it related through direct dependency handling changes.

Poem

🐰 Farewell, dear Svelte, your reactivity was grand,
But vanilla JavaScript now rules this land,
Factory functions dance where components once reigned,
CommonJS and ESM harmoniously sustained,
Pure DOM elements sprout from TypeScript's hand!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Remove svelte and use vanilla TS' directly and clearly summarizes the primary objective of this changeset: migrating from Svelte components to vanilla TypeScript implementations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch no-frameworks

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🤖 Fix all issues with AI agents
In `@shepherd.js/rollup.config.mjs`:
- Around line 152-191: The CJS build's plugin named "After CJS build tweaks"
relies on an ESM artifact (it copies ./dist/js/shepherd.d.mts to
./dist/cjs/shepherd.d.cts) inside its closeBundle hook, which breaks if builds
run in parallel; modify the closeBundle in that plugin to first check for the
existence of ./dist/js/shepherd.d.mts (and retry/wait or throw a clear error)
before attempting the cp/mv commands, or explicitly document the ordering
dependency with a comment referencing the plugin name and the closeBundle hook
so future maintainers know the CJS step depends on the ESM output; ensure any
failure logs a clear message including the missing path.
- Around line 70-78: The treeshake setting in sharedConfig currently sets
moduleSideEffects: false which causes Rollup to drop side-effect-only CSS
imports like the bare import './components/shepherd.css' in shepherd.ts; change
moduleSideEffects to a function allowlist that returns true for CSS imports
(e.g., check id.endsWith('.css') or match './components/shepherd.css') so Rollup
preserves those side-effectful CSS modules while keeping false for other
modules—update the treeshake.moduleSideEffects property in the sharedConfig
object accordingly.

In `@shepherd.js/src/components/shepherd-modal.ts`:
- Around line 252-258: The code reads scrollTop/scrollLeft from the DOMRect
returned by getBoundingClientRect (dead code); update the offset calculation in
the targetIframe block to use the iframe element's scroll offsets instead of the
rect's properties — e.g., call getBoundingClientRect() to get rect.top/left and
then add targetIframe.scrollTop and targetIframe.scrollLeft (or
targetIframe.contentWindow?.scrollY/scrollX if you intend document scroll inside
the iframe) when computing offset.top/offset.left, and remove the bogus DOMRect
cast and ?? 0 fallback.
- Around line 104-113: The current loop over elementsToHighlight incorrectly
skips all occurrences because it checks indexOf(el) !== lastIndexOf(el); change
the logic to keep the first occurrence and skip later duplicates by comparing
the current index to the first index (e.g., only continue when
elementsToHighlight.indexOf(el) !== currentIndex), or better yet deduplicate
elementsToHighlight before the loop (e.g., create a filtered array via a Set or
array.filter((el,i,a)=>a.indexOf(el)===i)) and then iterate that deduplicated
array; update the code around the loop that references elementsToHighlight to
use the deduplicated list so at least one opening is created per unique element.

In `@shepherd.js/src/components/shepherd-text.ts`:
- Around line 20-24: The current branch sets el.innerHTML = text without
guarding against non-string values, which will render "undefined" or "null" if
text is missing; update the branch that handles non-HTMLElement text (the else
after isHTMLElement(text)) to only assign innerHTML when text is a string (e.g.,
check typeof text === 'string') and otherwise set el.innerHTML to an empty
string (or another safe default), keeping the isHTMLElement, el.appendChild,
el.innerHTML and text identifiers to locate the change.

In `@shepherd.js/src/step.ts`:
- Around line 474-483: updateStepOptions currently calls destroy(), which emits
the public "destroy" event and misleads consumers; instead implement a private
teardown method (e.g., _teardownInternal or teardownWithoutEmit) that performs
the same DOM/component cleanup that destroy() does but does not emit the
"destroy" event, and call that from updateStepOptions before _setupElements();
keep the existing public destroy() unchanged (it should still emit the event)
and ensure _setupElements() continues to call bindAdvance() or equivalent to
reattach handlers after the internal teardown.

In `@shepherd.js/test/unit/components/shepherd-button.spec.js`:
- Around line 16-117: The label and text test cases are incorrectly nested
inside the describe('disabled', ...) block; extract the label tests (those using
{ label: ... } and references to aria-label) and the text tests (those using {
text: ... } and toHaveTextContent) into their own top-level describe blocks
(e.g., describe('label', ...) and describe('text', ...)) placed at the same
level as describe('disabled', ...); locate the tests by the createShepherdButton
calls that pass label or text and move each group so describe('disabled', ... )
only contains disabled-related it() cases while label- and text-related it()
cases are grouped under their respective describe blocks.
🧹 Nitpick comments (12)
shepherd.js/package.json (1)

21-34: CJS + ESM dual export setup looks correct.

The conditional exports are properly structured, and .cjs extension ensures CommonJS semantics regardless of the "type": "module" field. Good that @arethetypeswrong/cli is in devDependencies and the types:check:attw script validates the package exports.

One minor note: there's no top-level "types" field alongside "main". Tools that don't understand the "exports" map (e.g., TypeScript < 4.7 with older moduleResolution settings) won't resolve type declarations for the CJS entry. If you want to support those consumers, consider adding:

"types": "./dist/cjs/shepherd.d.cts",

Not critical given modern tooling and the breaking-change nature of this PR.

shepherd.js/src/utils/dom.ts (1)

40-44: Consider using a more explicit event listener prefix like on- or on:.

The on prefix check (key.startsWith('on')) could inadvertently match non-event attributes (e.g., hypothetical oneTimeUse). The AI summary mentions on- as the convention, but the implementation uses on (camelCase style like onClick). This works fine for the current internal usage but is worth noting if this utility is extended.

shepherd.js/src/components/shepherd-title.ts (1)

14-15: Consider textContent instead of innerHTML for the title.

Since StringOrStringFunction resolves to a plain string, textContent would be safer and sufficient here unless HTML-in-titles is an intentional feature. If HTML support is needed, a brief doc comment clarifying that would help.

Proposed change
   const resolvedTitle = isFunction(title) ? title() : title;
-  el.innerHTML = resolvedTitle;
+  el.textContent = resolvedTitle;
shepherd.js/src/components/shepherd-button.ts (2)

34-36: innerHTML with user-supplied button text — XSS surface by design.

The StepOptionsButton.text JSDoc says "The HTML text of the button," so this is intentional. However, since text can come from a dynamic function (getConfigOption), any user who passes unsanitized input through the config is exposed. Consider documenting this trust boundary or offering a textContent path for plain-text labels.


23-32: Class string can contain leading/trailing whitespace and double spaces.

When config.classes is undefined, the template literal produces a leading space (e.g., " shepherd-button "). Similarly, when secondary is false, there's a trailing space. While not functionally broken, classList and selector matching can be subtly affected by stray whitespace in some environments.

♻️ Suggested cleanup
-  const btn = h('button', {
-    'aria-label': label || null,
-    class: `${config.classes || ''} shepherd-button ${
-      config.secondary ? 'shepherd-button-secondary' : ''
-    }`,
+  const classes = [
+    config.classes,
+    'shepherd-button',
+    config.secondary ? 'shepherd-button-secondary' : null
+  ].filter(Boolean).join(' ');
+
+  const btn = h('button', {
+    'aria-label': label || null,
+    class: classes,
shepherd.js/test/unit/components/shepherd-content.spec.js (1)

1-82: Tests are correct, but coverage is limited to header rendering.

The header conditional logic is well-tested across all four scenarios. However, the test suite only covers the header portion of createShepherdContent. There are no tests for text rendering (step.options.text) or footer rendering (step.options.buttons).

shepherd.js/src/step.ts (1)

686-688: Duplicate this.el.hidden = false assignment.

Lines 673-674 and 686-688 both set this.el.hidden = false. This appears to be a pre-existing duplication (both are unchanged lines), but since this file is being reworked it's a good time to clean it up.

shepherd.js/test/unit/components/shepherd-modal.spec.js (1)

432-441: Skipped setupForStep tests reduce coverage.

The comment explains the spy limitation, but setupForStep is a key integration point (it wires up per-step modal behavior). Consider testing the observable outcome instead — e.g., after calling setupForStep(step) with a step configured for modal overlay, assert that the modal element has the shepherd-modal-is-visible class and that the path d attribute reflects the target element's position.

shepherd.js/test/unit/components/shepherd-button.spec.js (1)

18-18: Passing undefined as step is fragile and masks potential NPEs.

Every call uses createShepherdButton({...}, undefined), but the factory signature expects a Step instance (e.g., config.action.bind(step.tour) would throw if action were set). Consider creating a minimal mock/stub Step to avoid silent breakage if the factory internals ever touch step for the options being tested (e.g., getConfigOption receives step).

shepherd.js/test/unit/components/shepherd-element.spec.js (1)

6-8: keyCode is deprecated in favor of key or code.

Both the helper and the production code (shepherd-element.ts lines 50, 93, 100, 107) use the deprecated KeyboardEvent.keyCode. This is fine for now since it matches, but worth noting as a follow-up to migrate both to event.key (e.g., 'Escape', 'ArrowLeft', 'ArrowRight', 'Tab').

shepherd.js/src/components/shepherd-element.ts (1)

48-117: keyCode is deprecated — consider migrating to event.key.

KeyboardEvent.keyCode is deprecated in modern browsers. Using event.key ('Tab', 'Escape', 'ArrowLeft', 'ArrowRight') would be more future-proof and readable. Not urgent for this PR since it's a straight port from the Svelte version.

shepherd.js/src/components/shepherd-modal.ts (1)

46-294: No public destroy/dispose method to remove the SVG element and the touchmove listener.

The factory appends the SVG to container (line 66) and registers a touchmove listener on it (line 61), but the returned API has no way to tear these down. If the modal is recreated (e.g., on re-init), the old SVG and listener would leak. Consider adding a destroy() method that removes the element and its listeners.

Comment on lines +70 to +78
const sharedConfig = {
input: 'src/shepherd.ts',
// More aggressive tree shaking
treeshake: {
moduleSideEffects: false,
propertyReadSideEffects: false,
unknownGlobalSideEffects: false
}
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check if shepherd.ts has CSS imports
rg -n "import.*\.css" --type=ts src/

Repository: shipshapecode/shepherd

Length of output: 110


🏁 Script executed:

# Also look at the rollup config to understand the postcss setup
cat -n rollup.config.mjs | grep -A 30 "postcss"

Repository: shipshapecode/shepherd

Length of output: 115


🏁 Script executed:

# Check if there are any CSS files in the src directory
fd -e css src/

Repository: shipshapecode/shepherd

Length of output: 48


🏁 Script executed:

git ls-files | head -30

Repository: shipshapecode/shepherd

Length of output: 646


🏁 Script executed:

find . -name "rollup.config.mjs" -o -name "shepherd.ts" -o -name "*.css" | grep -v node_modules | head -20

Repository: shipshapecode/shepherd

Length of output: 642


🏁 Script executed:

cat -n shepherd.js/src/shepherd.ts | head -50

Repository: shipshapecode/shepherd

Length of output: 772


🏁 Script executed:

cat -n shepherd.js/rollup.config.mjs | sed -n '1,100p'

Repository: shipshapecode/shepherd

Length of output: 3321


🏁 Script executed:

# Search for CSS imports in shepherd.ts
rg "import.*\.css" shepherd.js/src/

Repository: shipshapecode/shepherd

Length of output: 129


🏁 Script executed:

# Check if there's a dist or build directory showing successful CSS extraction
find . -path "./shepherd.js/dist*" -name "*.css" -type f 2>/dev/null | head -10

Repository: shipshapecode/shepherd

Length of output: 48


🏁 Script executed:

# Check postcss plugin documentation or behavior by looking at package.json versions
cat shepherd.js/package.json | grep -A 5 -B 5 "postcss"

Repository: shipshapecode/shepherd

Length of output: 735


🏁 Script executed:

# Search for any tests or configs that might indicate if CSS extraction is working
rg "shepherd\.css" shepherd.js/ --type=json --type=js --type=mjs | head -20

Repository: shipshapecode/shepherd

Length of output: 93


🏁 Script executed:

# Check for any CI/test output or build artifacts that show if CSS is actually being extracted
cat shepherd.js/.github/workflows/test.yml | head -50

Repository: shipshapecode/shepherd

Length of output: 136


🏁 Script executed:

# Look for any GitHub issue discussions about CSS not being extracted
rg "CSS" shepherd.js/ --type=md | grep -i "extract\|tree"

Repository: shipshapecode/shepherd

Length of output: 48


🏁 Script executed:

# Check the actual postcss plugin documentation behavior with moduleSideEffects
web_search
What does rollup-plugin-postcss do with moduleSideEffects false treeshaking

Repository: shipshapecode/shepherd

Length of output: 159


🌐 Web query:

rollup-plugin-postcss v4.0.2 moduleSideEffects tree shaking CSS imports

💡 Result:

In rollup-plugin-postcss v4.0.2, a CSS import like:

import "./style.css";

is turned into a JS module that performs runtime CSS injection via style-inject (the plugin resolves style-inject/dist/style-inject.es)—i.e., it’s meant to be a side-effect import. [3]

However, v4.0.2 does not explicitly set Rollup’s moduleSideEffects flag for those generated modules, so if you enable aggressive tree-shaking (notably treeshake.moduleSideEffects: false, or presets that imply it), Rollup may drop “empty” imports whose exports aren’t used—this includes side-effect-only CSS imports. Rollup documents that treeshake.moduleSideEffects: false can suppress such imports. [1] Rollup also documents that plugins can prevent this by returning moduleSideEffects: true or "no-treeshake" from transform/load. [2]

What to do

  • Don’t set treeshake.moduleSideEffects: false (leave it at the default true) if you rely on import "./x.css" being kept. [1]
  • Or use a function allowlist so CSS modules are always treated as having side effects, e.g.:
    • return true for ids ending in .css (and any preprocessor extensions you use), and otherwise defer to your usual rule. [1]
  • If you’re authoring/forking the plugin: have the plugin’s transform() return moduleSideEffects: true / "no-treeshake" for CSS modules to make this robust under aggressive treeshaking. [2]

Sources: Rollup treeshake.moduleSideEffects behavior [1], Rollup plugin moduleSideEffects hook return value [2], rollup-plugin-postcss v4.0.2 uses style-inject for injection [3].


🏁 Script executed:

# Find the actual workflow files
find . -name "*.yml" -path "*/.github/workflows/*" | head -10

Repository: shipshapecode/shepherd

Length of output: 163


🏁 Script executed:

# Check if there are any build artifacts or test results that show CSS being generated
ls -la shepherd.js/dist/ 2>/dev/null || echo "dist/ not found"

Repository: shipshapecode/shepherd

Length of output: 81


🏁 Script executed:

# Check the README or docs for build instructions
cat README.md | grep -A 20 "build"

Repository: shipshapecode/shepherd

Length of output: 1882


moduleSideEffects: false will tree-shake side-effect-only CSS imports.

shepherd.ts contains a bare import './components/shepherd.css' (line 4), which is a side-effect-only import. With treeshake.moduleSideEffects: false, Rollup will drop this import because rollup-plugin-postcss v4.0.2 does not explicitly mark generated CSS modules as having side effects. This will result in missing styles in the final bundle.

Fix by using a function allowlist to preserve CSS imports:

Suggested fix
  treeshake: {
-   moduleSideEffects: false,
+   moduleSideEffects(id) {
+     return id.endsWith('.css');
+   },
    propertyReadSideEffects: false,
    unknownGlobalSideEffects: false
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const sharedConfig = {
input: 'src/shepherd.ts',
// More aggressive tree shaking
treeshake: {
moduleSideEffects: false,
propertyReadSideEffects: false,
unknownGlobalSideEffects: false
}
};
const sharedConfig = {
input: 'src/shepherd.ts',
// More aggressive tree shaking
treeshake: {
moduleSideEffects(id) {
return id.endsWith('.css');
},
propertyReadSideEffects: false,
unknownGlobalSideEffects: false
}
};
🤖 Prompt for AI Agents
In `@shepherd.js/rollup.config.mjs` around lines 70 - 78, The treeshake setting in
sharedConfig currently sets moduleSideEffects: false which causes Rollup to drop
side-effect-only CSS imports like the bare import './components/shepherd.css' in
shepherd.ts; change moduleSideEffects to a function allowlist that returns true
for CSS imports (e.g., check id.endsWith('.css') or match
'./components/shepherd.css') so Rollup preserves those side-effectful CSS
modules while keeping false for other modules—update the
treeshake.moduleSideEffects property in the sharedConfig object accordingly.

Comment on lines +152 to 191
// CJS build
{
...sharedConfig,
output: {
dir: 'tmp/cjs',
entryFileNames: '[name].cjs',
format: 'cjs',
sourcemap: true,
exports: 'named'
},
plugins: [
...plugins,
{
name: 'After CJS build tweaks',
closeBundle: async () => {
await execaCommand(`mkdir -p ./dist/cjs`, {
stdio: 'inherit'
});

console.log('Move shepherd.cjs from tmp to dist');

await execaCommand(
`mv ./tmp/cjs/shepherd.cjs ./tmp/cjs/shepherd.cjs.map ./dist/cjs/`,
{
stdio: 'inherit'
}
);

console.log('Copy CJS declarations');

await execaCommand(
`cp ./dist/js/shepherd.d.mts ./dist/cjs/shepherd.d.cts`,
{
stdio: 'inherit'
}
);
}
}
]
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

CJS closeBundle depends on ESM build artifacts — fragile ordering assumption.

Line 183 copies ./dist/js/shepherd.d.mts (produced by the ESM closeBundle) to ./dist/cjs/shepherd.d.cts. This only works because Rollup processes array entries sequentially. If the build is ever parallelized (e.g., via --parallel or a different build orchestrator), this will fail silently or error out.

Consider adding a guard or a comment documenting the dependency.

🤖 Prompt for AI Agents
In `@shepherd.js/rollup.config.mjs` around lines 152 - 191, The CJS build's plugin
named "After CJS build tweaks" relies on an ESM artifact (it copies
./dist/js/shepherd.d.mts to ./dist/cjs/shepherd.d.cts) inside its closeBundle
hook, which breaks if builds run in parallel; modify the closeBundle in that
plugin to first check for the existence of ./dist/js/shepherd.d.mts (and
retry/wait or throw a clear error) before attempting the cp/mv commands, or
explicitly document the ordering dependency with a comment referencing the
plugin name and the closeBundle hook so future maintainers know the CJS step
depends on the ESM output; ensure any failure logs a clear message including the
missing path.

Comment on lines +104 to +113
for (const el of elementsToHighlight) {
if (!el) continue;

// Skip duplicate elements
if (
elementsToHighlight.indexOf(el) !==
elementsToHighlight.lastIndexOf(el)
) {
continue;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Duplicate-element deduplication removes ALL occurrences instead of keeping one.

When the same element appears more than once in elementsToHighlight, indexOf(el) !== lastIndexOf(el) is true for every occurrence — so every copy is skipped and no opening is created for that element at all.

Proposed fix — skip only subsequent duplicates
       for (const el of elementsToHighlight) {
         if (!el) continue;
 
-        // Skip duplicate elements
-        if (
-          elementsToHighlight.indexOf(el) !==
-          elementsToHighlight.lastIndexOf(el)
-        ) {
-          continue;
-        }
+        // Skip duplicate elements (keep only the first occurrence)
+        if (elementsToHighlight.indexOf(el) !== elementsToHighlight.lastIndexOf(el) &&
+            elementsToHighlight.indexOf(el) !== elementsToHighlight.indexOf(el, 0)) {
+          continue;
+        }

Or more simply, deduplicate the array upfront:

       const elementsToHighlight = [
         targetElement,
         ...(extraHighlights || [])
       ];
+      const uniqueElements = [...new Set(elementsToHighlight)];
       const newOpenings: OpeningProperty[] = [];
 
-      for (const el of elementsToHighlight) {
+      for (const el of uniqueElements) {
         if (!el) continue;
-
-        // Skip duplicate elements
-        if (
-          elementsToHighlight.indexOf(el) !==
-          elementsToHighlight.lastIndexOf(el)
-        ) {
-          continue;
-        }
🤖 Prompt for AI Agents
In `@shepherd.js/src/components/shepherd-modal.ts` around lines 104 - 113, The
current loop over elementsToHighlight incorrectly skips all occurrences because
it checks indexOf(el) !== lastIndexOf(el); change the logic to keep the first
occurrence and skip later duplicates by comparing the current index to the first
index (e.g., only continue when elementsToHighlight.indexOf(el) !==
currentIndex), or better yet deduplicate elementsToHighlight before the loop
(e.g., create a filtered array via a Set or
array.filter((el,i,a)=>a.indexOf(el)===i)) and then iterate that deduplicated
array; update the code around the loop that references elementsToHighlight to
use the deduplicated list so at least one opening is created per unique element.

Comment on lines +252 to +258
if (targetIframe) {
const rect = targetIframe.getBoundingClientRect();
offset.top +=
rect.top + ((rect as unknown as Record<string, number>)['scrollTop'] ?? 0);
offset.left +=
rect.left + ((rect as unknown as Record<string, number>)['scrollLeft'] ?? 0);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

scrollTop/scrollLeft on DOMRect is always undefined — dead code.

getBoundingClientRect() returns a DOMRect which has no scrollTop or scrollLeft properties, so the ?? 0 fallback always applies. If the intent was to account for scroll offset of the iframe element, you'd need to read it from the element itself (e.g., targetIframe.scrollTop), not from its bounding rect.

🤖 Prompt for AI Agents
In `@shepherd.js/src/components/shepherd-modal.ts` around lines 252 - 258, The
code reads scrollTop/scrollLeft from the DOMRect returned by
getBoundingClientRect (dead code); update the offset calculation in the
targetIframe block to use the iframe element's scroll offsets instead of the
rect's properties — e.g., call getBoundingClientRect() to get rect.top/left and
then add targetIframe.scrollTop and targetIframe.scrollLeft (or
targetIframe.contentWindow?.scrollY/scrollX if you intend document scroll inside
the iframe) when computing offset.top/offset.left, and remove the bogus DOMRect
cast and ?? 0 fallback.

Comment on lines +20 to +24
if (isHTMLElement(text)) {
el.appendChild(text);
} else {
el.innerHTML = text as string;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

innerHTML usage is intentional but warrants a defensive guard for non-string values.

The static analysis XSS warning is acknowledged — this is developer-supplied config, not end-user input, and the library has historically supported HTML content in step text. However, if text is undefined or null after the function check (e.g., the function returned undefined), this will render the literal string "undefined" or "null".

Proposed defensive guard
   if (isHTMLElement(text)) {
     el.appendChild(text);
-  } else {
+  } else if (text) {
     el.innerHTML = text as string;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (isHTMLElement(text)) {
el.appendChild(text);
} else {
el.innerHTML = text as string;
}
if (isHTMLElement(text)) {
el.appendChild(text);
} else if (text) {
el.innerHTML = text as string;
}
🧰 Tools
🪛 ast-grep (0.40.5)

[warning] 22-22: Direct HTML content assignment detected. Modifying innerHTML, outerHTML, or using document.write with unsanitized content can lead to XSS vulnerabilities. Use secure alternatives like textContent or sanitize HTML with libraries like DOMPurify.
Context: el.innerHTML = text as string
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://www.dhairyashah.dev/posts/why-innerhtml-is-a-bad-idea-and-how-to-avoid-it/
- https://cwe.mitre.org/data/definitions/79.html

(unsafe-html-content-assignment)


[warning] 22-22: Direct modification of innerHTML or outerHTML properties detected. Modifying these properties with unsanitized user input can lead to XSS vulnerabilities. Use safe alternatives or sanitize content first.
Context: el.innerHTML = text as string
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://owasp.org/www-community/xss-filter-evasion-cheatsheet
- https://cwe.mitre.org/data/definitions/79.html

(dom-content-modification)

🤖 Prompt for AI Agents
In `@shepherd.js/src/components/shepherd-text.ts` around lines 20 - 24, The
current branch sets el.innerHTML = text without guarding against non-string
values, which will render "undefined" or "null" if text is missing; update the
branch that handles non-HTMLElement text (the else after isHTMLElement(text)) to
only assign innerHTML when text is a string (e.g., check typeof text ===
'string') and otherwise set el.innerHTML to an empty string (or another safe
default), keeping the isHTMLElement, el.appendChild, el.innerHTML and text
identifiers to locate the change.

Comment on lines 474 to 483
updateStepOptions(options: StepOptions) {
Object.assign(this.options, options);

// @ts-expect-error TODO: get types for Svelte components
if (this.shepherdElementComponent) {
// @ts-expect-error TODO: get types for Svelte components
this.shepherdElementComponent.$set({ step: this });
// Recreate the element with updated options
if (this.el) {
this.destroy();
this._setupElements();
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's verify the destroy() method emits the 'destroy' event
rg -n "trigger\('destroy'\)|trigger\(\"destroy\"\)" -A2 -B2 src/

Repository: shipshapecode/shepherd

Length of output: 110


🏁 Script executed:

# Check for listeners on the 'destroy' event
rg -n "\.on\('destroy'|\.on\(\"destroy\"|'destroy':|\"destroy\":" src/ --type ts --type js -C2

Repository: shipshapecode/shepherd

Length of output: 110


🏁 Script executed:

# Verify the updateStepOptions implementation
rg -n "updateStepOptions" src/ -A10 --type ts

Repository: shipshapecode/shepherd

Length of output: 110


🏁 Script executed:

git ls-files | head -20

Repository: shipshapecode/shepherd

Length of output: 408


🏁 Script executed:

fd -t f "step\.ts" .

Repository: shipshapecode/shepherd

Length of output: 91


🏁 Script executed:

# Verify the destroy() method emits 'destroy' event
rg -n "trigger\('destroy'\)|trigger\(\"destroy\"\)" shepherd.js/src/ -A2 -B2 --type ts

Repository: shipshapecode/shepherd

Length of output: 278


🏁 Script executed:

# Check for listeners on the 'destroy' event
rg -n "\.on\('destroy'|\.on\(\"destroy\"|addEventListener.*destroy|'destroy':" shepherd.js/ -C2 --type ts --type js

Repository: shipshapecode/shepherd

Length of output: 1106


🏁 Script executed:

# Verify the updateStepOptions implementation
rg -n "updateStepOptions" shepherd.js/src/step.ts -A15 --type ts

Repository: shipshapecode/shepherd

Length of output: 585


🏁 Script executed:

# Check _setupElements() implementation
rg -n "_setupElements" shepherd.js/src/step.ts -A20 --type ts

Repository: shipshapecode/shepherd

Length of output: 1776


🏁 Script executed:

# Check if there are tests for updateStepOptions
rg -n "updateStepOptions" shepherd.js/test/ -A10 -B5 --type js

Repository: shipshapecode/shepherd

Length of output: 6625


🏁 Script executed:

# Check the bind.ts implementation to understand event handler cleanup
cat -n shepherd.js/src/utils/bind.ts

Repository: shipshapecode/shepherd

Length of output: 2262


updateStepOptions triggers a spurious destroy event that breaks the semantic contract of the event.

When updateStepOptions() calls destroy() at line 480, it emits a destroy event (line 390) that signals permanent step destruction. However, this is an internal update operation, not actual destruction. Consumers listening for destroy events (e.g., bind.ts lines 50, 55 for event handler cleanup, or custom listeners for analytics) receive false signals and cannot distinguish between an actual destruction and an intermediate update step.

While event handlers are functionally re-attached via bindAdvance() in the subsequent _setupElements() call, the semantic contract is broken. This represents a behavioral change from the previous Svelte $set approach that updated the component in-place without any destroy/recreate cycle.

Consider extracting the teardown logic into a private method that doesn't emit the destroy event, or suppress the event emission during option updates.

Proposed fix: extract internal teardown without event emission
+ /**
+  * Tears down the element and tooltip without emitting 'destroy'.
+  * `@private`
+  */
+ _teardownElements() {
+   destroyTooltip(this);
+
+   if (this.shepherdElementComponent) {
+     this.shepherdElementComponent.cleanup();
+     this.shepherdElementComponent = undefined;
+   }
+
+   if (isHTMLElement(this.el)) {
+     this.el.remove();
+     this.el = null;
+   }
+
+   this._updateStepTargetOnHide();
+ }
+
  /**
   * Remove the step, delete the step's element, and destroy the FloatingUI instance for the step.
   * Triggers `destroy` event
   */
  destroy() {
-   destroyTooltip(this);
-
-   if (this.shepherdElementComponent) {
-     this.shepherdElementComponent.cleanup();
-     this.shepherdElementComponent = undefined;
-   }
-
-   if (isHTMLElement(this.el)) {
-     this.el.remove();
-     this.el = null;
-   }
-
-   this._updateStepTargetOnHide();
+   this._teardownElements();
    this._originalTabIndexes.clear();
-
    this.trigger('destroy');
  }

Then in updateStepOptions:

  updateStepOptions(options: StepOptions) {
    Object.assign(this.options, options);

    if (this.shepherdElementComponent) {
      if (this.el) {
-       this.destroy();
+       this._teardownElements();
        this._setupElements();
      }
    }
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
updateStepOptions(options: StepOptions) {
Object.assign(this.options, options);
// @ts-expect-error TODO: get types for Svelte components
if (this.shepherdElementComponent) {
// @ts-expect-error TODO: get types for Svelte components
this.shepherdElementComponent.$set({ step: this });
// Recreate the element with updated options
if (this.el) {
this.destroy();
this._setupElements();
}
}
updateStepOptions(options: StepOptions) {
Object.assign(this.options, options);
if (this.shepherdElementComponent) {
// Recreate the element with updated options
if (this.el) {
this._teardownElements();
this._setupElements();
}
}
}
/**
* Tears down the element and tooltip without emitting 'destroy'.
* `@private`
*/
_teardownElements() {
destroyTooltip(this);
if (this.shepherdElementComponent) {
this.shepherdElementComponent.cleanup();
this.shepherdElementComponent = undefined;
}
if (isHTMLElement(this.el)) {
this.el.remove();
this.el = null;
}
this._updateStepTargetOnHide();
}
/**
* Remove the step, delete the step's element, and destroy the FloatingUI instance for the step.
* Triggers `destroy` event
*/
destroy() {
this._teardownElements();
this._originalTabIndexes.clear();
this.trigger('destroy');
}
🤖 Prompt for AI Agents
In `@shepherd.js/src/step.ts` around lines 474 - 483, updateStepOptions currently
calls destroy(), which emits the public "destroy" event and misleads consumers;
instead implement a private teardown method (e.g., _teardownInternal or
teardownWithoutEmit) that performs the same DOM/component cleanup that destroy()
does but does not emit the "destroy" event, and call that from updateStepOptions
before _setupElements(); keep the existing public destroy() unchanged (it should
still emit the event) and ensure _setupElements() continues to call
bindAdvance() or equivalent to reattach handlers after the internal teardown.

Comment on lines 16 to 117
describe('disabled', () => {
it('should be enabled by default', () => {
const config = {};
const button = createShepherdButton({}, undefined);
container.appendChild(button);

const { container } = render(ShepherdButton, {
props: {
config
}
});

const button = container.querySelector('.shepherd-button');
expect(button.disabled).toBeFalsy();
});

it('is enabled when false', () => {
const config = {
disabled: false
};

const { container } = render(ShepherdButton, {
props: {
config
}
});
const button = createShepherdButton({ disabled: false }, undefined);
container.appendChild(button);

const button = container.querySelector('.shepherd-button');
expect(button.disabled).toBeFalsy();
});

it('can be disabled with boolean', () => {
const config = {
disabled: true
};

const { container } = render(ShepherdButton, {
props: {
config
}
});
const button = createShepherdButton({ disabled: true }, undefined);
container.appendChild(button);

const button = container.querySelector('.shepherd-button');
expect(button.disabled).toBeTruthy();
});

it('can be disabled with function', () => {
const config = {
disabled: () => true
};
const button = createShepherdButton({ disabled: () => true }, undefined);
container.appendChild(button);

const { container } = render(ShepherdButton, {
props: {
config
}
});

const button = container.querySelector('.shepherd-button');
expect(button.disabled).toBeTruthy();
});

it('label - string', () => {
const config = {
label: 'Test'
};

const { container } = render(ShepherdButton, {
props: {
config
}
});
const button = createShepherdButton({ label: 'Test' }, undefined);
container.appendChild(button);

const button = container.querySelector('.shepherd-button');
expect(button).toHaveAttribute('aria-label', 'Test');
});

it('label - number', () => {
const config = {
label: 5
};

const { container } = render(ShepherdButton, {
props: {
config
}
});
const button = createShepherdButton({ label: 5 }, undefined);
container.appendChild(button);

const button = container.querySelector('.shepherd-button');
expect(button).toHaveAttribute('aria-label', '5');
});

it('label - funtion', async () => {
it('label - function', () => {
const button = createShepherdButton({ label: () => 'Test' }, undefined);
container.appendChild(button);

expect(button).toHaveAttribute('aria-label', 'Test');
});

it('label - function re-creation uses updated value', () => {
let label = 'Test';
const labelFunction = () => label;
const config = {
label: labelFunction
};

const { container, rerender } = render(ShepherdButton, {
props: {
config
}
});

const button = container.querySelector('.shepherd-button');
const button = createShepherdButton({ label: () => label }, undefined);
container.appendChild(button);
expect(button).toHaveAttribute('aria-label', 'Test');

label = 'Test 2';

rerender({
config: { label: () => label }
});

await tick();

const buttonUpdated = container.querySelector('.shepherd-button');
const buttonUpdated = createShepherdButton({ label: () => label }, undefined);
container.appendChild(buttonUpdated);
expect(buttonUpdated).toHaveAttribute('aria-label', 'Test 2');
});

it('label - null', () => {
const config = {
label: null
};

const { container } = render(ShepherdButton, {
props: {
config
}
});
const button = createShepherdButton({ label: null }, undefined);
container.appendChild(button);

const button = container.querySelector('.shepherd-button');
expect(button).not.toHaveAttribute('aria-label');
});

it('label - undefined', () => {
const config = {};

const { container } = render(ShepherdButton, {
props: {
config
}
});
const button = createShepherdButton({}, undefined);
container.appendChild(button);

const button = container.querySelector('.shepherd-button');
expect(button).not.toHaveAttribute('aria-label');
});

it('text - string', () => {
const config = {
text: 'Test'
};
const button = createShepherdButton({ text: 'Test' }, undefined);
container.appendChild(button);

const { container } = render(ShepherdButton, {
props: {
config
}
});
expect(button).toHaveTextContent('Test');
});

it('text - function', () => {
const button = createShepherdButton({ text: () => 'Test' }, undefined);
container.appendChild(button);

const button = container.querySelector('.shepherd-button');
expect(button).toHaveTextContent('Test');
});

it('text - function', async () => {
it('text - function re-creation uses updated value', () => {
let text = 'Test';
const textFunction = () => text;
const config = {
text: textFunction
};

const { container, rerender } = render(ShepherdButton, {
props: {
config
}
});

const button = container.querySelector('.shepherd-button');
const button = createShepherdButton({ text: () => text }, undefined);
container.appendChild(button);
expect(button).toHaveTextContent('Test');

text = 'Test 2';

rerender({
config: { text: () => text }
});

await tick();

const buttonUpdated = container.querySelector('.shepherd-button');
const buttonUpdated = createShepherdButton({ text: () => text }, undefined);
container.appendChild(buttonUpdated);
expect(buttonUpdated).toHaveTextContent('Test 2');
});
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Label and text tests are incorrectly nested under the describe('disabled', ...) block.

All label tests (lines 45–76) and text tests (lines 92–116) are inside the describe('disabled', ...) block that starts at line 16 and closes at line 117. This appears to be a copy/restructure oversight — these tests should be in their own describe blocks at the same level.

Proposed structural fix
   describe('disabled', () => {
     // ... disabled tests (lines 17-43) ...
+  });
 
+  describe('label', () => {
     it('label - string', () => {
       // ...
     });
     // ... other label tests ...
+  });
 
+  describe('text', () => {
     it('text - string', () => {
       // ...
     });
     // ... other text tests ...
-  });
+  });
🤖 Prompt for AI Agents
In `@shepherd.js/test/unit/components/shepherd-button.spec.js` around lines 16 -
117, The label and text test cases are incorrectly nested inside the
describe('disabled', ...) block; extract the label tests (those using { label:
... } and references to aria-label) and the text tests (those using { text: ...
} and toHaveTextContent) into their own top-level describe blocks (e.g.,
describe('label', ...) and describe('text', ...)) placed at the same level as
describe('disabled', ...); locate the tests by the createShepherdButton calls
that pass label or text and move each group so describe('disabled', ... ) only
contains disabled-related it() cases while label- and text-related it() cases
are grouped under their respective describe blocks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant