diff --git a/app/components/Package/Playgrounds.vue b/app/components/Package/Playgrounds.vue index 83a6cdefb..3a6e4d18b 100644 --- a/app/components/Package/Playgrounds.vue +++ b/app/components/Package/Playgrounds.vue @@ -17,6 +17,7 @@ const providerIcons: Record = { 'nuxt-new': 'i-simple-icons:nuxtdotjs', 'vite-new': 'i-simple-icons:vite', 'jsfiddle': 'i-carbon:code', + 'storybook': 'i-simple-icons:storybook', } // Map provider id to color class @@ -30,6 +31,7 @@ const providerColors: Record = { 'nuxt-new': 'text-provider-nuxt', 'vite-new': 'text-provider-vite', 'jsfiddle': 'text-provider-jsfiddle', + 'storybook': 'text-provider-storybook', } function getIcon(provider: string): string { diff --git a/app/components/Storybook/FileTree.vue b/app/components/Storybook/FileTree.vue new file mode 100644 index 000000000..8e4c34fc6 --- /dev/null +++ b/app/components/Storybook/FileTree.vue @@ -0,0 +1,129 @@ + + + diff --git a/app/components/Storybook/MobileTreeDrawer.vue b/app/components/Storybook/MobileTreeDrawer.vue new file mode 100644 index 000000000..89097439f --- /dev/null +++ b/app/components/Storybook/MobileTreeDrawer.vue @@ -0,0 +1,82 @@ + + + diff --git a/app/composables/useStoryTreeState.ts b/app/composables/useStoryTreeState.ts new file mode 100644 index 000000000..0b9f52186 --- /dev/null +++ b/app/composables/useStoryTreeState.ts @@ -0,0 +1,36 @@ +import { computed } from 'vue' +import { useState } from '#app' + +export function useStoryTreeState(baseUrl: string) { + const stateKey = computed(() => `npmx-story-tree${baseUrl}`) + + const expanded = useState>(stateKey.value, () => new Set()) + + function toggleDir(path: string) { + if (expanded.value.has(path)) { + expanded.value.delete(path) + } else { + expanded.value.add(path) + } + } + + function isExpanded(path: string) { + return expanded.value.has(path) + } + + function autoExpandAncestors(path: string) { + if (!path) return + const parts = path.split('/').filter(Boolean) + let prefix = '' + for (const part of parts) { + prefix = prefix ? `${prefix}/${part}` : part + expanded.value.add(prefix) + } + } + + return { + toggleDir, + isExpanded, + autoExpandAncestors, + } +} diff --git a/app/pages/package-stories/[...path].vue b/app/pages/package-stories/[...path].vue new file mode 100644 index 000000000..442dfb33d --- /dev/null +++ b/app/pages/package-stories/[...path].vue @@ -0,0 +1,428 @@ + + + diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index 913c54af3..c8472161b 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -54,6 +54,12 @@ if (import.meta.server) { assertValidPackageName(packageName.value) } +const { data: packageJson } = useLazyFetch<{ storybook?: { title: string; url: string } }>(() => { + const version = requestedVersion.value ?? 'latest' + const url = `https://cdn.jsdelivr.net/npm/${packageName.value}@${version}/package.json` + return url +}) + // Fetch README for specific version if requested, otherwise latest const { data: readmeData } = useLazyFetch( () => { @@ -64,6 +70,20 @@ const { data: readmeData } = useLazyFetch( { default: () => ({ html: '', md: '', playgroundLinks: [], toc: [] }) }, ) +const playgroundLinks = computed(() => [ + ...readmeData.value.playgroundLinks, + ...(packageJson.value?.storybook + ? [ + { + url: packageJson.value.storybook.url, + provider: 'storybook', + providerName: 'Storybook', + label: 'Storybook', + }, + ] + : []), +]) + //copy README file as Markdown const { copied: copiedReadme, copy: copyReadme } = useClipboard({ source: () => readmeData.value?.md ?? '', @@ -631,6 +651,15 @@ onKeyStroke( > {{ $t('package.links.code') }} + + stories + - + diff --git a/app/utils/storybook-tree.ts b/app/utils/storybook-tree.ts new file mode 100644 index 000000000..2643f397f --- /dev/null +++ b/app/utils/storybook-tree.ts @@ -0,0 +1,140 @@ +import type { StorybookEntry, StorybookFileTree } from '#shared/types' + +/** + * Transform flat Storybook entries into hierarchical tree structure + */ +export function transformStorybookEntries( + entries: Record, +): StorybookFileTree[] { + const tree: StorybookFileTree[] = [] + const dirMap = new Map() + + // Use entries in original order to preserve object key ordering + for (const [_id, entry] of Object.entries(entries)) { + // Parse title into path parts + // "Example/Button/Primary" -> ["Example", "Button", "Primary"] + if (!entry.title) continue + const parts = entry.title.split('/') + const storyName = entry.name + const storyPath = parts.join('/') || '' || '' + + // Create directories as needed + let currentPath = '' + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + if (!part) continue + currentPath = currentPath ? `${currentPath}/${part}` : part + + if (!dirMap.has(currentPath)) { + const dirNode: StorybookFileTree = { + name: part, + path: currentPath, + type: 'directory', + children: [], + } + dirMap.set(currentPath, dirNode) + + // Add to appropriate parent + if (i === 0) { + tree.push(dirNode) + } else { + const parentPath = parts.slice(0, i).join('/') || '' + const parent = dirMap.get(parentPath) + if (parent) { + parent.children!.push(dirNode) + } + } + } + } + + // Create story node + const storyNode: StorybookFileTree = { + name: storyName, + path: entry.title, + type: 'story', + storyId: entry.id, + story: entry, + } + + // Add story to its directory or root + if (storyPath) { + const parentDir = dirMap.get(storyPath) + if (parentDir) { + parentDir.children!.push(storyNode) + } + } else { + // Root level story + tree.push(storyNode) + } + } + + return tree +} + +/** + * Find a story by its ID in the tree + */ +export function findStoryById( + tree: StorybookFileTree[], + storyId: string, +): StorybookFileTree | null { + for (const node of tree) { + if (node.type === 'story' && node.storyId === storyId) { + return node + } + if (node.type === 'directory' && node.children) { + const found = findStoryById(node.children, storyId) + if (found) return found + } + } + return null +} + +/** + * Get the first story from the tree (for default selection) + */ +export function getFirstStory(tree: StorybookFileTree[]): StorybookFileTree | null { + for (const node of tree) { + if (node.type === 'story') { + return node + } + if (node.type === 'directory' && node.children) { + const found = getFirstStory(node.children) + if (found) return found + } + } + return null +} + +/** + * Get the first story from a specific directory + */ +export function getFirstStoryInDirectory(directory: StorybookFileTree): StorybookFileTree | null { + if (directory.type !== 'directory' || !directory.children) { + return null + } + return getFirstStory(directory.children) +} + +/** + * Build breadcrumb path segments for a story + */ +export function getStoryBreadcrumbs( + story: StorybookFileTree, +): { name: string; path: string; storyId?: string }[] { + const parts = story.path.split('/') + const result: { name: string; path: string; storyId?: string }[] = [] + + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + if (!part) continue + const path = parts.slice(0, i + 1).join('/') + result.push({ + name: part, + path, + storyId: i === parts.length - 1 ? story.storyId || undefined : undefined, + }) + } + + return result +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index f0f28b1c2..72f7e4955 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -631,6 +631,25 @@ }, "file_path": "File path" }, + "stories": { + "stories_label": "Stories", + "story_path": "Story path", + "root": "Stories", + "version_required": "Version is required to browse stories", + "go_to_package": "Go to package", + "loading_stories": "Loading Storybook stories...", + "no_storybook_found": "No Storybook found", + "check_package_json": "Check if this package has Storybook configuration in package.json", + "back_to_package": "Back to package", + "failed_to_load_stories": "Failed to load Storybook stories", + "storybook_unavailable": "The Storybook instance may be unavailable or misconfigured", + "open_storybook": "Open Storybook", + "story": "Story", + "open_in_storybook": "Open in Storybook", + "select_story": "Select a story from the tree to view it here", + "toggle_tree": "Toggle story tree", + "close_tree": "Close story tree" + }, "badges": { "provenance": { "verified": "verified", diff --git a/i18n/schema.json b/i18n/schema.json index 1b2ffaa8f..493412250 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1897,6 +1897,63 @@ }, "additionalProperties": false }, + "stories": { + "type": "object", + "properties": { + "stories_label": { + "type": "string" + }, + "story_path": { + "type": "string" + }, + "root": { + "type": "string" + }, + "version_required": { + "type": "string" + }, + "go_to_package": { + "type": "string" + }, + "loading_stories": { + "type": "string" + }, + "no_storybook_found": { + "type": "string" + }, + "check_package_json": { + "type": "string" + }, + "back_to_package": { + "type": "string" + }, + "failed_to_load_stories": { + "type": "string" + }, + "storybook_unavailable": { + "type": "string" + }, + "open_storybook": { + "type": "string" + }, + "story": { + "type": "string" + }, + "open_in_storybook": { + "type": "string" + }, + "select_story": { + "type": "string" + }, + "toggle_tree": { + "type": "string" + }, + "close_tree": { + "type": "string" + } + }, + "additionalProperties": false + }, "badges": { "type": "object", "properties": { diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json index 0655549a9..25695dada 100644 --- a/lunaria/files/en-GB.json +++ b/lunaria/files/en-GB.json @@ -630,6 +630,25 @@ }, "file_path": "File path" }, + "stories": { + "stories_label": "Stories", + "story_path": "Story path", + "root": "Stories", + "version_required": "Version is required to browse stories", + "go_to_package": "Go to package", + "loading_stories": "Loading Storybook stories...", + "no_storybook_found": "No Storybook found", + "check_package_json": "Check if this package has Storybook configuration in package.json", + "back_to_package": "Back to package", + "failed_to_load_stories": "Failed to load Storybook stories", + "storybook_unavailable": "The Storybook instance may be unavailable or misconfigured", + "open_storybook": "Open Storybook", + "story": "Story", + "open_in_storybook": "Open in Storybook", + "select_story": "Select a story from the tree to view it here", + "toggle_tree": "Toggle story tree", + "close_tree": "Close story tree" + }, "badges": { "provenance": { "verified": "verified", diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index 7fb0060ef..d9282873d 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -630,6 +630,25 @@ }, "file_path": "File path" }, + "stories": { + "stories_label": "Stories", + "story_path": "Story path", + "root": "Stories", + "version_required": "Version is required to browse stories", + "go_to_package": "Go to package", + "loading_stories": "Loading Storybook stories...", + "no_storybook_found": "No Storybook found", + "check_package_json": "Check if this package has Storybook configuration in package.json", + "back_to_package": "Back to package", + "failed_to_load_stories": "Failed to load Storybook stories", + "storybook_unavailable": "The Storybook instance may be unavailable or misconfigured", + "open_storybook": "Open Storybook", + "story": "Story", + "open_in_storybook": "Open in Storybook", + "select_story": "Select a story from the tree to view it here", + "toggle_tree": "Toggle story tree", + "close_tree": "Close story tree" + }, "badges": { "provenance": { "verified": "verified", diff --git a/shared/types/index.ts b/shared/types/index.ts index 96f55a32b..c97553f28 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -8,3 +8,4 @@ export * from './deno-doc' export * from './i18n-status' export * from './comparison' export * from './skills' +export * from './storybook' diff --git a/shared/types/storybook.ts b/shared/types/storybook.ts new file mode 100644 index 000000000..c026def04 --- /dev/null +++ b/shared/types/storybook.ts @@ -0,0 +1,81 @@ +/** + * Storybook API Types + * Types for Storybook index.json responses and story navigation + */ + +/** + * Individual story entry from Storybook's index.json + */ +export interface StorybookEntry { + /** Unique identifier for the story (e.g., "example-button--primary") */ + id: string + /** Display name of the story */ + name: string + /** Full title/path (e.g., "Example/Button/Primary") */ + title: string + /** Import path for the story file */ + importPath?: string + /** Story tags (e.g., ["autodocs", "play-fn"]) */ + tags?: string[] + /** Component kind/group */ + kind?: string + /** Story name (alternative to 'name') */ + story?: string + /** Story parameters and configuration */ + parameters?: Record + /** Story metadata */ + type?: 'story' | 'docs' | 'component' +} + +/** + * Storybook index.json response structure + */ +export interface StorybookIndexResponse { + /** Storybook version */ + v?: string + /** All story entries keyed by their ID */ + entries: Record + /** Global metadata about the Storybook instance */ + metadata?: { + /** Storybook version info */ + storybook?: { + version?: string + configDir?: string + } + /** Package information */ + packageJson?: { + name?: string + version?: string + dependencies?: Record + } + } +} + +/** + * Tree node for Storybook story navigation + * Similar structure to PackageFileTree but for stories + */ +export interface StorybookFileTree { + /** Story or group name */ + name: string + /** Full path from root */ + path: string + /** Node type */ + type: 'story' | 'directory' + /** Story ID (only for stories) */ + storyId?: string + /** Story entry data (only for stories) */ + story?: StorybookEntry + /** Child nodes (only for directories) */ + children?: StorybookFileTree[] +} + +/** + * Response for Storybook tree API + */ +export interface StorybookTreeResponse { + package: string + version: string + storybookUrl: string + tree: StorybookFileTree[] +} diff --git a/uno.config.ts b/uno.config.ts index b75446763..26bf8db80 100644 --- a/uno.config.ts +++ b/uno.config.ts @@ -98,6 +98,7 @@ export default defineConfig({ nuxt: '#00DC82', vite: '#646CFF', jsfiddle: '#0084FF', + storybook: '#FF4785', }, }, animation: {