From c86cfcce089e916ee71084574072b43bd367c5b9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 5 Mar 2026 14:08:43 -0600 Subject: [PATCH] Add Fairytale component showcase for Holocene design system - Component registry with lazy loading, prop definitions, and cva variant extraction - Prop variations view showing all possible values for boolean and union props - Vite plugin scanning component usage across codebase with line numbers - VS Code links for usage files opening at the import line - Dark mode support using DarkMode component and semantic design tokens - Sidebar nav using Holocene VerticalNav, Input, and DarkModeMenu components - Filter out context-dependent components that can't render in isolation --- plugins/vite-plugin-component-usage.ts | 165 +++++ src/lib/holocene/fairytale-registry.ts | 678 ++++++++++++++++++ src/routes/fairytale/+layout.svelte | 103 +++ src/routes/fairytale/+layout.ts | 9 + src/routes/fairytale/+page.svelte | 53 ++ src/routes/fairytale/[component]/+page.svelte | 209 ++++++ src/routes/fairytale/[component]/+page.ts | 15 + .../_components/lazy-component.svelte | 86 +++ src/virtual-modules.d.ts | 10 + vite.config.ts | 2 + 10 files changed, 1330 insertions(+) create mode 100644 plugins/vite-plugin-component-usage.ts create mode 100644 src/lib/holocene/fairytale-registry.ts create mode 100644 src/routes/fairytale/+layout.svelte create mode 100644 src/routes/fairytale/+layout.ts create mode 100644 src/routes/fairytale/+page.svelte create mode 100644 src/routes/fairytale/[component]/+page.svelte create mode 100644 src/routes/fairytale/[component]/+page.ts create mode 100644 src/routes/fairytale/_components/lazy-component.svelte create mode 100644 src/virtual-modules.d.ts diff --git a/plugins/vite-plugin-component-usage.ts b/plugins/vite-plugin-component-usage.ts new file mode 100644 index 0000000000..4fd74c9a58 --- /dev/null +++ b/plugins/vite-plugin-component-usage.ts @@ -0,0 +1,165 @@ +import fs from 'fs'; +import path from 'path'; + +import type { Plugin, ViteDevServer } from 'vite'; + +const VIRTUAL_MODULE_ID = 'virtual:holocene-usage'; +const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID; + +type UsageFile = { path: string; line: number }; +type UsageData = Record; + +export function componentUsagePlugin(): Plugin { + const srcDir = path.resolve('src'); + const componentsJsonPath = path.resolve( + 'src/lib/holocene/holocene-components.json', + ); + let usageCache: UsageData = {}; + let componentKeys: string[] = []; + let server: ViteDevServer | undefined; + + function getComponentImportPatterns(key: string): RegExp[] { + const patterns: RegExp[] = []; + patterns.push(new RegExp(`from ['"]\\$lib/holocene/${key}\\.svelte['"]`)); + patterns.push(new RegExp(`from ['"]\\$holocene/${key}\\.svelte['"]`)); + patterns.push( + new RegExp(`from ['"]\\$lib/holocene/[^'"]*/${key}\\.svelte['"]`), + ); + patterns.push( + new RegExp(`from ['"]\\$holocene/[^'"]*/${key}\\.svelte['"]`), + ); + return patterns; + } + + function scanFile(filePath: string, content?: string): Map { + const matches = new Map(); + if (!content) { + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch { + return matches; + } + } + const lines = content.split('\n'); + for (const key of componentKeys) { + const importPatterns = getComponentImportPatterns(key); + for (let i = 0; i < lines.length; i++) { + if (importPatterns.some((pattern) => pattern.test(lines[i]))) { + matches.set(key, i + 1); + break; + } + } + } + return matches; + } + + function getAllSvelteFiles(dir: string): string[] { + const files: string[] = []; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if ( + entry.name === 'node_modules' || + entry.name === '.svelte-kit' || + entry.name === 'dist' || + entry.name === 'build' + ) + continue; + files.push(...getAllSvelteFiles(fullPath)); + } else if ( + entry.name.endsWith('.svelte') && + !entry.name.endsWith('.stories.svelte') + ) { + const rel = path.relative(srcDir, fullPath); + if (!rel.startsWith('routes/fairytale')) { + files.push(fullPath); + } + } + } + return files; + } + + function fullScan(): void { + try { + const json = JSON.parse(fs.readFileSync(componentsJsonPath, 'utf-8')); + componentKeys = Object.keys(json); + } catch { + componentKeys = []; + } + + usageCache = {}; + for (const key of componentKeys) { + usageCache[key] = { count: 0, files: [] }; + } + + const svelteFiles = getAllSvelteFiles(srcDir); + for (const file of svelteFiles) { + const matches = scanFile(file); + const relPath = path.relative(srcDir, file); + for (const [key, line] of matches) { + usageCache[key].count++; + usageCache[key].files.push({ path: relPath, line }); + } + } + } + + function updateFileInCache(filePath: string, content?: string): void { + const relPath = path.relative(srcDir, filePath); + + for (const key of componentKeys) { + const idx = usageCache[key].files.findIndex((f) => f.path === relPath); + if (idx !== -1) { + usageCache[key].files.splice(idx, 1); + usageCache[key].count--; + } + } + + const matches = scanFile(filePath, content); + for (const [key, line] of matches) { + if (!usageCache[key]) { + usageCache[key] = { count: 0, files: [] }; + } + usageCache[key].count++; + usageCache[key].files.push({ path: relPath, line }); + } + } + + return { + name: 'holocene-component-usage', + buildStart() { + fullScan(); + }, + configureServer(s) { + server = s; + }, + resolveId(id) { + if (id === VIRTUAL_MODULE_ID) { + return RESOLVED_VIRTUAL_MODULE_ID; + } + }, + load(id) { + if (id === RESOLVED_VIRTUAL_MODULE_ID) { + const data = { basePath: srcDir, usage: usageCache }; + return `export default ${JSON.stringify(data)};`; + } + }, + handleHotUpdate({ file, read }) { + if (!file.endsWith('.svelte') || file.endsWith('.stories.svelte')) return; + const rel = path.relative(srcDir, file); + if (rel.startsWith('routes/fairytale')) return; + + Promise.resolve(read()).then((content) => { + updateFileInCache(file, content); + if (server) { + const mod = server.moduleGraph.getModuleById( + RESOLVED_VIRTUAL_MODULE_ID, + ); + if (mod) { + server.moduleGraph.invalidateModule(mod); + } + } + }); + }, + }; +} diff --git a/src/lib/holocene/fairytale-registry.ts b/src/lib/holocene/fairytale-registry.ts new file mode 100644 index 0000000000..1de76733bc --- /dev/null +++ b/src/lib/holocene/fairytale-registry.ts @@ -0,0 +1,678 @@ +import type { Component } from 'svelte'; + +export type RegistryEntry = { + load: () => Promise; + defaultProps: Record; + children: string; + filePath: string; + category: string; + requiresContext: boolean; + variants?: Record; +}; + +export const componentRegistry: Record = { + accordion: { + load: () => import('$lib/holocene/accordion/accordion.svelte'), + defaultProps: { title: 'Example Accordion' }, + children: 'Accordion content goes here.', + filePath: 'accordion/accordion.svelte', + category: 'Layout', + requiresContext: false, + }, + alert: { + load: () => import('$lib/holocene/alert.svelte'), + defaultProps: { intent: 'info', title: 'Alert Title' }, + children: 'This is an alert message.', + filePath: 'alert.svelte', + category: 'Feedback', + requiresContext: false, + }, + badge: { + load: () => import('$lib/holocene/badge.svelte'), + defaultProps: {}, + children: 'Badge', + filePath: 'badge.svelte', + category: 'Data Display', + requiresContext: false, + variants: { + type: [ + 'default', + 'primary', + 'secondary', + 'warning', + 'success', + 'danger', + 'count', + 'subtle', + ], + }, + }, + button: { + load: () => import('$lib/holocene/button.svelte'), + defaultProps: { variant: 'primary' }, + children: 'Click me', + filePath: 'button.svelte', + category: 'Buttons', + requiresContext: false, + variants: { + variant: ['primary', 'secondary', 'destructive', 'ghost', 'table-header'], + size: ['xs', 'sm', 'md'], + }, + }, + calendar: { + load: () => import('$lib/holocene/calendar.svelte'), + defaultProps: { date: undefined, month: undefined, year: undefined }, + children: '', + filePath: 'calendar.svelte', + category: 'Layout', + requiresContext: false, + }, + card: { + load: () => import('$lib/holocene/card.svelte'), + defaultProps: {}, + children: 'Card content goes here.', + filePath: 'card.svelte', + category: 'Layout', + requiresContext: false, + }, + checkbox: { + load: () => import('$lib/holocene/checkbox.svelte'), + defaultProps: { id: 'demo', label: 'Example Label', checked: false }, + children: '', + filePath: 'checkbox.svelte', + category: 'Inputs', + requiresContext: false, + }, + chip: { + load: () => import('$lib/holocene/chip.svelte'), + defaultProps: { intent: 'default' }, + children: 'Chip label', + filePath: 'chip.svelte', + category: 'Data Display', + requiresContext: false, + }, + 'code-block': { + load: () => import('$lib/holocene/code-block.svelte'), + defaultProps: { content: '{ "example": true }' }, + children: '', + filePath: 'code-block.svelte', + category: 'Data Display', + requiresContext: false, + }, + 'date-picker': { + load: () => import('$lib/holocene/date-picker.svelte'), + defaultProps: {}, + children: '', + filePath: 'date-picker.svelte', + category: 'Inputs', + requiresContext: false, + }, + 'day-of-month-picker': { + load: () => import('$lib/holocene/day-of-month-picker.svelte'), + defaultProps: { daysOfMonth: [] }, + children: '', + filePath: 'day-of-month-picker.svelte', + category: 'Inputs', + requiresContext: false, + }, + 'day-of-week-picker': { + load: () => import('$lib/holocene/day-of-week-picker.svelte'), + defaultProps: { daysOfWeek: [] }, + children: '', + filePath: 'day-of-week-picker.svelte', + category: 'Inputs', + requiresContext: false, + }, + drawer: { + load: () => import('$lib/holocene/drawer.svelte'), + defaultProps: { flyin: false, flyout: false, onClose: () => {} }, + children: '', + filePath: 'drawer.svelte', + category: 'Overlays', + requiresContext: false, + }, + 'empty-state': { + load: () => import('$lib/holocene/empty-state.svelte'), + defaultProps: { title: 'Nothing to show' }, + children: '', + filePath: 'empty-state.svelte', + category: 'Feedback', + requiresContext: false, + }, + 'error-boundary': { + load: () => import('$lib/holocene/error-boundary.svelte'), + defaultProps: {}, + children: '', + filePath: 'error-boundary.svelte', + category: 'Feedback', + requiresContext: false, + }, + error: { + load: () => import('$lib/holocene/error.svelte'), + defaultProps: {}, + children: '', + filePath: 'error.svelte', + category: 'Feedback', + requiresContext: false, + }, + 'feature-tag': { + load: () => import('$lib/holocene/feature-tag.svelte'), + defaultProps: { feature: 'beta' }, + children: '', + filePath: 'feature-tag.svelte', + category: 'Data Display', + requiresContext: false, + }, + 'filter-or-copy-buttons': { + load: () => import('$lib/holocene/filter-or-copy-buttons.svelte'), + defaultProps: { content: 'example' }, + children: '', + filePath: 'filter-or-copy-buttons.svelte', + category: 'Data Display', + requiresContext: false, + }, + 'icon-button': { + load: () => import('$lib/holocene/icon-button.svelte'), + defaultProps: {}, + children: '', + filePath: 'icon-button.svelte', + category: 'Buttons', + requiresContext: false, + variants: { + variant: ['primary', 'secondary', 'ghost'], + }, + }, + 'chip-input': { + load: () => import('$lib/holocene/input/chip-input.svelte'), + defaultProps: { id: 'demo', chips: [] }, + children: '', + filePath: 'input/chip-input.svelte', + category: 'Inputs', + requiresContext: false, + }, + input: { + load: () => import('$lib/holocene/input/input.svelte'), + defaultProps: { id: 'demo', value: 'example', label: 'Example Label' }, + children: '', + filePath: 'input/input.svelte', + category: 'Inputs', + requiresContext: false, + }, + 'number-input': { + load: () => import('$lib/holocene/input/number-input.svelte'), + defaultProps: { id: 'demo', value: '0', label: 'Example Label' }, + children: '', + filePath: 'input/number-input.svelte', + category: 'Inputs', + requiresContext: false, + }, + 'range-input': { + load: () => import('$lib/holocene/input/range-input.svelte'), + defaultProps: { + id: 'demo', + label: 'Example Label', + min: 0, + max: 100, + value: 50, + }, + children: '', + filePath: 'input/range-input.svelte', + category: 'Inputs', + requiresContext: false, + }, + 'arrow-down': { + load: () => import('$lib/holocene/keyboard-shortcut/arrow-down.svelte'), + defaultProps: {}, + children: '', + filePath: 'keyboard-shortcut/arrow-down.svelte', + category: 'Layout', + requiresContext: false, + }, + 'arrow-left': { + load: () => import('$lib/holocene/keyboard-shortcut/arrow-left.svelte'), + defaultProps: {}, + children: '', + filePath: 'keyboard-shortcut/arrow-left.svelte', + category: 'Layout', + requiresContext: false, + }, + 'arrow-right': { + load: () => import('$lib/holocene/keyboard-shortcut/arrow-right.svelte'), + defaultProps: {}, + children: '', + filePath: 'keyboard-shortcut/arrow-right.svelte', + category: 'Layout', + requiresContext: false, + }, + 'arrow-up': { + load: () => import('$lib/holocene/keyboard-shortcut/arrow-up.svelte'), + defaultProps: {}, + children: '', + filePath: 'keyboard-shortcut/arrow-up.svelte', + category: 'Layout', + requiresContext: false, + }, + shortcut: { + load: () => import('$lib/holocene/keyboard-shortcut/shortcut.svelte'), + defaultProps: {}, + children: '', + filePath: 'keyboard-shortcut/shortcut.svelte', + category: 'Layout', + requiresContext: false, + }, + 'labs-mode-guard': { + load: () => import('$lib/holocene/labs-mode-guard.svelte'), + defaultProps: {}, + children: '', + filePath: 'labs-mode-guard.svelte', + category: 'Layout', + requiresContext: true, + }, + link: { + load: () => import('$lib/holocene/link.svelte'), + defaultProps: { href: '#' }, + children: 'Link text', + filePath: 'link.svelte', + category: 'Navigation', + requiresContext: false, + }, + loading: { + load: () => import('$lib/holocene/loading.svelte'), + defaultProps: {}, + children: '', + filePath: 'loading.svelte', + category: 'Feedback', + requiresContext: false, + }, + logo: { + load: () => import('$lib/holocene/logo.svelte'), + defaultProps: { isCloud: false }, + children: '', + filePath: 'logo.svelte', + category: 'Layout', + requiresContext: false, + }, + 'main-content-container': { + load: () => import('$lib/holocene/main-content-container.svelte'), + defaultProps: {}, + children: '', + filePath: 'main-content-container.svelte', + category: 'Layout', + requiresContext: false, + }, + modal: { + load: () => import('$lib/holocene/modal.svelte'), + defaultProps: { id: 'demo' }, + children: 'Modal content', + filePath: 'modal.svelte', + category: 'Overlays', + requiresContext: false, + }, + 'month-picker': { + load: () => import('$lib/holocene/month-picker.svelte'), + defaultProps: { months: [] }, + children: '', + filePath: 'month-picker.svelte', + category: 'Inputs', + requiresContext: false, + }, + 'orderable-list-item': { + load: () => + import('$lib/holocene/orderable-list/orderable-list-item.svelte'), + defaultProps: { label: 'Example Label' }, + children: '', + filePath: 'orderable-list/orderable-list-item.svelte', + category: 'Data Display', + requiresContext: false, + }, + 'orderable-list': { + load: () => import('$lib/holocene/orderable-list/orderable-list.svelte'), + defaultProps: {}, + children: '', + filePath: 'orderable-list/orderable-list.svelte', + category: 'Data Display', + requiresContext: false, + }, + 'page-transition': { + load: () => import('$lib/holocene/page-transition.svelte'), + defaultProps: {}, + children: '', + filePath: 'page-transition.svelte', + category: 'Layout', + requiresContext: false, + }, + pagination: { + load: () => import('$lib/holocene/pagination.svelte'), + defaultProps: { items: [] }, + children: '', + filePath: 'pagination.svelte', + category: 'Navigation', + requiresContext: false, + }, + 'menu-button': { + load: () => import('$lib/holocene/menu/menu-button.svelte'), + defaultProps: { controls: 'demo-menu' }, + children: '', + filePath: 'menu/menu-button.svelte', + category: 'Buttons', + requiresContext: true, + }, + 'menu-container': { + load: () => import('$lib/holocene/menu/menu-container.svelte'), + defaultProps: {}, + children: '', + filePath: 'menu/menu-container.svelte', + category: 'Overlays', + requiresContext: true, + }, + 'menu-divider': { + load: () => import('$lib/holocene/menu/menu-divider.svelte'), + defaultProps: {}, + children: '', + filePath: 'menu/menu-divider.svelte', + category: 'Overlays', + requiresContext: false, + }, + 'menu-item': { + load: () => import('$lib/holocene/menu/menu-item.svelte'), + defaultProps: {}, + children: 'Menu Item', + filePath: 'menu/menu-item.svelte', + category: 'Overlays', + requiresContext: true, + }, + menu: { + load: () => import('$lib/holocene/menu/menu.svelte'), + defaultProps: { id: 'demo' }, + children: '', + filePath: 'menu/menu.svelte', + category: 'Overlays', + requiresContext: true, + }, + 'progress-bar': { + load: () => import('$lib/holocene/progress-bar.svelte'), + defaultProps: {}, + children: '', + filePath: 'progress-bar.svelte', + category: 'Feedback', + requiresContext: false, + }, + 'filter-select': { + load: () => import('$lib/holocene/select/filter-select.svelte'), + defaultProps: { value: 'example' }, + children: '', + filePath: 'select/filter-select.svelte', + category: 'Inputs', + requiresContext: false, + }, + 'option-group': { + load: () => import('$lib/holocene/select/option-group.svelte'), + defaultProps: { label: 'Example Label' }, + children: '', + filePath: 'select/option-group.svelte', + category: 'Inputs', + requiresContext: false, + }, + option: { + load: () => import('$lib/holocene/select/option.svelte'), + defaultProps: { value: 'example' }, + children: '', + filePath: 'select/option.svelte', + category: 'Inputs', + requiresContext: true, + }, + select: { + load: () => import('$lib/holocene/select/select.svelte'), + defaultProps: { id: 'demo' }, + children: '', + filePath: 'select/select.svelte', + category: 'Inputs', + requiresContext: false, + }, + 'simple-option': { + load: () => import('$lib/holocene/select/simple-option.svelte'), + defaultProps: {}, + children: '', + filePath: 'select/simple-option.svelte', + category: 'Inputs', + requiresContext: false, + }, + 'simple-select': { + load: () => import('$lib/holocene/select/simple-select.svelte'), + defaultProps: { id: 'demo', value: 'example' }, + children: '', + filePath: 'select/simple-select.svelte', + category: 'Inputs', + requiresContext: false, + }, + index: { + load: () => import('$lib/holocene/select/select.svelte'), + defaultProps: {}, + children: '', + filePath: 'select/select.svelte', + category: 'Inputs', + requiresContext: false, + }, + table: { + load: () => import('$lib/holocene/table/table.svelte'), + defaultProps: { variant: 'simple' }, + children: '', + filePath: 'table/table.svelte', + category: 'Data Display', + requiresContext: false, + }, + 'split-button': { + load: () => import('$lib/holocene/split-button.svelte'), + defaultProps: { id: 'demo' }, + children: 'Click me', + filePath: 'split-button.svelte', + category: 'Buttons', + requiresContext: false, + variants: { + variant: ['primary', 'destructive'], + }, + }, + 'tab-list': { + load: () => import('$lib/holocene/tab/tab-list.svelte'), + defaultProps: { label: 'Example Label' }, + children: '', + filePath: 'tab/tab-list.svelte', + category: 'Navigation', + requiresContext: true, + }, + 'tab-panel': { + load: () => import('$lib/holocene/tab/tab-panel.svelte'), + defaultProps: { id: 'demo', tabId: 'demo-tab' }, + children: '', + filePath: 'tab/tab-panel.svelte', + category: 'Navigation', + requiresContext: true, + }, + tab: { + load: () => import('$lib/holocene/tab/tab.svelte'), + defaultProps: { label: 'Example Label', id: 'demo' }, + children: '', + filePath: 'tab/tab.svelte', + category: 'Navigation', + requiresContext: true, + }, + tabs: { + load: () => import('$lib/holocene/tab/tabs.svelte'), + defaultProps: {}, + children: '', + filePath: 'tab/tabs.svelte', + category: 'Navigation', + requiresContext: false, + }, + 'table-header-row': { + load: () => import('$lib/holocene/table/table-header-row.svelte'), + defaultProps: {}, + children: '', + filePath: 'table/table-header-row.svelte', + category: 'Data Display', + requiresContext: false, + }, + 'table-row': { + load: () => import('$lib/holocene/table/table-row.svelte'), + defaultProps: {}, + children: '', + filePath: 'table/table-row.svelte', + category: 'Data Display', + requiresContext: false, + }, + textarea: { + load: () => import('$lib/holocene/textarea.svelte'), + defaultProps: { id: 'demo', value: 'example', label: 'Example Label' }, + children: '', + filePath: 'textarea.svelte', + category: 'Inputs', + requiresContext: false, + }, + 'time-picker': { + load: () => import('$lib/holocene/time-picker.svelte'), + defaultProps: {}, + children: '', + filePath: 'time-picker.svelte', + category: 'Inputs', + requiresContext: false, + }, + toast: { + load: () => import('$lib/holocene/toast.svelte'), + defaultProps: { id: 'demo', variant: 'success' }, + children: 'Toast message', + filePath: 'toast.svelte', + category: 'Feedback', + requiresContext: false, + variants: { + variant: ['primary', 'success', 'error', 'info', 'warning'], + }, + }, + toaster: { + load: () => import('$lib/holocene/toaster.svelte'), + defaultProps: { pop: () => {}, toasts: [] }, + children: '', + filePath: 'toaster.svelte', + category: 'Feedback', + requiresContext: true, + }, + 'toggle-button': { + load: () => import('$lib/holocene/toggle-button/toggle-button.svelte'), + defaultProps: { active: false }, + children: 'Click me', + filePath: 'toggle-button/toggle-button.svelte', + category: 'Buttons', + requiresContext: false, + }, + 'toggle-buttons': { + load: () => import('$lib/holocene/toggle-button/toggle-buttons.svelte'), + defaultProps: {}, + children: '', + filePath: 'toggle-button/toggle-buttons.svelte', + category: 'Buttons', + requiresContext: true, + }, + 'toggle-switch': { + load: () => import('$lib/holocene/toggle-switch.svelte'), + defaultProps: { id: 'demo', checked: false }, + children: '', + filePath: 'toggle-switch.svelte', + category: 'Inputs', + requiresContext: false, + }, + tooltip: { + load: () => import('$lib/holocene/tooltip.svelte'), + defaultProps: { text: 'Tooltip text' }, + children: 'Hover me', + filePath: 'tooltip.svelte', + category: 'Overlays', + requiresContext: false, + }, + 'collapsible-divider': { + load: () => + import('$lib/holocene/collapsible-divider/collapsible-divider.svelte'), + defaultProps: {}, + children: '', + filePath: 'collapsible-divider/collapsible-divider.svelte', + category: 'Layout', + requiresContext: false, + }, + combobox: { + load: () => import('$lib/holocene/combobox/combobox.svelte'), + defaultProps: {}, + children: '', + filePath: 'combobox/combobox.svelte', + category: 'Inputs', + requiresContext: false, + variants: { + variant: ['default', 'ghost'], + }, + }, + 'radio-input': { + load: () => import('$lib/holocene/radio-input/radio-input.svelte'), + defaultProps: { id: 'demo', value: 'option1', label: 'Example Option' }, + children: '', + filePath: 'radio-input/radio-input.svelte', + category: 'Inputs', + requiresContext: false, + }, + 'pill-container': { + load: () => import('$lib/holocene/pill-container/pill-container.svelte'), + defaultProps: {}, + children: '', + filePath: 'pill-container/pill-container.svelte', + category: 'Data Display', + requiresContext: false, + }, + 'vertical-nav': { + load: () => import('$lib/holocene/vertical-nav/vertical-nav.svelte'), + defaultProps: {}, + children: '', + filePath: 'vertical-nav/vertical-nav.svelte', + category: 'Navigation', + requiresContext: true, + }, + 'duration-input': { + load: () => import('$lib/holocene/duration-input/duration-input.svelte'), + defaultProps: {}, + children: '', + filePath: 'duration-input/duration-input.svelte', + category: 'Inputs', + requiresContext: false, + }, + 'markdown-editor': { + load: () => import('$lib/holocene/markdown-editor/markdown-editor.svelte'), + defaultProps: {}, + children: '', + filePath: 'markdown-editor/markdown-editor.svelte', + category: 'Inputs', + requiresContext: false, + }, + portal: { + load: () => import('$lib/holocene/portal/portal.svelte'), + defaultProps: {}, + children: '', + filePath: 'portal/portal.svelte', + category: 'Overlays', + requiresContext: false, + }, + skeleton: { + load: () => import('$lib/holocene/skeleton/index.svelte'), + defaultProps: {}, + children: '', + filePath: 'skeleton/index.svelte', + category: 'Feedback', + requiresContext: false, + }, + icon: { + load: () => import('$lib/holocene/icon/svg.svelte'), + defaultProps: {}, + children: '', + filePath: 'icon/svg.svelte', + category: 'Layout', + requiresContext: false, + }, +}; + +export const categories = [ + ...new Set(Object.values(componentRegistry).map((e) => e.category)), +].sort(); diff --git a/src/routes/fairytale/+layout.svelte b/src/routes/fairytale/+layout.svelte new file mode 100644 index 0000000000..2472bb7e6c --- /dev/null +++ b/src/routes/fairytale/+layout.svelte @@ -0,0 +1,103 @@ + + + + +
+ + +
+ {@render children()} +
+
diff --git a/src/routes/fairytale/+layout.ts b/src/routes/fairytale/+layout.ts new file mode 100644 index 0000000000..eece8f2899 --- /dev/null +++ b/src/routes/fairytale/+layout.ts @@ -0,0 +1,9 @@ +import { error } from '@sveltejs/kit'; + +import '../../app.css'; + +export const load = () => { + if (!import.meta.env.DEV) { + error(404, 'Not found'); + } +}; diff --git a/src/routes/fairytale/+page.svelte b/src/routes/fairytale/+page.svelte new file mode 100644 index 0000000000..5015a9c513 --- /dev/null +++ b/src/routes/fairytale/+page.svelte @@ -0,0 +1,53 @@ + + +
+

Holocene Component Library

+

+ {entries.length} components across {categories.length} categories +

+ +
+ {#each categories as category (category)} + {@const count = categoryCounts[category] || 0} + {@const firstComponent = categoryFirstComponent[category]} + + +

+ {category} +

+

+ {count} component{count !== 1 ? 's' : ''} +

+
+
+ {/each} +
+
diff --git a/src/routes/fairytale/[component]/+page.svelte b/src/routes/fairytale/[component]/+page.svelte new file mode 100644 index 0000000000..1c5078f073 --- /dev/null +++ b/src/routes/fairytale/[component]/+page.svelte @@ -0,0 +1,209 @@ + + +
+
+
+

+ {data.componentKey} +

+ + {entry.category} + +
+

+ {entry.filePath} +

+
+ +
+

Default

+
+ +
+
+ + {#if variations.length > 0} +
+

Prop Variations

+ +
+ {#each variations as variation (variation.name)} + +
+ {variation.name} + {variation.type} +
+
+
+ {#each variation.values as v (v.label)} +
+
+ +
+ {variation.name}="{v.label}" +
+ {/each} +
+
+
+ {/each} +
+
+ {/if} + +
+

+ Usage + + {usage.count} file{usage.count !== 1 ? 's' : ''} + +

+ + {#if usage.files.length > 0} + + + {#if usage.files.length > 10} + + {/if} + + {:else} +

No usages found in the codebase.

+ {/if} +
+
diff --git a/src/routes/fairytale/[component]/+page.ts b/src/routes/fairytale/[component]/+page.ts new file mode 100644 index 0000000000..8487aa52f5 --- /dev/null +++ b/src/routes/fairytale/[component]/+page.ts @@ -0,0 +1,15 @@ +import { error } from '@sveltejs/kit'; + +import type { PageLoad } from './$types'; + +import { componentRegistry } from '$lib/holocene/fairytale-registry'; + +export const load: PageLoad = ({ params }) => { + const key = params.component; + + if (!componentRegistry[key] || key === 'index') { + error(404, 'Component not found'); + } + + return { componentKey: key }; +}; diff --git a/src/routes/fairytale/_components/lazy-component.svelte b/src/routes/fairytale/_components/lazy-component.svelte new file mode 100644 index 0000000000..0983ea1762 --- /dev/null +++ b/src/routes/fairytale/_components/lazy-component.svelte @@ -0,0 +1,86 @@ + + +{#if entry.requiresContext} +
+

+ Requires App Context +

+

+ This component depends on app stores or layout data and cannot be + previewed in isolation. +

+
+{:else if loadError} +
+

+ Failed to load component +

+

{loadError}

+
+{:else if !LoadedComponent} +
+
+
+{:else} + + {#if entry.children} + + {entry.children} + + {:else} + + {/if} + {#snippet failed(error)} + {@const message = + error instanceof Error ? error.message : 'Unknown error'} +
+

+ Render Error +

+

+ {message} +

+
+ {/snippet} +
+{/if} diff --git a/src/virtual-modules.d.ts b/src/virtual-modules.d.ts new file mode 100644 index 0000000000..e9ca46ce47 --- /dev/null +++ b/src/virtual-modules.d.ts @@ -0,0 +1,10 @@ +declare module 'virtual:holocene-usage' { + const data: { + basePath: string; + usage: Record< + string, + { count: number; files: { path: string; line: number }[] } + >; + }; + export default data; +} diff --git a/vite.config.ts b/vite.config.ts index 88a70cee6f..c930b39df3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,7 @@ import path from 'path'; import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; +import { componentUsagePlugin } from './plugins/vite-plugin-component-usage'; import { oidcServerPlugin } from './plugins/vite-plugin-oidc-server'; import { temporalServer } from './plugins/vite-plugin-temporal-server'; import { uiServerPlugin } from './plugins/vite-plugin-ui-server'; @@ -13,6 +14,7 @@ export default defineConfig({ oidcServerPlugin(), temporalServer(), uiServerPlugin(), + componentUsagePlugin(), ], optimizeDeps: { include: ['date-fns', 'date-fns-tz'],