-
-
Notifications
You must be signed in to change notification settings - Fork 241
feat: add story viewer #1326
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: add story viewer #1326
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
2 Skipped Deployments
|
Lunaria Status Overview🌕 This pull request will trigger status changes. Learn moreBy default, every PR changing files present in the Lunaria configuration's You can change this by adding one of the keywords present in the Tracked Files
Warnings reference
|
❌ 1 Tests Failed:
View the top 1 failed test(s) by shortest run time
To view more test analytics, go to the Test Analytics Dashboard |
JReinhold
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is cool!
I definitely like the in-npmx navigator better, it's just better DX.
However I'm thorn between it being in the top vs on the side. Putting it in the top (next to "code") would make the navigation experience consistent, it does something very similar to the code view, so putting them together makes sense from a user-expectation standpoint.
However it also works similar to a playground, so having it in the side with the other playground links makes sense too. But if we build it as the in-npmx experience (which IMO we should), it would act differently from all the other playground entries, which AFAIK are all external links with target="_blank".
WDYT @danielroe?
| query: { storyid: node.storyId }, | ||
| } | ||
| } | ||
| // For directories - navigate to first story in that directory |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think instead we want to follow the existing navigation pattern in the Code view, where clicking a directory only expands it (and changes it icon) but doesn't select any stories - only navigating to a story actually selects it.
I know that Storybook itself does the latter, but here I think we want to be consistent with npmx instead.
| return getFileIcon(node.name) | ||
| } | ||
|
|
||
| const { toggleDir, isExpanded, autoExpandAncestors } = useStoryTreeState(props.baseUrl) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think autoExpandAncestors is the right idea, but it's not active at the moment.
| <span v-if="currentStory.story?.tags" class="text-fg-subtle"> | ||
| {{ currentStory.story.tags.join(', ') }} | ||
| </span> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think tags is valuable information here, in 99 % of the cases it's internal information.
|
|
||
| const playgroundLinks = computed(() => [ | ||
| ...readmeData.value.playgroundLinks, | ||
| ...(packageJson.value?.storybook |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
packages can have a storybook property without having a storybook.url property, so we need to check for that here instead. Storybook addons have metadata in the same storybook property, but with no url, eg. https://npmxdev-git-fork-sacrosanctic-story-viewer-npmx.vercel.app/package-code/@storybook/addon-docs/v/10.2.8/package.json#L118-L124
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you know where I can get source the type definition for package.json#storybook and /index.json?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can find the index type here:
import { StoryIndex } from 'storybook/internal/types';
As for the package.json definition, AFAIC there aren't any, we don't type it ourselves
https://github.com/storybookjs/storybook/blob/d29589dfa45bcb218479b013334747ed4655b2b9/code/core/src/common/utils/get-storybook-refs.ts#L28
📝 WalkthroughWalkthroughThis pull request introduces a native Storybook viewer feature to npmx.dev. It adds support for displaying and navigating Storybook components directly within the platform by fetching Storybook index.json files, transforming them into a hierarchical file tree, and rendering stories in an isolated iframe. The implementation includes new page routes, UI components for desktop and mobile layouts, state management for directory expansion, TypeScript type definitions for Storybook data structures, utility functions for tree transformation and breadcrumb generation, and internationalization strings for the new interface. Suggested labels
Suggested reviewers
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 8
🧹 Nitpick comments (4)
app/utils/storybook-tree.ts (1)
6-72: Consider splittingtransformStorybookEntriesinto smaller helpers.The function is doing directory creation, parent linking, and story insertion in one block. Extracting helpers (e.g.,
getOrCreateDir,insertStory) would keep it closer to the guideline target and make it easier to test. As per coding guidelines: "Keep functions focused and manageable (generally under 50 lines)".app/components/Storybook/FileTree.vue (2)
58-65: Use colon syntax for UnoCSS icons.The Storybook icon is using dash syntax; switch to colon syntax to align with the project’s icon convention.
Based on learnings: In Vue components that use UnoCSS with the preset-icons collection, prefer colon-syntax for icons (e.g., i-carbon:checkmark) over the dash-separated form (i-carbon-checkmark).Suggested fix
- return 'i-vscode-icons-file-type-storybook' + return 'i-vscode-icons:file-type-storybook'
70-70: Remove or use the unusedautoExpandAncestorsbinding.It is currently declared but not used; either drop it or call it when a story becomes active.
app/components/Storybook/MobileTreeDrawer.vue (1)
68-72: Use colon syntax for UnoCSS icons.
classicon="i-carbon-close"should use the colon syntax to match the repo’s icon convention.Based on learnings: In Vue components that use UnoCSS with the preset-icons collection, prefer colon-syntax for icons (e.g., i-carbon:checkmark) over the dash-separated form (i-carbon-checkmark).Suggested fix
- classicon="i-carbon-close" + classicon="i-carbon:close"
| // Build route object for a story | ||
| function getStoryRoute(node: StorybookFileTree): RouteLocationRaw { | ||
| if (node.type === 'story') { | ||
| return { | ||
| name: 'stories', | ||
| params: { path: props.basePath }, | ||
| query: { storyid: node.storyId }, | ||
| } | ||
| } | ||
| // For directories - navigate to first story in that directory | ||
| if (node.type === 'directory') { | ||
| const firstStory = getFirstStoryInDirectory(node) | ||
| if (firstStory) { | ||
| return { | ||
| name: 'stories', | ||
| params: { path: props.basePath }, | ||
| query: { storyid: firstStory.storyId }, | ||
| } | ||
| } | ||
| } | ||
| return { name: 'stories', params: { path: props.basePath } } | ||
| } | ||
|
|
||
| // Get icon for story or directory | ||
| function getNodeIcon(node: StorybookFileTree): string { | ||
| if (node.type === 'directory') { | ||
| return isNodeActive(node) | ||
| ? 'i-carbon:folder-open text-yellow-500' | ||
| : 'i-carbon:folder text-yellow-600' | ||
| } | ||
|
|
||
| if (node.storyId) { | ||
| // Try to get icon based on story file type if available | ||
| if (node.story?.importPath) { | ||
| return getFileIcon(node.story.importPath) | ||
| } | ||
| // Default story icon | ||
| return 'i-vscode-icons-file-type-storybook' | ||
| } | ||
|
|
||
| return getFileIcon(node.name) | ||
| } | ||
|
|
||
| const { toggleDir, isExpanded, autoExpandAncestors } = useStoryTreeState(props.baseUrl) | ||
|
|
||
| // Handle directory click - toggle expansion and navigate to first story | ||
| function handleDirectoryClick(node: StorybookFileTree) { | ||
| if (node.type !== 'directory') return | ||
|
|
||
| // Toggle directory expansion | ||
| toggleDir(node.path) | ||
|
|
||
| // Navigate to first story in directory (if available) | ||
| const route = getStoryRoute(node) | ||
| if (route.query?.storyid) { | ||
| navigateTo(route) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# Locate and read the FileTree.vue file
find . -name "FileTree.vue" -type fRepository: npmx-dev/npmx.dev
Length of output: 135
🏁 Script executed:
# Search for StorybookFileTree type definition
rg "type StorybookFileTree|interface StorybookFileTree" -A 10Repository: npmx-dev/npmx.dev
Length of output: 646
🏁 Script executed:
# Search for RouteLocationRaw usage in the file
rg "RouteLocationRaw" -B 2 -A 2Repository: npmx-dev/npmx.dev
Length of output: 3420
🏁 Script executed:
# Check basePath type and how it's used in the component
rg "basePath" app/components/Storybook/FileTree.vue -B 1 -A 1Repository: npmx-dev/npmx.dev
Length of output: 569
🏁 Script executed:
# Check if autoExpandAncestors is used elsewhere in the file
rg "autoExpandAncestors" app/components/Storybook/FileTree.vueRepository: npmx-dev/npmx.dev
Length of output: 148
🏁 Script executed:
# Read the complete FileTree.vue file to see all content
cat -n app/components/Storybook/FileTree.vueRepository: npmx-dev/npmx.dev
Length of output: 4985
🏁 Script executed:
# Find route definitions for 'stories'
rg "name.*stories|stories.*route" app/ -B 2 -A 5 --type ts --type vueRepository: npmx-dev/npmx.dev
Length of output: 88
🏁 Script executed:
# Check app.vue or router configuration
find app -name "*.ts" -o -name "app.vue" | head -20Repository: npmx-dev/npmx.dev
Length of output: 772
🏁 Script executed:
# Look for router/routes configuration
fd -e ts -e vue | xargs rg "stories.*path|path.*param" -B 2 -A 2 | head -50Repository: npmx-dev/npmx.dev
Length of output: 1542
🏁 Script executed:
# Check the route definition more carefully
cat -n app/pages/package-stories/[...path].vue | head -20Repository: npmx-dev/npmx.dev
Length of output: 670
🏁 Script executed:
# Look for TypeScript/ESLint configuration to understand what errors would be caught
find . -name "tsconfig.*" -o -name ".eslintrc*" -o -name "nuxt.config.*" | head -10Repository: npmx-dev/npmx.dev
Length of output: 135
🏁 Script executed:
# Check what type vue-router expects for RouteLocationRaw with params.path
rg "definePageMeta|path.*param" app/pages/package-stories/[...path].vue -B 3 -A 3Repository: npmx-dev/npmx.dev
Length of output: 244
🏁 Script executed:
# Verify if there's any CI output or type errors
fd -e log -e txt | xargs grep -l "TS2322\|storyid\|query" 2>/dev/null | head -5Repository: npmx-dev/npmx.dev
Length of output: 78
🏁 Script executed:
# Check Code/FileTree.vue to see similar pattern
cat -n app/components/Code/FileTree.vue | grep -A 10 "getFileRoute"Repository: npmx-dev/npmx.dev
Length of output: 970
🏁 Script executed:
# Check how route params are handled in Nuxt for :path+ pattern
rg "useRoute\|path.*param" app/pages/package-stories/[...path].vue -A 10 | head -30Repository: npmx-dev/npmx.dev
Length of output: 43
🏁 Script executed:
# Check if there are TypeScript strict mode settings
cat -n tsconfig.json | head -40Repository: npmx-dev/npmx.dev
Length of output: 506
🏁 Script executed:
# Verify if autoExpandAncestors is used anywhere after the destructuring
sed -n '70,85p' app/components/Storybook/FileTree.vueRepository: npmx-dev/npmx.dev
Length of output: 537
🏁 Script executed:
# Check the nuxt.config to understand strict type checking
cat -n nuxt.config.ts | head -50Repository: npmx-dev/npmx.dev
Length of output: 1761
🏁 Script executed:
# Look for how other routes handle the path parameter with string[]
rg "params.*path" app/ -B 2 -A 2Repository: npmx-dev/npmx.dev
Length of output: 5930
🏁 Script executed:
# Check if there's a package.json with type checking scripts
cat -n package.json | grep -A 10 "scripts"Repository: npmx-dev/npmx.dev
Length of output: 3127
🏁 Script executed:
# Verify the complete getStoryRoute function and understand the query issue
ast-grep --pattern 'function getStoryRoute($_) { $$$ }'Repository: npmx-dev/npmx.dev
Length of output: 43
🏁 Script executed:
# Run type checking to see actual errors
pnpm test:types 2>&1 | grep -A 5 "FileTree.vue"Repository: npmx-dev/npmx.dev
Length of output: 43
🏁 Script executed:
# Check if there's a build or type error log
pnpm build 2>&1 | grep -i "filetree\|storyid\|query" | head -20Repository: npmx-dev/npmx.dev
Length of output: 43
🏁 Script executed:
# Verify the actual type definition for route query access
rg "RouteLocationRaw|RouteLocation" shared/types --type ts -A 5Repository: npmx-dev/npmx.dev
Length of output: 43
🏁 Script executed:
# Check if storyId being optional causes issues by looking at how it's used elsewhere
rg "storyId" app/components/Storybook/FileTree.vue -B 1 -A 1Repository: npmx-dev/npmx.dev
Length of output: 676
🏁 Script executed:
# Verify router param types from vue-router documentation in the code
rg "path\+" app/pages -B 2 -A 2Repository: npmx-dev/npmx.dev
Length of output: 1122
🏁 Script executed:
# Check if there are actual TypeScript errors by looking at node_modules or error reports
ls -la .nuxt/ 2>/dev/null | head -10Repository: npmx-dev/npmx.dev
Length of output: 43
🏁 Script executed:
# Let me check the exact issue with optional storyId and undefined query values
python3 << 'EOF'
# Analyzing the type issue
analysis = """
StorybookFileTree.storyId is optional (?: string)
When getStoryRoute() returns { query: { storyid: node.storyId } }
and node.storyId is undefined, we get { storyid: undefined }
In handleDirectoryClick at line 81:
if (route.query?.storyid) - this checks for the property's truthiness
But the real issue is at creation time - RouteLocationRaw doesn't
guarantee query exists or its shape. When storyId is undefined,
the query object shouldn't include it at all.
"""
print(analysis)
EOFRepository: npmx-dev/npmx.dev
Length of output: 524
🏁 Script executed:
# Check if there are any actual test failures or TS errors related to this
find . -name "*.vue" -path "*/Storybook/*" | xargs wc -lRepository: npmx-dev/npmx.dev
Length of output: 169
🏁 Script executed:
# Let's verify if the query object with undefined values is actually a type error
cat > /tmp/test-type.ts << 'EOF'
import type { RouteLocationRaw } from 'vue-router'
// Simulating the issue
const storyId: string | undefined = undefined
// This is what the code does - passing undefined in query
const route1: RouteLocationRaw = {
name: 'stories',
params: { path: ['test'] as [string, ...string[]] },
query: { storyid: storyId }, // storyId could be undefined
}
// This is the safer pattern
const route2: RouteLocationRaw = storyId ? {
name: 'stories',
params: { path: ['test'] as [string, ...string[]] },
query: { storyid: storyId },
} : {
name: 'stories',
params: { path: ['test'] as [string, ...string[]] },
}
console.log(route1, route2)
EOF
cat /tmp/test-type.tsRepository: npmx-dev/npmx.dev
Length of output: 705
🏁 Script executed:
# Check if autoExpandAncestors is used in template or elsewhere
sed -n '1,129p' app/components/Storybook/FileTree.vue | grep -i "autoexpand"Repository: npmx-dev/npmx.dev
Length of output: 148
🏁 Script executed:
# Verify that passing basePath directly (without .join) works elsewhere
rg "params.*path.*basePath" app/ -B 2 -A 2Repository: npmx-dev/npmx.dev
Length of output: 1415
🏁 Script executed:
# Verify what the 'stories' route expects for path parameter
rg "const.*path.*route\.params" app/pages/package-stories/[...path].vue -B 2 -A 2Repository: npmx-dev/npmx.dev
Length of output: 43
🏁 Script executed:
# Check if basePath needs type assertion like Code components do
rg "string\[\].*RouteLocationRaw\|RouteLocationRaw.*string\[\]" app/components -B 3 -A 3Repository: npmx-dev/npmx.dev
Length of output: 43
Address optional storyId when building route objects.
StorybookFileTree.storyId is optional, so getStoryRoute() can return query objects with undefined values. Only include the query property when a story ID exists. Similarly, remove the unused autoExpandAncestors from the destructuring on line 70.
Suggested fix
// Build route object for a story
function getStoryRoute(node: StorybookFileTree): RouteLocationRaw {
if (node.type === 'story') {
+ if (node.storyId) {
return {
name: 'stories',
params: { path: props.basePath },
query: { storyid: node.storyId },
}
+ }
}
// For directories - navigate to first story in that directory
if (node.type === 'directory') {
const firstStory = getFirstStoryInDirectory(node)
- if (firstStory) {
+ if (firstStory?.storyId) {
return {
name: 'stories',
params: { path: props.basePath },
query: { storyid: firstStory.storyId },
}
}
}
return { name: 'stories', params: { path: props.basePath } }
}
-const { toggleDir, isExpanded, autoExpandAncestors } = useStoryTreeState(props.baseUrl)
+const { toggleDir, isExpanded } = useStoryTreeState(props.baseUrl)📝 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.
| // Build route object for a story | |
| function getStoryRoute(node: StorybookFileTree): RouteLocationRaw { | |
| if (node.type === 'story') { | |
| return { | |
| name: 'stories', | |
| params: { path: props.basePath }, | |
| query: { storyid: node.storyId }, | |
| } | |
| } | |
| // For directories - navigate to first story in that directory | |
| if (node.type === 'directory') { | |
| const firstStory = getFirstStoryInDirectory(node) | |
| if (firstStory) { | |
| return { | |
| name: 'stories', | |
| params: { path: props.basePath }, | |
| query: { storyid: firstStory.storyId }, | |
| } | |
| } | |
| } | |
| return { name: 'stories', params: { path: props.basePath } } | |
| } | |
| // Get icon for story or directory | |
| function getNodeIcon(node: StorybookFileTree): string { | |
| if (node.type === 'directory') { | |
| return isNodeActive(node) | |
| ? 'i-carbon:folder-open text-yellow-500' | |
| : 'i-carbon:folder text-yellow-600' | |
| } | |
| if (node.storyId) { | |
| // Try to get icon based on story file type if available | |
| if (node.story?.importPath) { | |
| return getFileIcon(node.story.importPath) | |
| } | |
| // Default story icon | |
| return 'i-vscode-icons-file-type-storybook' | |
| } | |
| return getFileIcon(node.name) | |
| } | |
| const { toggleDir, isExpanded, autoExpandAncestors } = useStoryTreeState(props.baseUrl) | |
| // Handle directory click - toggle expansion and navigate to first story | |
| function handleDirectoryClick(node: StorybookFileTree) { | |
| if (node.type !== 'directory') return | |
| // Toggle directory expansion | |
| toggleDir(node.path) | |
| // Navigate to first story in directory (if available) | |
| const route = getStoryRoute(node) | |
| if (route.query?.storyid) { | |
| navigateTo(route) | |
| } | |
| // Build route object for a story | |
| function getStoryRoute(node: StorybookFileTree): RouteLocationRaw { | |
| if (node.type === 'story') { | |
| if (node.storyId) { | |
| return { | |
| name: 'stories', | |
| params: { path: props.basePath }, | |
| query: { storyid: node.storyId }, | |
| } | |
| } | |
| } | |
| // For directories - navigate to first story in that directory | |
| if (node.type === 'directory') { | |
| const firstStory = getFirstStoryInDirectory(node) | |
| if (firstStory?.storyId) { | |
| return { | |
| name: 'stories', | |
| params: { path: props.basePath }, | |
| query: { storyid: firstStory.storyId }, | |
| } | |
| } | |
| } | |
| return { name: 'stories', params: { path: props.basePath } } | |
| } | |
| // Get icon for story or directory | |
| function getNodeIcon(node: StorybookFileTree): string { | |
| if (node.type === 'directory') { | |
| return isNodeActive(node) | |
| ? 'i-carbon:folder-open text-yellow-500' | |
| : 'i-carbon:folder text-yellow-600' | |
| } | |
| if (node.storyId) { | |
| // Try to get icon based on story file type if available | |
| if (node.story?.importPath) { | |
| return getFileIcon(node.story.importPath) | |
| } | |
| // Default story icon | |
| return 'i-vscode-icons-file-type-storybook' | |
| } | |
| return getFileIcon(node.name) | |
| } | |
| const { toggleDir, isExpanded } = useStoryTreeState(props.baseUrl) | |
| // Handle directory click - toggle expansion and navigate to first story | |
| function handleDirectoryClick(node: StorybookFileTree) { | |
| if (node.type !== 'directory') return | |
| // Toggle directory expansion | |
| toggleDir(node.path) | |
| // Navigate to first story in directory (if available) | |
| const route = getStoryRoute(node) | |
| if (route.query?.storyid) { | |
| navigateTo(route) | |
| } |
🧰 Tools
🪛 GitHub Actions: ci
[error] 30-30: TS2322: Type '{ name: "stories"; params: { path: string[]; }; query: { storyid: string | undefined; }; }' is not assignable to type 'RouteLocationRaw'.
🪛 GitHub Check: 💪 Type check
[failure] 81-81:
Property 'query' does not exist on type 'RouteLocationRaw'.
[failure] 70-70:
'autoExpandAncestors' is declared but its value is never read.
[failure] 47-47:
Type '{ name: "stories"; params: { path: string[]; }; }' is not assignable to type 'RouteLocationRaw'.
[failure] 40-40:
Type '{ name: "stories"; params: { path: string[]; }; query: { storyid: string | undefined; }; }' is not assignable to type 'RouteLocationRaw'.
[failure] 30-30:
Type '{ name: "stories"; params: { path: string[]; }; query: { storyid: string | undefined; }; }' is not assignable to type 'RouteLocationRaw'.
| // Fetch package.json to get Storybook URL | ||
| const { data: packageJson } = useFetch<{ storybook: { title: string; url: string } }>(() => { | ||
| const url = `https://cdn.jsdelivr.net/npm/${packageName.value}/package.json` | ||
| return url | ||
| }) | ||
|
|
||
| // Fetch Storybook index data | ||
| const { data: storybookData, status: storybookStatus } = useLazyFetch<{ | ||
| v: number | ||
| entries: Record<string, StorybookEntry> | ||
| }>( | ||
| () => { | ||
| if (!packageJson.value?.storybook?.url) return '' | ||
| return packageJson.value.storybook.url + '/index.json' | ||
| }, | ||
| { | ||
| key: computed(() => `storybook:${packageName.value}`), | ||
| server: false, // Storybook URLs are usually client-side only | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, let's find and read the file
fd -t f "package-stories" -p --exec cat {} \;Repository: npmx-dev/npmx.dev
Length of output: 15214
🏁 Script executed:
# Let's look at the exact file path
git ls-files | grep package-storiesRepository: npmx-dev/npmx.dev
Length of output: 100
🏁 Script executed:
# Read the specific file with line numbers
cat -n app/pages/package-stories/[...path].vue | head -100Repository: npmx-dev/npmx.dev
Length of output: 4121
Include version in both Storybook fetch URL and cache key.
The CDN URL and lazy fetch cache key ignore the selected version, causing the component to always fetch the latest package.json and reuse Storybook data when switching versions. This results in mismatched metadata for the selected version. Include the version in the URL and fetch key, and guard the fetch to avoid running until version is available.
Suggested fix
-const { data: packageJson } = useFetch<{ storybook: { title: string; url: string } }>(() => {
- const url = `https://cdn.jsdelivr.net/npm/${packageName.value}/package.json`
- return url
-})
+const { data: packageJson } = useFetch<{ storybook?: { title: string; url: string } }>(() => {
+ if (!version.value) return null
+ return `https://cdn.jsdelivr.net/npm/${packageName.value}@${version.value}/package.json`
+})- () => {
- if (!packageJson.value?.storybook?.url) return ''
- return packageJson.value.storybook.url + '/index.json'
- },
+ () => {
+ if (!packageJson.value?.storybook?.url || !version.value) return ''
+ return `${packageJson.value.storybook.url}/index.json`
+ },
{
- key: computed(() => `storybook:${packageName.value}`),
+ key: computed(() => `storybook:${packageName.value}@${version.value}`),
server: 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.
| // Fetch package.json to get Storybook URL | |
| const { data: packageJson } = useFetch<{ storybook: { title: string; url: string } }>(() => { | |
| const url = `https://cdn.jsdelivr.net/npm/${packageName.value}/package.json` | |
| return url | |
| }) | |
| // Fetch Storybook index data | |
| const { data: storybookData, status: storybookStatus } = useLazyFetch<{ | |
| v: number | |
| entries: Record<string, StorybookEntry> | |
| }>( | |
| () => { | |
| if (!packageJson.value?.storybook?.url) return '' | |
| return packageJson.value.storybook.url + '/index.json' | |
| }, | |
| { | |
| key: computed(() => `storybook:${packageName.value}`), | |
| server: false, // Storybook URLs are usually client-side only | |
| }, | |
| // Fetch package.json to get Storybook URL | |
| const { data: packageJson } = useFetch<{ storybook?: { title: string; url: string } }>(() => { | |
| if (!version.value) return null | |
| return `https://cdn.jsdelivr.net/npm/${packageName.value}@${version.value}/package.json` | |
| }) | |
| // Fetch Storybook index data | |
| const { data: storybookData, status: storybookStatus } = useLazyFetch<{ | |
| v: number | |
| entries: Record<string, StorybookEntry> | |
| }>( | |
| () => { | |
| if (!packageJson.value?.storybook?.url || !version.value) return '' | |
| return `${packageJson.value.storybook.url}/index.json` | |
| }, | |
| { | |
| key: computed(() => `storybook:${packageName.value}@${version.value}`), | |
| server: false, // Storybook URLs are usually client-side only | |
| }, |
| // Auto-select first story if none specified | ||
| watch( | ||
| [storybookTree, () => parsedRoute.value.storyId], | ||
| ([tree, storyId]) => { | ||
| if (tree.length && !storyId) { | ||
| const first = getFirstStory(tree) | ||
| if (first?.storyId) { | ||
| navigateTo({ | ||
| name: 'stories', | ||
| params: { path: [...packageName.value.split('/'), 'v', version.value!] }, | ||
| query: { storyid: first.storyId }, | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
find . -name "*.vue" -path "*package-stories*" | head -20Repository: npmx-dev/npmx.dev
Length of output: 102
🏁 Script executed:
cat -n ./app/pages/package-stories/[...path].vueRepository: npmx-dev/npmx.dev
Length of output: 18210
🏁 Script executed:
find . -name "*.ts" -o -name "*.d.ts" | xargs grep -l "stories.*route\|path\+" | head -10Repository: npmx-dev/npmx.dev
Length of output: 443
🏁 Script executed:
find . -name "*.config.*" -o -name "*routes*" | head -20Repository: npmx-dev/npmx.dev
Length of output: 318
🏁 Script executed:
cat -n ./nuxt.config.ts | head -100Repository: npmx-dev/npmx.dev
Length of output: 3293
🏁 Script executed:
find . -name "*.d.ts" -path "*/app/*" | head -20Repository: npmx-dev/npmx.dev
Length of output: 43
🏁 Script executed:
find . -type f \( -name "*.ts" -o -name "*.d.ts" \) -exec grep -l "definePageMeta\|useRoute.*stories" {} \; | head -20Repository: npmx-dev/npmx.dev
Length of output: 43
🏁 Script executed:
find . -name "tsconfig.json" -o -name "tsconfig*.json"Repository: npmx-dev/npmx.dev
Length of output: 96
🏁 Script executed:
cat -n ./tsconfig.json | head -80Repository: npmx-dev/npmx.dev
Length of output: 506
🏁 Script executed:
grep -r "useRoute.*stories\|routes.*stories\|path.*\+.*path" . --include="*.ts" --include="*.vue" --include="*.d.ts" 2>/dev/null | head -20Repository: npmx-dev/npmx.dev
Length of output: 1937
🏁 Script executed:
cat -n ./app/pages/package-docs/[...path].vue | head -100Repository: npmx-dev/npmx.dev
Length of output: 3751
🏁 Script executed:
cat -n ./app/pages/package-code/[...path].vue | head -100Repository: npmx-dev/npmx.dev
Length of output: 4424
🏁 Script executed:
grep -A 5 "navigateTo" ./app/pages/package-code/[...path].vue | head -30Repository: npmx-dev/npmx.dev
Length of output: 43
🏁 Script executed:
grep -n "navigateTo\|as \[string" ./app/pages/package-docs/[...path].vueRepository: npmx-dev/npmx.dev
Length of output: 266
🏁 Script executed:
grep -B 2 -A 2 "as \[string" ./app/pages/package-docs/[...path].vue ./app/components/Code/DirectoryListing.vue ./app/components/Code/FileTree.vueRepository: npmx-dev/npmx.dev
Length of output: 1870
🏁 Script executed:
cd ./app/pages/package-stories && ls -laRepository: npmx-dev/npmx.dev
Length of output: 238
Add storyId guard at line 162 and apply type casting pattern for route params.
Line 162 is missing a storyId guard: change if (firstStoryInCategory) to if (firstStoryInCategory?.storyId) to prevent undefined errors when navigating. Additionally, the navigateTo params at lines 114, 165, and 178 should be typed consistently with other pages in the codebase (package-docs, package-code) using as [string, ...string[]] rather than .join('/').
Suggested fixes
Line 162:
- if (firstStoryInCategory) {
+ if (firstStoryInCategory?.storyId) {Lines 114, 165, 178 (apply type casting):
- params: { path: [...packageName.value.split('/'), 'v', version.value!] },
+ params: { path: [...packageName.value.split('/'), 'v', version.value!] as [string, ...string[]] },- params: { path: [...packageName.value.split('/'), 'v', newVersion] },
+ params: { path: [...packageName.value.split('/'), 'v', newVersion] as [string, ...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.
| // Auto-select first story if none specified | |
| watch( | |
| [storybookTree, () => parsedRoute.value.storyId], | |
| ([tree, storyId]) => { | |
| if (tree.length && !storyId) { | |
| const first = getFirstStory(tree) | |
| if (first?.storyId) { | |
| navigateTo({ | |
| name: 'stories', | |
| params: { path: [...packageName.value.split('/'), 'v', version.value!] }, | |
| query: { storyid: first.storyId }, | |
| }) | |
| // Auto-select first story if none specified | |
| watch( | |
| [storybookTree, () => parsedRoute.value.storyId], | |
| ([tree, storyId]) => { | |
| if (tree.length && !storyId) { | |
| const first = getFirstStory(tree) | |
| if (first?.storyId) { | |
| navigateTo({ | |
| name: 'stories', | |
| params: { path: [...packageName.value.split('/'), 'v', version.value!] as [string, ...string[]] }, | |
| query: { storyid: first.storyId }, | |
| }) |
🧰 Tools
🪛 GitHub Check: 💪 Type check
[failure] 112-112:
Argument of type '{ name: "stories"; params: { path: [...string[], string, string]; }; query: { storyid: string; }; }' is not assignable to parameter of type 'RouteLocationRaw | null | undefined'.
| // Navigation helper - build URL for a story | ||
| function getStoryUrl(story: StorybookFileTree): string { | ||
| const base = `/package-stories/${packageName.value}/v/${version.value}` | ||
| return `${base}?storyid=${story.storyId}` | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Breadcrumb links can produce ?storyid=undefined.
getStoryUrl always appends storyId, but intermediate crumbs don’t have one, so their links become invalid and prevent auto-selection. Consider only appending the query when a story id is present (or resolve a valid story id per crumb).
Suggested fix
function getStoryUrl(story: StorybookFileTree): string {
const base = `/package-stories/${packageName.value}/v/${version.value}`
- return `${base}?storyid=${story.storyId}`
+ return story.storyId ? `${base}?storyid=${story.storyId}` : base
}Also applies to: 295-306
| const playgroundLinks = computed(() => [ | ||
| ...readmeData.value.playgroundLinks, | ||
| ...(packageJson.value?.storybook | ||
| ? [ | ||
| { | ||
| url: packageJson.value.storybook.url, | ||
| provider: 'storybook', | ||
| providerName: 'Storybook', | ||
| label: 'Storybook', | ||
| }, | ||
| ] | ||
| : []), | ||
| ]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Inspect Storybook link rendering and any URL sanitisation.
fd -a 'PackagePlaygrounds\.vue$' app | xargs -I{} sed -n '1,220p' {}
fd -a 'LinkBase\.vue$' app | xargs -I{} sed -n '1,220p' {}
rg -n "sanitize|safeUrl|safe|http" app/components app/utilsRepository: npmx-dev/npmx.dev
Length of output: 8953
🏁 Script executed:
#!/bin/bash
# Get the full LinkBase.vue file
cat -n app/components/Link/Base.vue
# Find PackagePlaygrounds
fd -i 'playground' app/components --type fRepository: npmx-dev/npmx.dev
Length of output: 5095
🏁 Script executed:
#!/bin/bash
# Read the Playgrounds component
cat -n app/components/Package/Playgrounds.vueRepository: npmx-dev/npmx.dev
Length of output: 8282
Validate the Storybook URL before surfacing it.
package.json is untrusted input. The storybook.url is rendered directly in the Playgrounds component without any validation. If the URL property is missing, has a non-string value, or uses a non-http(s) scheme, you could emit a broken or unsafe link. LinkBase.vue's URL validation is not used here (raw <a> tags are used instead).
🔒 Suggested guard
-const playgroundLinks = computed(() => [
- ...readmeData.value.playgroundLinks,
- ...(packageJson.value?.storybook
- ? [
- {
- url: packageJson.value.storybook.url,
- provider: 'storybook',
- providerName: 'Storybook',
- label: 'Storybook',
- },
- ]
- : []),
-])
+const playgroundLinks = computed(() => {
+ const storybook = packageJson.value?.storybook
+ const storybookUrl =
+ storybook && typeof storybook.url === 'string' && /^https?:\/\//i.test(storybook.url)
+ ? storybook.url
+ : null
+
+ return [
+ ...readmeData.value.playgroundLinks,
+ ...(storybookUrl
+ ? [
+ {
+ url: storybookUrl,
+ provider: 'storybook',
+ providerName: 'Storybook',
+ label: 'Storybook',
+ },
+ ]
+ : []),
+ ]
+})| <LinkBase | ||
| v-if="packageJson?.storybook" | ||
| variant="button-secondary" | ||
| :to="{ name: 'stories', params: { path: [pkg.name, 'v', resolvedVersion] } }" | ||
| aria-keyshortcuts="." | ||
| classicon="i-carbon:code" | ||
| > | ||
| stories | ||
| </LinkBase> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
rg -n "package\.links\.stories" appRepository: npmx-dev/npmx.dev
Length of output: 43
🏁 Script executed:
rg -n "package\.links\." app/locales --max-count=20Repository: npmx-dev/npmx.dev
Length of output: 112
🏁 Script executed:
fd -e vue app/pages && cat -n app/pages/package/\[\[org\]\]/\[name\].vue | sed -n '654,680p'Repository: npmx-dev/npmx.dev
Length of output: 402
🏁 Script executed:
git ls-files | grep -E "(locale|i18n|translation)" | head -20Repository: npmx-dev/npmx.dev
Length of output: 529
🏁 Script executed:
git ls-files | grep -E "\.vue$" | grep -E "package.*\[org\].*\[name\]"Repository: npmx-dev/npmx.dev
Length of output: 189
🏁 Script executed:
fd -type d -name "locales" -o -name "lang" -o -name "i18n"Repository: npmx-dev/npmx.dev
Length of output: 231
🏁 Script executed:
cat -n app/pages/package/\[\[org\]\]/\[name\].vue | sed -n '654,680p'Repository: npmx-dev/npmx.dev
Length of output: 1235
🏁 Script executed:
rg -n "package\.links" i18n/locales/en-GB.jsonRepository: npmx-dev/npmx.dev
Length of output: 43
🏁 Script executed:
rg -n "stories" i18n/locales/en-GB.jsonRepository: npmx-dev/npmx.dev
Length of output: 43
🏁 Script executed:
cat i18n/locales/en-GB.json | head -200Repository: npmx-dev/npmx.dev
Length of output: 1674
🏁 Script executed:
cat -n app/pages/package/\[\[org\]\]/\[name\].vue | sed -n '620,700p'Repository: npmx-dev/npmx.dev
Length of output: 3695
🏁 Script executed:
rg -n "package\." i18n/locales/en-GB.json | head -50Repository: npmx-dev/npmx.dev
Length of output: 294
🏁 Script executed:
python3 << 'EOF'
import json
# Read the en-GB locale file
with open('i18n/locales/en-GB.json', 'r') as f:
data = json.load(f)
# Navigate to package.links if it exists
if 'package' in data and 'links' in data['package']:
print("package.links keys found:")
for key in data['package']['links']:
print(f" - {key}: {data['package']['links'][key]}")
else:
print("package.links structure:")
if 'package' in data:
print(f" Keys in package: {list(data['package'].keys())}")
else:
print(" No 'package' key found in en-GB.json")
EOFRepository: npmx-dev/npmx.dev
Length of output: 124
🏁 Script executed:
cat i18n/locales/en.json | python3 -m json.tool | grep -A 20 '"package"'Repository: npmx-dev/npmx.dev
Length of output: 3465
🏁 Script executed:
cat i18n/locales/en.json | python3 -m json.tool | grep -A 50 '"links"'Repository: npmx-dev/npmx.dev
Length of output: 4150
🏁 Script executed:
cat i18n/locales/en.json | python3 << 'EOF'
import json
import sys
data = json.load(sys.stdin)
# Navigate through the structure
if 'package' in data:
print("Found 'package' key")
if 'links' in data['package']:
print("Found 'package.links':")
print(json.dumps(data['package']['links'], indent=2))
else:
print("No 'package.links' found")
print("Available keys in package:", list(data['package'].keys()))
EOFRepository: npmx-dev/npmx.dev
Length of output: 781
🏁 Script executed:
rg -n "docs|code|compare" i18n/locales/en.json | head -20Repository: npmx-dev/npmx.dev
Length of output: 1118
Localise the label, fix the duplicate shortcut, and consider the icon.
The button text is hardcoded, and aria-keyshortcuts="." conflicts with the Code button. Additionally, package.links.stories does not exist in the locale files, so the i18n key would need to be added to all locale files alongside the localisation update. Consider also changing the icon from i-carbon:code to avoid duplication with the Code button.
✍️ Suggested tweak
- aria-keyshortcuts="."
- classicon="i-carbon:code"
+ classicon="i-carbon:code"
>
- stories
+ {{ $t('package.links.stories') }}
</LinkBase>📝 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.
| <LinkBase | |
| v-if="packageJson?.storybook" | |
| variant="button-secondary" | |
| :to="{ name: 'stories', params: { path: [pkg.name, 'v', resolvedVersion] } }" | |
| aria-keyshortcuts="." | |
| classicon="i-carbon:code" | |
| > | |
| stories | |
| </LinkBase> | |
| <LinkBase | |
| v-if="packageJson?.storybook" | |
| variant="button-secondary" | |
| :to="{ name: 'stories', params: { path: [pkg.name, 'v', resolvedVersion] } }" | |
| classicon="i-carbon:code" | |
| > | |
| {{ $t('package.links.stories') }} | |
| </LinkBase> |
| // Create story node | ||
| const storyNode: StorybookFileTree = { | ||
| name: storyName, | ||
| path: entry.title, | ||
| type: 'story', | ||
| storyId: entry.id, | ||
| story: entry, | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ensure story nodes have unique paths.
path: entry.title collides with the directory path and with other stories that share the same title, which can cause duplicate keys and unstable tree navigation. Include the story name (or id) in the path to make each story node unique.
Suggested fix
- const storyNode: StorybookFileTree = {
- name: storyName,
- path: entry.title,
+ const storyNode: StorybookFileTree = {
+ name: storyName,
+ path: `${entry.title}/${storyName}`,
type: 'story',
storyId: entry.id,
story: entry,
}| story?: string | ||
| /** Story parameters and configuration */ | ||
| parameters?: Record<string, any> | ||
| /** Story metadata */ | ||
| type?: 'story' | 'docs' | 'component' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
cat -n shared/types/storybook.ts | head -30Repository: npmx-dev/npmx.dev
Length of output: 1064
🌐 Web query:
TypeScript best practices anyvsunknown type safety
💡 Result:
any vs unknown: what to prefer (and why)
any= “turn off the checker here”. You can access any properties, call it, assign it anywhere, and TypeScript won’t protect you—errors can slip to runtime.anyalso tends to propagate (“contagious”) through your code. [1]unknown= “I don’t know yet, force me to prove it”. Anything can be assigned tounknown, but you can’t use it (property access/calls, etc.) until you narrow it (type guards,instanceof,typeof, schema validation, etc.). It’s explicitly described as the type-safe counterpart ofany. [2]
Best-practice guidance
-
Default to
unknownfor untrusted/unknown data (JSON, user input, external APIs,catcherrors, etc.), then narrow:function parse(input: string): unknown { return JSON.parse(input); } const v = parse("{}"); // unknown // v.x; // error if (typeof v === "object" && v !== null && "x" in v) { // now you can refine further }
Rationale:
unknownblocks unsafe usage until you validate. [2] -
Use
anyonly as a last-resort escape hatch, and keep it localized (one boundary), immediately converting it to something safer (unknown+ narrowing, or a real type). TypeScript’s own guidance calls out the type-safety tradeoff. [1] -
Turn on strictness + stop
anyfrom creeping in:- Enable
"strict": true/ at least"noImplicitAny": trueto catch implicitanys. (Note: it doesn’t prevent explicitany.) [5] - Add lint rules like
@typescript-eslint/no-explicit-any, which explicitly recommendsunknownas the safer alternative and can even auto-suggest switching. [4]
- Enable
-
Handle exceptions safely:
- Prefer
unknownincatchvariables viauseUnknownInCatchVariables, so you must verify what was thrown before using it (e.g.,err instanceof Error). [3]
- Prefer
Practical rule of thumb
- If you truly don’t know the type yet:
unknown+ narrow. [2] - If you know the type: model it (
interface/type/generics) instead of either. [4] - If you must bypass the type system temporarily: use
any, but isolate it and replace it ASAP. [1][4]
Sources: TypeScript docs on any and unknown [1][2], useUnknownInCatchVariables [3], and typescript-eslint guidance on avoiding any/preferring unknown [4][5].
Replace Record<string, any> with Record<string, unknown> for type safety.
The parameters field accepts external JSON data from Storybook's index, making it the exact scenario where unknown is preferred over any. Using unknown enforces that consumers validate the structure before access, preventing type-safety violations. This aligns with both TypeScript best practices and the coding guideline requiring strict type-safe code.
🔧 Suggested fix
- parameters?: Record<string, any>
+ parameters?: Record<string, unknown>📝 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.
| story?: string | |
| /** Story parameters and configuration */ | |
| parameters?: Record<string, any> | |
| /** Story metadata */ | |
| type?: 'story' | 'docs' | 'component' | |
| story?: string | |
| /** Story parameters and configuration */ | |
| parameters?: Record<string, unknown> | |
| /** Story metadata */ | |
| type?: 'story' | 'docs' | 'component' |
|
This is incredible!! I was testing out in a project and noticed that the MDX icon works for some entries and not for others, e.g. both of these should have the mdx icon: They're both |
closes #1298
I added both an integrated viewer and an external link just to see how it would feel.
The UI is really rough right now, and had a lot of assist from LLM, so I'll need to clean it up.