Skip to content

Comments

feat: add storybook playground link#1624

Merged
danielroe merged 4 commits intonpmx-dev:mainfrom
sacrosanctic:storybook-playground-link
Feb 24, 2026
Merged

feat: add storybook playground link#1624
danielroe merged 4 commits intonpmx-dev:mainfrom
sacrosanctic:storybook-playground-link

Conversation

@yannbf
Copy link
Contributor

@yannbf yannbf commented Feb 24, 2026

🔗 Linked issue

resolves #1298

🧭 Context

Libraries with a storybook field in package.json contain a link to their deployed playground, e.g. Chakra UI
has this playground: https://storybook.chakra-ui.com/?path=/story/layout-absolutecenter--basic

You can read more about that standard in the Storybook docs.

It would be amazing to surface that automatically, which is what this PR is providing.

📚 Description

This PR adds a new type of playground: Storybook. Works automatically based on packages that contain the storybook field in their package.json

image

Thanks a lot @sacrosanctic for doing incredible experimentation on integrating Storybook with npmx!

Co-Authored-By: Scott Wu <sw@scottwu.ca>
@vercel
Copy link

vercel bot commented Feb 24, 2026

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

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Feb 24, 2026 4:06pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 24, 2026 4:06pm
npmx-lunaria Ignored Ignored Feb 24, 2026 4:06pm

Request Review

@codecov
Copy link

codecov bot commented Feb 24, 2026

Codecov Report

❌ Patch coverage is 50.00000% with 5 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/pages/package/[[org]]/[name].vue 0.00% 4 Missing ⚠️
app/composables/npm/usePackage.ts 83.33% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 24, 2026

📝 Walkthrough

Walkthrough

Adds Storybook as a playground provider across the app: maps the "storybook" provider to an icon and colour in the Playgrounds component, merges package.json-derived Storybook links into the package page's playgroundLinks, augments SlimPackument with an optional storybook?: { url: string } and surfaces extracted storybook metadata from npm package data, and adds a Storybook colour token (#FF4785) to the UnoCSS theme.

Possibly related PRs

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed PR description clearly describes the feature: adding Storybook playground support by detecting the storybook field in package.json and surfacing links to deployed Storybooks.
Linked Issues check ✅ Passed The PR implements the primary objective from issue #1298: detecting packages with a storybook field in package.json and surfacing an external link to the deployed Storybook, which is Step 1 of the proposed solution.
Out of Scope Changes check ✅ Passed All changes are scoped to Storybook playground support: type definitions, icon/colour mappings, data extraction from package.json, and template integration—no unrelated modifications detected.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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
Contributor

@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: 1

🧹 Nitpick comments (1)
app/pages/package/[[org]]/[name].vue (1)

112-115: Add server: false to avoid an external CDN round-trip on the SSR path.

The Storybook playground link is a non-critical enhancement. Other similarly non-critical useLazyFetch calls in this file (installSize, provenance) already opt out of SSR with server: false. Without it this fetch runs server-side for every page load, adding jsDelivr latency to your SSR response time.

♻️ Suggested change
-const { data: packageJson } = useLazyFetch<{ storybook?: { title: string; url: string } }>(() => {
-  const version = requestedVersion.value ?? 'latest'
-  return `https://cdn.jsdelivr.net/npm/${packageName.value}@${version}/package.json`
-})
+const { data: packageJson } = useLazyFetch<{ storybook?: { title?: string; url: string } }>(
+  () => {
+    const version = requestedVersion.value ?? 'latest'
+    return `https://cdn.jsdelivr.net/npm/${packageName.value}@${version}/package.json`
+  },
+  { server: false },
+)

Also note: title is optional in the Storybook package composition schema — title has no default and is not required; typing it as string (non-optional) is inaccurate (fix shown in the diff above).


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d4e1ae9 and 49c2c0d.

📒 Files selected for processing (3)
  • app/components/Package/Playgrounds.vue
  • app/pages/package/[[org]]/[name].vue
  • uno.config.ts

const playgroundLinks = computed(() => [
...readmeData.value.playgroundLinks,
// Libraries with a storybook field in package.json contain a link to their deployed playground
...(packageJson.value?.storybook
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
...(packageJson.value?.storybook
...(packageJson.value?.storybook?.url

Copy link
Contributor

@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: 2


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 49c2c0d and 26feb38.

📒 Files selected for processing (3)
  • app/composables/npm/usePackage.ts
  • app/pages/package/[[org]]/[name].vue
  • shared/types/npm-registry.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/pages/package/[[org]]/[name].vue

Comment on lines +123 to +125
// Extract storybook field from the requested version (custom package.json field)
const requestedPkgVersion = requestedVersion ? pkg.versions[requestedVersion] : null
const rawStorybook = requestedPkgVersion?.storybook
Copy link
Contributor

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:

cat -n app/composables/npm/usePackage.ts | sed -n '120,135p'

Repository: npmx-dev/npmx.dev

Length of output: 712


🏁 Script executed:

find . -path "*/node_modules/@npm/types*" -name "*.d.ts" -o -path "*/node_modules/@npm/types*" -name "*.ts" | head -20

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

rg "PackumentVersion" --type ts -A 10 -B 2 | head -80

Repository: npmx-dev/npmx.dev

Length of output: 6995


🏁 Script executed:

fd -name "types.ts" -o -name "types.d.ts" | grep shared | head -10

Repository: npmx-dev/npmx.dev

Length of output: 291


🏁 Script executed:

find . -path "*/shared/*types*" -name "*.ts" | head -20

Repository: npmx-dev/npmx.dev

Length of output: 613


🏁 Script executed:

rg "export.*PackumentVersion" --type ts -B 5 -A 15 | head -100

Repository: npmx-dev/npmx.dev

Length of output: 5135


🏁 Script executed:

cat -n shared/types/npm-registry.ts | head -60

Repository: npmx-dev/npmx.dev

Length of output: 2619


🏁 Script executed:

rg "PackumentVersionWithoutAttestations" --type ts -B 2 -A 5 | head -60

Repository: npmx-dev/npmx.dev

Length of output: 1421


🏁 Script executed:

cat package.json | grep -A 5 "@npm/types"

Repository: npmx-dev/npmx.dev

Length of output: 269


🏁 Script executed:

rg "storybook" --type ts -i | head -20

Repository: npmx-dev/npmx.dev

Length of output: 1133


🏁 Script executed:

npx tsc --noEmit 2>&1 | grep -i "storybook\|usePackage" | head -20

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

cat -n shared/types/npm-registry.ts | sed -n '15,100p'

Repository: npmx-dev/npmx.dev

Length of output: 3876


🏁 Script executed:

rg "'storybook'" --type ts -B 5 -A 3 shared/types/npm-registry.ts

Repository: npmx-dev/npmx.dev

Length of output: 433


🏁 Script executed:

cat -n shared/types/npm-registry.ts | sed -n '1,30p'

Repository: npmx-dev/npmx.dev

Length of output: 1497


🏁 Script executed:

rg "interface PackumentVersion|type PackumentVersion" --type ts -A 10 -B 2

Repository: npmx-dev/npmx.dev

Length of output: 1118


🏁 Script executed:

cat -n app/composables/npm/usePackage.ts | sed -n '120,165p'

Repository: npmx-dev/npmx.dev

Length of output: 1917


🏁 Script executed:

rg "PackumentVersionWithoutAttestations\[" --type ts -A 3 -B 3

Repository: npmx-dev/npmx.dev

Length of output: 606


🏁 Script executed:

npm ls `@npm/types` 2>&1 | head -20

Repository: npmx-dev/npmx.dev

Length of output: 103


🏁 Script executed:

cat -n app/composables/npm/usePackage.ts | sed -n '1,50p'

Repository: npmx-dev/npmx.dev

Length of output: 2073


🏁 Script executed:

rg "transformPackument" --type ts -B 5 -A 50 | head -80

Repository: npmx-dev/npmx.dev

Length of output: 5709


🏁 Script executed:

node -e "
const pkg = require('./package.json');
console.log('@npm/types version:', pkg.dependencies['@npm/types']);
"

Repository: npmx-dev/npmx.dev

Length of output: 90


🏁 Script executed:

ls -la node_modules/@npm/types/ 2>/dev/null | head -20

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

python3 << 'EOF'
import json
import subprocess

# Try to find and inspect the actual `@npm/types` definitions
result = subprocess.run(['find', '.', '-path', '*/node_modules/@npm/types*', '-name', 'index.d.ts'], 
                       capture_output=True, text=True, timeout=5)
files = result.stdout.strip().split('\n')
if files and files[0]:
    with open(files[0], 'r') as f:
        content = f.read()
        # Find PackumentVersion definition
        lines = content.split('\n')
        for i, line in enumerate(lines):
            if 'PackumentVersion' in line and 'interface' in line:
                # Print context around the definition
                start = max(0, i - 3)
                end = min(len(lines), i + 30)
                print('\n'.join(f'{start+j}: {lines[start+j]}' for j in range(end-start)))
                break
EOF

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

cat package.json | jq '.dependencies, .devDependencies' | grep -A 2 -B 2 "@npm"

Repository: npmx-dev/npmx.dev

Length of output: 214


🏁 Script executed:

rg "@npm/types" --type ts | head -20

Repository: npmx-dev/npmx.dev

Length of output: 1563


🏁 Script executed:

find . -type f -name "*.json" -path "*/node_modules/@npm*" | head -5

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

cat tsconfig.json | jq '.compilerOptions | {strict, noImplicitAny, suppressImplicitAnyIndexErrors}'

Repository: npmx-dev/npmx.dev

Length of output: 117


🏁 Script executed:

rg "storybook" --type ts -B 5 -A 5 app/composables/npm/usePackage.ts

Repository: npmx-dev/npmx.dev

Length of output: 896


🏁 Script executed:

python3 << 'EOF'
# Let's check if `@npm/types` has an index signature by checking the installed version
import subprocess
import json

# Read package.json
with open('package.json', 'r') as f:
    pkg = json.load(f)
    version = pkg.get('dependencies', {}).get('@npm/types', 'not found')
    print(f"@npm/types version: {version}")

# Try to find the actual type definitions
result = subprocess.run(['find', 'node_modules/@npm/types', '-name', '*.d.ts'], 
                       capture_output=True, text=True, timeout=5)
if result.returncode == 0 and result.stdout:
    files = result.stdout.strip().split('\n')
    if files[0]:
        print(f"\nFound type definition files:\n{chr(10).join(files[:3])}")
        # Try to read the first one
        try:
            with open(files[0], 'r') as f:
                content = f.read()
                # Look for PackumentVersion
                if 'PackumentVersion' in content:
                    lines = content.split('\n')
                    for i, line in enumerate(lines):
                        if 'PackumentVersion' in line:
                            start = max(0, i - 2)
                            end = min(len(lines), i + 25)
                            print(f"\nFound at line {i}:")
                            print('\n'.join(lines[start:end]))
                            break
        except Exception as e:
            print(f"Error reading file: {e}")
else:
    print("Could not find `@npm/types` files")
EOF

Repository: npmx-dev/npmx.dev

Length of output: 122


🏁 Script executed:

cat tsconfig.json | head -40

Repository: npmx-dev/npmx.dev

Length of output: 380


🏁 Script executed:

rg "\"strict\"|\"noImplicitAny\"" tsconfig.json

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

python3 << 'EOF'
# Analyze the code structure to understand type safety
content = '''
shared/types/npm-registry.ts line 21-24:
export interface PackumentVersion extends PackumentVersionWithoutAttestations {
  _npmUser?: Contact & { trustedPublisher?: NpmTrustedPublisherEvidence }
  dist: PackumentVersionWithoutAttestations['dist'] & { attestations?: NpmVersionAttestations }
}

usePackage.ts line 124-125:
const requestedPkgVersion = requestedVersion ? pkg.versions[requestedVersion] : null
const rawStorybook = requestedPkgVersion?.storybook
'''

print("Type Analysis:")
print("=" * 60)
print("requestedPkgVersion type: PackumentVersion | null")
print("PackumentVersion properties: _npmUser (optional), dist (required)")
print("                            + inherited from PackumentVersionWithoutAttestations")
print("\nAccessing: requestedPkgVersion?.storybook")
print("Property 'storybook' is NOT defined on PackumentVersion")
print("\nConclusion: This is a type error IF strict mode is enabled")
print("=" * 60)
EOF

Repository: npmx-dev/npmx.dev

Length of output: 553


🏁 Script executed:

cat -n app/composables/npm/usePackage.ts | sed -n '36,80p'

Repository: npmx-dev/npmx.dev

Length of output: 2090


🏁 Script executed:

find .nuxt -name "tsconfig*.json" -exec cat {} \; 2>/dev/null | grep -A 5 '"strict"' | head -20

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

cat -n shared/types/npm-registry.ts | sed -n '80,95p'

Repository: npmx-dev/npmx.dev

Length of output: 717


🏁 Script executed:

rg "// Extract storybook" --type ts -B 2 -A 15

Repository: npmx-dev/npmx.dev

Length of output: 1282


🏁 Script executed:

cat -n shared/types/npm-registry.ts | sed -n '1,30p'

Repository: npmx-dev/npmx.dev

Length of output: 1497


🏁 Script executed:

python3 << 'EOF'
# Let's trace the actual type flow
print("""
TYPE FLOW ANALYSIS:
==================

Line 124 (usePackage.ts):
  const requestedPkgVersion = requestedVersion ? pkg.versions[requestedVersion] : null
  Type: PackumentVersion | null
  (pkg is of type Packument, pkg.versions is Record<string, PackumentVersion>)

Line 125:
  const rawStorybook = requestedPkgVersion?.storybook
  
PackumentVersion definition (shared/types/npm-registry.ts lines 21-24):
  export interface PackumentVersion extends PackumentVersionWithoutAttestations {
    _npmUser?: Contact & { trustedPublisher?: NpmTrustedPublisherEvidence }
    dist: PackumentVersionWithoutAttestations['dist'] & { attestations?: NpmVersionAttestations }
  }

Properties on PackumentVersion:
  - _npmUser (optional, from local interface)
  - dist (required, overridden)
  - All properties from PackumentVersionWithoutAttestations (`@npm/types`)
  
Property 'storybook':
  - NOT present on PackumentVersion
  - IS present on SlimPackument (line 86: 'storybook'?: { url: string })
  - The code is trying to EXTRACT storybook from PackumentVersion to build SlimPackument
  
This is a TYPE SAFETY ISSUE: accessing undefined property on a typed interface.

The guard at line 127-129:
  rawStorybook && typeof rawStorybook === 'object' && 'url' in rawStorybook
  Only checks key presence, not value type. 'url' could be null/undefined/empty string.
""")
EOF

Repository: npmx-dev/npmx.dev

Length of output: 1408


Add type-safe accessor for undeclared storybook field.

requestedPkgVersion is typed as PackumentVersion (from shared/types/npm-registry.ts), which does not declare a storybook property. Although this is a custom package.json field that the code intentionally extracts, accessing it without proper typing creates a type safety violation. Use bracket notation with a type assertion to safely access custom fields:

Proposed fix
  // Extract storybook field from the requested version (custom package.json field)
  const requestedPkgVersion = requestedVersion ? pkg.versions[requestedVersion] : null
- const rawStorybook = requestedPkgVersion?.storybook
+ const rawStorybook = requestedPkgVersion
+   ? (requestedPkgVersion as Record<string, unknown>)['storybook']
+   : undefined

Additionally, the guard at line 127 checks only for key presence ('url' in rawStorybook), not value validity. The url property could be null, undefined, or an empty string, making the downstream type assertion unsafe. Strengthen the validation:

  const storybook =
    rawStorybook && typeof rawStorybook === 'object' && 'url' in rawStorybook
+     && typeof (rawStorybook as { url?: unknown }).url === 'string'
+     && (rawStorybook as { url: string }).url.length > 0
      ? ({ url: rawStorybook.url } as { url: string })
      : undefined
📝 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
// Extract storybook field from the requested version (custom package.json field)
const requestedPkgVersion = requestedVersion ? pkg.versions[requestedVersion] : null
const rawStorybook = requestedPkgVersion?.storybook
// Extract storybook field from the requested version (custom package.json field)
const requestedPkgVersion = requestedVersion ? pkg.versions[requestedVersion] : null
const rawStorybook = requestedPkgVersion
? (requestedPkgVersion as Record<string, unknown>)['storybook']
: undefined

Comment on lines 126 to 129
const storybook =
rawStorybook && typeof rawStorybook === 'object' && 'url' in rawStorybook
? (rawStorybook as { title?: string; url: string })
: undefined
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

'url' in rawStorybook only checks key presence — the url value may still be null, undefined, or a non-string.

The type assertion on line 128 types url as string, but { url: null } or { url: 42 } would pass the current guard. This would propagate an invalid URL into the slim package payload, which the downstream playground link UI would then render broken.

🛡️ Proposed fix — validate that `url` is a non-empty string
  const storybook =
-   rawStorybook && typeof rawStorybook === 'object' && 'url' in rawStorybook
-     ? (rawStorybook as { title?: string; url: string })
+   rawStorybook && typeof rawStorybook === 'object'
+     && typeof (rawStorybook as Record<string, unknown>)['url'] === 'string'
+     && ((rawStorybook as Record<string, unknown>)['url'] as string).length > 0
+     ? (rawStorybook as { title?: string; url: string })
      : undefined
📝 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 storybook =
rawStorybook && typeof rawStorybook === 'object' && 'url' in rawStorybook
? (rawStorybook as { title?: string; url: string })
: undefined
const storybook =
rawStorybook && typeof rawStorybook === 'object'
&& typeof (rawStorybook as Record<string, unknown>)['url'] === 'string'
&& ((rawStorybook as Record<string, unknown>)['url'] as string).length > 0
? (rawStorybook as { title?: string; url: string })
: undefined

@danielroe danielroe added this pull request to the merge queue Feb 24, 2026
Merged via the queue into npmx-dev:main with commit c52d259 Feb 24, 2026
17 checks passed
@github-actions
Copy link

Thanks for your first contribution, @yannbf! 🎊

We'd love to welcome you to the npmx community. Come and say hi on Discord! And once you've joined, visit npmx.wamellow.com to claim the contributor role.

Copy link
Contributor

@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: 1

🧹 Nitpick comments (1)
app/pages/package/[[org]]/[name].vue (1)

112-125: LGTM — the prior storybook.url guard concern is now resolved.

The condition correctly uses pkg.value?.storybook?.url, ensuring no PlaygroundLink entry is emitted when storybook is present but url is absent (e.g., Storybook addon packages).

One minor edge case to keep in mind: readmeData.value.playgroundLinks could theoretically contain a Storybook entry if the README parser ever starts detecting storybook.io URLs, which would produce a duplicate entry in the merged array. Deduplication logic (e.g., by provider or url) would guard against that if it becomes a concern.


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 26feb38 and 39259f4.

📒 Files selected for processing (3)
  • app/composables/npm/usePackage.ts
  • app/pages/package/[[org]]/[name].vue
  • shared/types/npm-registry.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/composables/npm/usePackage.ts

'keywords'?: string[]
'repository'?: { type?: string; url?: string; directory?: string }
'bugs'?: { url?: string; email?: string }
'storybook'?: { url: string }
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Locate usePackage.ts and npm-registry.ts files
fd -t f "usePackage\\.ts" -o "npm-registry\\.ts"

Repository: npmx-dev/npmx.dev

Length of output: 212


🏁 Script executed:

# Locate usePackage.ts and npm-registry.ts files
fd -t f "usePackage\.ts"
fd -t f "npm-registry\.ts"

Repository: npmx-dev/npmx.dev

Length of output: 123


🏁 Script executed:

# Get context around line 86 in npm-registry.ts
wc -l shared/types/npm-registry.ts

Repository: npmx-dev/npmx.dev

Length of output: 93


🏁 Script executed:

# Read npm-registry.ts around line 86 to see the full type definition
sed -n '70,105p' shared/types/npm-registry.ts

Repository: npmx-dev/npmx.dev

Length of output: 1375


🏁 Script executed:

# Get file size of usePackage.ts
wc -l app/composables/npm/usePackage.ts

Repository: npmx-dev/npmx.dev

Length of output: 98


🏁 Script executed:

# Read entire usePackage.ts
cat -n app/composables/npm/usePackage.ts

Repository: npmx-dev/npmx.dev

Length of output: 7211


The guard at line 127 only checks property existence, not that url is a string.

The condition 'url' in rawStorybook on line 127 returns true even if url is undefined. Line 128 then creates { url: undefined } and casts it as { url: string }, violating the SlimPackument contract where url: string is required (non-optional). The guard should additionally validate that rawStorybook.url is a string:

rawStorybook && typeof rawStorybook === 'object' && typeof rawStorybook.url === 'string'

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.

Native stories viewer

4 participants