diff --git a/apps/builder/build/generate-dependencies.ts b/apps/builder/build/generate-dependencies.ts new file mode 100644 index 000000000..94f7690b7 --- /dev/null +++ b/apps/builder/build/generate-dependencies.ts @@ -0,0 +1,70 @@ +import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(__dirname, '../../..') +const V0_SRC = resolve(ROOT, 'packages/0/src') + +interface DependencyGraph { + composables: Record + components: Record +} + +function extractV0Imports (filePath: string): string[] { + let content: string + try { + content = readFileSync(filePath, 'utf8') + } catch { + return [] + } + + const imports: string[] = [] + const pattern = /from\s+['"]#v0\/(composables|components)\/(\w+)['"]/g + let match: RegExpExecArray | null + + while ((match = pattern.exec(content)) !== null) { + imports.push(match[2]) + } + + return [...new Set(imports)] +} + +function scanDirectory (dir: string): Record { + const entries = readdirSync(dir) + const graph: Record = {} + + for (const entry of entries) { + const entryPath = resolve(dir, entry) + if (!statSync(entryPath).isDirectory()) continue + + const indexPath = resolve(entryPath, 'index.ts') + const deps = extractV0Imports(indexPath) + + // Also scan .vue files and non-index .ts files + try { + const files = readdirSync(entryPath) + for (const file of files) { + if (file.endsWith('.vue') || (file.endsWith('.ts') && file !== 'index.ts')) { + deps.push(...extractV0Imports(resolve(entryPath, file))) + } + } + } catch { /* empty */ } + + graph[entry] = [...new Set(deps)].filter(d => d !== entry).toSorted() + } + + return graph +} + +const graph: DependencyGraph = { + composables: scanDirectory(resolve(V0_SRC, 'composables')), + components: scanDirectory(resolve(V0_SRC, 'components')), +} + +const outPath = resolve(__dirname, '../src/data/dependencies.json') +writeFileSync(outPath, JSON.stringify(graph, null, 2) + '\n') + +console.log( + `Generated dependency graph: ${Object.keys(graph.composables).length} composables, ${Object.keys(graph.components).length} components`, +) diff --git a/apps/builder/index.html b/apps/builder/index.html new file mode 100644 index 000000000..8a7ea986d --- /dev/null +++ b/apps/builder/index.html @@ -0,0 +1,12 @@ + + + + + + v0 Framework Builder + + +
+ + + diff --git a/apps/builder/package.json b/apps/builder/package.json new file mode 100644 index 000000000..41ab5e0b8 --- /dev/null +++ b/apps/builder/package.json @@ -0,0 +1,28 @@ +{ + "name": "@vuetify-private/builder", + "version": "1.0.0-alpha.0", + "private": true, + "type": "module", + "scripts": { + "generate": "tsx build/generate-dependencies.ts", + "dev": "pnpm generate && vite", + "build": "pnpm generate && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@mdi/js": "catalog:", + "@vuetify/v0": "workspace:*", + "fflate": "catalog:", + "pinia": "catalog:", + "vue": "catalog:" + }, + "devDependencies": { + "tsx": "catalog:", + "unocss": "catalog:", + "unplugin-vue": "catalog:", + "unplugin-vue-components": "catalog:", + "vite": "catalog:", + "vite-plugin-vue-layouts-next": "catalog:", + "vue-router": "catalog:" + } +} diff --git a/apps/builder/src/App.vue b/apps/builder/src/App.vue new file mode 100644 index 000000000..ff64961ee --- /dev/null +++ b/apps/builder/src/App.vue @@ -0,0 +1,10 @@ + + + diff --git a/apps/builder/src/components.d.ts b/apps/builder/src/components.d.ts new file mode 100644 index 000000000..3fde989c1 --- /dev/null +++ b/apps/builder/src/components.d.ts @@ -0,0 +1,18 @@ +/* eslint-disable */ +// @ts-nocheck +// biome-ignore lint: disable +// oxlint-disable +// ------ +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 + +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + PluginConfigShell: typeof import('./components/PluginConfigShell.vue')['default'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + } +} diff --git a/apps/builder/src/components/PluginConfigShell.vue b/apps/builder/src/components/PluginConfigShell.vue new file mode 100644 index 000000000..83041bff5 --- /dev/null +++ b/apps/builder/src/components/PluginConfigShell.vue @@ -0,0 +1,103 @@ + + + diff --git a/apps/builder/src/data/component-recommendations.ts b/apps/builder/src/data/component-recommendations.ts new file mode 100644 index 000000000..351bd5d98 --- /dev/null +++ b/apps/builder/src/data/component-recommendations.ts @@ -0,0 +1,48 @@ +// apps/builder/src/data/component-recommendations.ts + +// Hand-curated mapping from plugin id to the components that meaningfully benefit +// from that plugin being installed. Components are listed by id (e.g., 'Button', 'Dialog'). +// +// This is a recommendation layer: it doesn't enforce dependencies (the resolver does that). +// Components that don't exist in v0 yet (DatePicker, Calendar) are still listed — +// they're tracked for when v0 ships them. +const PLUGIN_TO_COMPONENTS: Record = { + useTheme: [ + 'Alert', 'Avatar', 'Badge', 'Button', 'Card', 'Checkbox', 'Chip', + 'Combobox', 'Dialog', 'Drawer', 'Form', 'Image', 'Input', 'List', + 'Menu', 'Pagination', 'Popover', 'Progress', 'Radio', 'Rating', + 'Select', 'Slider', 'Switch', 'Tabs', 'Toast', 'Tooltip', + ], + useStack: ['Dialog', 'Drawer', 'Menu', 'Popover', 'Tooltip', 'Toast'], + useRules: ['Form', 'Input', 'Combobox', 'Select', 'Slider', 'Numeric', 'Rating'], + useNotifications: ['Toast'], + useLocale: ['Alert', 'Button', 'Dialog', 'Input', 'Select', 'Combobox', 'Form'], + useDate: ['DatePicker', 'Calendar'], + useFeatures: [], + usePermissions: [], + useHydration: [], + useStorage: [], + useBreakpoints: [], + useLogger: [], + useRtl: [], +} + +export function recommendedFor (selectedPlugins: Set): Set { + const out = new Set() + for (const pluginId of selectedPlugins) { + for (const componentId of PLUGIN_TO_COMPONENTS[pluginId] ?? []) { + out.add(componentId) + } + } + return out +} + +export function reasonsFor (componentId: string, selectedPlugins: Set): string[] { + const reasons: string[] = [] + for (const pluginId of selectedPlugins) { + if ((PLUGIN_TO_COMPONENTS[pluginId] ?? []).includes(componentId)) { + reasons.push(pluginId) + } + } + return reasons +} diff --git a/apps/builder/src/data/dependencies.json b/apps/builder/src/data/dependencies.json new file mode 100644 index 000000000..eef4d0c0e --- /dev/null +++ b/apps/builder/src/data/dependencies.json @@ -0,0 +1,554 @@ +{ + "composables": { + "createBreadcrumbs": [ + "createContext", + "createSingle", + "createTrinity" + ], + "createCombobox": [ + "createContext", + "createSelection", + "createTrinity", + "toArray", + "usePopover", + "useVirtualFocus" + ], + "createContext": [], + "createDataTable": [ + "createContext", + "createFilter", + "createGroup", + "createPagination", + "createRegistry", + "createTrinity", + "useLocale" + ], + "createFilter": [ + "createContext", + "createTrinity", + "toArray" + ], + "createFocusTraversal": [], + "createForm": [ + "createContext", + "createRegistry", + "createTrinity", + "createValidation", + "toArray" + ], + "createGroup": [ + "createContext", + "createSelection", + "createTrinity", + "toArray", + "useProxyRegistry" + ], + "createInput": [ + "createForm", + "createRegistry", + "createValidation", + "toArray", + "useRules" + ], + "createKanban": [ + "createRegistry", + "createSortable", + "useLogger" + ], + "createModel": [ + "createRegistry" + ], + "createNested": [ + "createContext", + "createGroup", + "createTrinity", + "toArray", + "useLogger" + ], + "createNumberField": [ + "createInput", + "createNumeric" + ], + "createNumeric": [], + "createObserver": [ + "toElement", + "useHydration" + ], + "createOverflow": [ + "createContext", + "createTrinity", + "useResizeObserver" + ], + "createPagination": [ + "createContext", + "createTrinity" + ], + "createPlugin": [ + "createContext", + "createTrinity", + "useStorage" + ], + "createProgress": [ + "createContext", + "createModel", + "createTrinity" + ], + "createQueue": [ + "createContext", + "createRegistry", + "createTrinity", + "useTimer" + ], + "createRating": [ + "createContext", + "createTrinity" + ], + "createRegistry": [ + "createContext", + "createTrinity", + "useLogger" + ], + "createSelection": [ + "createContext", + "createModel", + "createTrinity" + ], + "createSingle": [ + "createContext", + "createSelection", + "createTrinity" + ], + "createSlider": [ + "createModel", + "createNumeric" + ], + "createSortable": [ + "createModel", + "createRegistry", + "useLogger" + ], + "createStep": [ + "createContext", + "createSingle", + "createTrinity" + ], + "createTimeline": [ + "createContext", + "createRegistry", + "createTrinity" + ], + "createTokens": [ + "createContext", + "createRegistry", + "createTrinity", + "useLogger" + ], + "createTrinity": [ + "createContext" + ], + "createValidation": [ + "createForm", + "createGroup", + "useRules" + ], + "createVirtual": [ + "createContext", + "createTrinity", + "useResizeObserver" + ], + "toArray": [], + "toElement": [], + "toHighlight": [ + "toArray" + ], + "toReactive": [], + "useBreakpoints": [ + "createPlugin", + "useEventListener", + "useHydration" + ], + "useClickOutside": [ + "toArray", + "useEventListener" + ], + "useDate": [ + "createContext", + "createPlugin", + "createTrinity", + "useLocale" + ], + "useDelay": [ + "useTimer" + ], + "useDragDrop": [ + "createRegistry", + "useLogger", + "useMutationObserver", + "useResizeObserver" + ], + "useEventListener": [], + "useFeatures": [ + "createGroup", + "createPlugin", + "createRegistry", + "createTokens" + ], + "useHotkey": [ + "useEventListener", + "useLogger" + ], + "useHydration": [ + "createPlugin" + ], + "useImage": [], + "useIntersectionObserver": [ + "createObserver", + "toElement" + ], + "useLazy": [ + "useTimer" + ], + "useLocale": [ + "createPlugin", + "createSingle", + "createTokens", + "useStorage" + ], + "useLogger": [ + "createPlugin" + ], + "useMediaQuery": [ + "useHydration" + ], + "useMutationObserver": [ + "createObserver", + "toElement" + ], + "useNotifications": [ + "createPlugin", + "createQueue", + "createRegistry" + ], + "usePermissions": [ + "createPlugin", + "createTokens", + "toArray" + ], + "usePopover": [ + "useEventListener", + "useTimer" + ], + "usePresence": [], + "useProxyModel": [ + "createSelection", + "toArray" + ], + "useProxyRegistry": [ + "createRegistry" + ], + "useRaf": [], + "useResizeObserver": [ + "createObserver", + "toElement" + ], + "useRovingFocus": [ + "createFocusTraversal", + "useEventListener" + ], + "useRtl": [ + "createPlugin" + ], + "useRules": [ + "createForm", + "createPlugin", + "useLocale", + "useLogger" + ], + "useStack": [ + "createContext", + "createPlugin", + "createSelection", + "createTrinity" + ], + "useStorage": [ + "createPlugin", + "useEventListener" + ], + "useTheme": [ + "createPlugin", + "createRegistry", + "createSingle", + "createTokens", + "useStorage" + ], + "useTimer": [], + "useToggleScope": [], + "useVirtualFocus": [ + "createFocusTraversal", + "useEventListener" + ] + }, + "components": { + "AlertDialog": [ + "Atom", + "createContext", + "useClickOutside", + "useLocale", + "useStack", + "useToggleScope" + ], + "AspectRatio": [ + "Atom" + ], + "Atom": [], + "Avatar": [ + "Atom", + "createContext", + "createSelection", + "useImage" + ], + "Breadcrumbs": [ + "Atom", + "createBreadcrumbs", + "createContext", + "createGroup", + "createOverflow", + "useLocale" + ], + "Button": [ + "Atom", + "createContext", + "createSelection", + "createSingle", + "useLocale", + "useProxyModel", + "useTimer" + ], + "Carousel": [ + "Atom", + "createContext", + "createRegistry", + "createStep", + "toElement", + "useEventListener", + "useLocale", + "useProxyModel", + "useResizeObserver", + "useTimer", + "useToggleScope" + ], + "Checkbox": [ + "Atom", + "createContext", + "createGroup", + "useProxyModel" + ], + "Collapsible": [ + "Atom", + "createContext", + "createSingle", + "useProxyModel" + ], + "Combobox": [ + "Atom", + "createCombobox", + "createContext", + "useClickOutside", + "useLazy", + "useProxyModel" + ], + "Dialog": [ + "Atom", + "createContext", + "useClickOutside", + "useLocale", + "useStack", + "useToggleScope" + ], + "ExpansionPanel": [ + "Atom", + "createContext", + "createSelection", + "useProxyModel" + ], + "Form": [ + "Atom", + "createContext", + "createForm", + "createValidation" + ], + "Group": [ + "createContext", + "createGroup", + "useProxyModel" + ], + "Image": [ + "Atom", + "createContext", + "toElement", + "useImage", + "useIntersectionObserver", + "useLogger" + ], + "Input": [ + "Atom", + "createContext", + "createForm", + "createInput", + "createRegistry", + "useRules" + ], + "Locale": [ + "createContext", + "useLocale" + ], + "NumberField": [ + "Atom", + "Input", + "createContext", + "createForm", + "createInput", + "createNumberField", + "createRegistry", + "useEventListener", + "useLocale", + "useRaf", + "useRules", + "useTimer" + ], + "Overflow": [ + "Atom", + "createContext", + "createOverflow", + "createRegistry", + "toElement", + "useResizeObserver" + ], + "Pagination": [ + "Atom", + "createContext", + "createOverflow", + "createPagination", + "createRegistry", + "useLocale" + ], + "Popover": [ + "Atom", + "createContext", + "usePopover" + ], + "Portal": [ + "useStack" + ], + "Presence": [ + "usePresence" + ], + "Progress": [ + "Atom", + "createContext", + "createProgress", + "useProxyModel" + ], + "Radio": [ + "Atom", + "createContext", + "createSingle", + "toElement", + "useProxyModel" + ], + "Rating": [ + "Atom", + "createContext", + "createRating", + "useLocale" + ], + "Scrim": [ + "Atom", + "useStack" + ], + "Select": [ + "Atom", + "createContext", + "createSelection", + "useLazy", + "usePopover", + "useProxyModel", + "useVirtualFocus" + ], + "Selection": [ + "createContext", + "createSelection", + "useProxyModel" + ], + "Single": [ + "createContext", + "createSingle", + "useProxyModel" + ], + "Slider": [ + "Atom", + "createContext", + "createSlider", + "useEventListener", + "useProxyModel", + "useToggleScope" + ], + "Snackbar": [ + "Atom", + "Portal", + "createContext", + "useLocale", + "useNotifications", + "useStack" + ], + "Splitter": [ + "Atom", + "createContext", + "createRegistry", + "createSelection", + "toElement", + "useEventListener", + "useRaf", + "useResizeObserver", + "useToggleScope" + ], + "Step": [ + "createContext", + "createStep", + "useProxyModel" + ], + "Switch": [ + "Atom", + "createContext", + "createGroup", + "useProxyModel" + ], + "Tabs": [ + "Atom", + "createContext", + "createStep", + "toElement", + "useProxyModel" + ], + "Theme": [ + "Atom", + "createContext", + "useTheme" + ], + "Toggle": [ + "Atom", + "createContext", + "createGroup", + "createSingle", + "useProxyModel" + ], + "Treeview": [ + "Atom", + "createContext", + "createNested", + "toElement", + "useProxyModel", + "useRovingFocus" + ] + } +} diff --git a/apps/builder/src/data/plugins.ts b/apps/builder/src/data/plugins.ts new file mode 100644 index 000000000..f90311ea9 --- /dev/null +++ b/apps/builder/src/data/plugins.ts @@ -0,0 +1,43 @@ +// apps/builder/src/data/plugins.ts + +// Types +import type { Component } from 'vue' + +export interface PluginMeta { + id: string + slug: string + title: string + category: string + hasConfig: boolean + loader: () => Promise<{ default: Component }> +} + +export const PLUGINS: PluginMeta[] = [ + // Appearance + { id: 'useTheme', slug: 'theme', title: 'Theme', category: 'appearance', hasConfig: true, loader: () => import('@/plugins/theme/ThemeConfig.vue') }, + { id: 'useBreakpoints', slug: 'breakpoints', title: 'Breakpoints', category: 'appearance', hasConfig: true, loader: () => import('@/plugins/breakpoints/BreakpointsConfig.vue') }, + // i18n + { id: 'useLocale', slug: 'locale', title: 'Locale', category: 'i18n', hasConfig: true, loader: () => import('@/plugins/locale/LocaleConfig.vue') }, + { id: 'useRtl', slug: 'rtl', title: 'Right-to-Left', category: 'i18n', hasConfig: true, loader: () => import('@/plugins/rtl/RtlConfig.vue') }, + // Infrastructure + { id: 'useStorage', slug: 'storage', title: 'Storage', category: 'infrastructure', hasConfig: true, loader: () => import('@/plugins/storage/StorageConfig.vue') }, + { id: 'useHydration', slug: 'hydration', title: 'SSR / SSG', category: 'infrastructure', hasConfig: false, loader: () => import('@/plugins/hydration/HydrationConfig.vue') }, + { id: 'useLogger', slug: 'logger', title: 'Logger', category: 'infrastructure', hasConfig: true, loader: () => import('@/plugins/logger/LoggerConfig.vue') }, + { id: 'useStack', slug: 'stack', title: 'Stack', category: 'infrastructure', hasConfig: true, loader: () => import('@/plugins/stack/StackConfig.vue') }, + // Access + { id: 'useFeatures', slug: 'features', title: 'Feature Flags', category: 'access', hasConfig: true, loader: () => import('@/plugins/features/FeaturesConfig.vue') }, + { id: 'usePermissions', slug: 'permissions', title: 'Permissions', category: 'access', hasConfig: true, loader: () => import('@/plugins/permissions/PermissionsConfig.vue') }, + // Utilities + { id: 'useDate', slug: 'date', title: 'Date', category: 'utilities', hasConfig: true, loader: () => import('@/plugins/date/DateConfig.vue') }, + { id: 'useNotifications', slug: 'notifications', title: 'Notifications', category: 'utilities', hasConfig: true, loader: () => import('@/plugins/notifications/NotificationsConfig.vue') }, + // Forms + { id: 'useRules', slug: 'rules', title: 'Rules', category: 'forms', hasConfig: true, loader: () => import('@/plugins/rules/RulesConfig.vue') }, +] + +export function getPluginById (id: string): PluginMeta | undefined { + return PLUGINS.find(p => p.id === id) +} + +export function getPluginBySlug (slug: string): PluginMeta | undefined { + return PLUGINS.find(p => p.slug === slug) +} diff --git a/apps/builder/src/data/questions.ts b/apps/builder/src/data/questions.ts new file mode 100644 index 000000000..2256c4ce3 --- /dev/null +++ b/apps/builder/src/data/questions.ts @@ -0,0 +1,166 @@ +// Types +import type { Intent } from './types' + +export interface Question { + id: string + title: string + description: string + feature: string + category: string +} + +export interface QuestionCategory { + id: string + title: string + description: string + questions: Question[] +} + +const COMPONENT_LIBRARY: QuestionCategory[] = [ + { + id: 'appearance', + title: 'Appearance', + description: 'Theming and responsive behavior', + questions: [ + { + id: 'theme', + title: 'Theme', + description: 'Light/dark mode with custom color tokens and CSS variables', + feature: 'useTheme', + category: 'appearance', + }, + { + id: 'breakpoints', + title: 'Breakpoints', + description: 'Reactive viewport tracking with named breakpoints for responsive logic', + feature: 'useBreakpoints', + category: 'appearance', + }, + ], + }, + { + id: 'i18n', + title: 'Internationalization', + description: 'Language and direction support', + questions: [ + { + id: 'locale', + title: 'Locale', + description: 'Translate component labels and messages with vue-i18n or built-in adapter', + feature: 'useLocale', + category: 'i18n', + }, + { + id: 'rtl', + title: 'Right-to-Left', + description: 'Reactive RTL direction state for mirroring component layouts', + feature: 'useRtl', + category: 'i18n', + }, + ], + }, + { + id: 'infrastructure', + title: 'Infrastructure', + description: 'Storage, rendering, and observability', + questions: [ + { + id: 'storage', + title: 'Storage', + description: 'Persistent localStorage/sessionStorage with auto-serialization', + feature: 'useStorage', + category: 'infrastructure', + }, + { + id: 'ssr', + title: 'SSR / SSG', + description: 'Hydration lifecycle tracking to prevent mismatches and defer browser APIs', + feature: 'useHydration', + category: 'infrastructure', + }, + { + id: 'logger', + title: 'Logger', + description: 'Structured logging with namespaces and pluggable adapters', + feature: 'useLogger', + category: 'infrastructure', + }, + { + id: 'stack', + title: 'Stack', + description: 'Z-index management for overlays (Dialog, Drawer, Menu, Popover, Tooltip, Toast)', + feature: 'useStack', + category: 'infrastructure', + }, + ], + }, + { + id: 'access', + title: 'Access Control', + description: 'Feature gating and permissions', + questions: [ + { + id: 'features', + title: 'Feature Flags', + description: 'Boolean toggles for A/B testing with LaunchDarkly, Flagsmith, or local config', + feature: 'useFeatures', + category: 'access', + }, + { + id: 'permissions', + title: 'Permissions', + description: 'Role-based access control for gating UI elements and routes', + feature: 'usePermissions', + category: 'access', + }, + ], + }, + { + id: 'utilities', + title: 'Utilities', + description: 'Date handling and notifications', + questions: [ + { + id: 'date', + title: 'Date', + description: 'Date manipulation with adapter support for date-fns, dayjs, or luxon', + feature: 'useDate', + category: 'utilities', + }, + { + id: 'notifications', + title: 'Notifications', + description: 'Toast and notification system with auto-dismiss and queue management', + feature: 'useNotifications', + category: 'utilities', + }, + ], + }, + { + id: 'forms', + title: 'Forms', + description: 'Validation and form-state management', + questions: [ + { + id: 'rules', + title: 'Rules', + description: 'Reusable validation rules (required, email, min, max, pattern, custom)', + feature: 'useRules', + category: 'forms', + }, + ], + }, +] + +const CATEGORIES: Record = { + 'component-library': COMPONENT_LIBRARY, + 'spa': [], + 'design-system': [], + 'admin-dashboard': [], + 'content-site': [], + 'mobile-first': [], +} + +export function getCategories (intent: Intent): QuestionCategory[] { + return CATEGORIES[intent] ?? [] +} diff --git a/apps/builder/src/data/types.ts b/apps/builder/src/data/types.ts new file mode 100644 index 000000000..2d972a695 --- /dev/null +++ b/apps/builder/src/data/types.ts @@ -0,0 +1,26 @@ +export interface DependencyGraph { + composables: Record + components: Record +} + +export interface ResolvedSet { + selected: string[] + autoIncluded: string[] + reasons: Record + warnings: Warning[] +} + +export interface Warning { + featureId: string + type: 'draft' | 'missing' + message: string +} + +export type Intent = 'spa' | 'component-library' | 'design-system' | 'admin-dashboard' | 'content-site' | 'mobile-first' + +export interface FrameworkManifest { + intent?: string + features: string[] + resolved: string[] + adapters: Record +} diff --git a/apps/builder/src/engine/manifest.test.ts b/apps/builder/src/engine/manifest.test.ts new file mode 100644 index 000000000..7480a2d70 --- /dev/null +++ b/apps/builder/src/engine/manifest.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest' + +import { generateFiles, generateImports, toHashData } from './manifest' + +describe('generateFiles', () => { + it('generates SelectionDemo when selection features are selected', () => { + const files = generateFiles({ + intent: 'spa', + features: ['createSingle'], + resolved: ['createContext', 'createModel', 'createTrinity'], + adapters: {}, + }) + + expect(files['src/SelectionDemo.vue']).toContain('createSingle') + expect(files['src/App.vue']).toContain('SelectionDemo') + }) + + it('generates FormDemo when form features are selected', () => { + const files = generateFiles({ + intent: 'spa', + features: ['createForm'], + resolved: ['createContext'], + adapters: {}, + }) + + expect(files['src/FormDemo.vue']).toContain('createForm') + expect(files['src/App.vue']).toContain('FormDemo') + }) + + it('generates DataDemo when data features are selected', () => { + const files = generateFiles({ + intent: 'spa', + features: ['createDataTable'], + resolved: ['createContext'], + adapters: {}, + }) + + expect(files['src/DataDemo.vue']).toContain('createDataTable') + expect(files['src/App.vue']).toContain('DataDemo') + }) + + it('generates multiple demos for mixed feature sets', () => { + const files = generateFiles({ + intent: 'spa', + features: ['createSingle', 'createForm', 'useResizeObserver'], + resolved: ['createContext', 'createModel', 'createTrinity'], + adapters: {}, + }) + + expect(files['src/SelectionDemo.vue']).toBeDefined() + expect(files['src/FormDemo.vue']).toBeDefined() + expect(files['src/ObserverDemo.vue']).toBeDefined() + expect(files['src/App.vue']).toContain('SelectionDemo') + expect(files['src/App.vue']).toContain('FormDemo') + expect(files['src/App.vue']).toContain('ObserverDemo') + }) + + it('does not generate main.ts or uno.config.ts', () => { + const files = generateFiles({ + intent: 'spa', + features: ['createSingle', 'useTheme'], + resolved: ['createContext', 'createModel', 'createTrinity'], + adapters: {}, + }) + + expect(files['src/main.ts']).toBeUndefined() + expect(files['src/uno.config.ts']).toBeUndefined() + }) + + it('generates fallback App.vue when only plugins are selected', () => { + const files = generateFiles({ + intent: 'spa', + features: ['useTheme', 'useLocale'], + resolved: ['createContext'], + adapters: {}, + }) + + expect(Object.keys(files)).toEqual(['src/App.vue']) + expect(files['src/App.vue']).toContain('plugins are ready') + }) + + it('shows correct feature count in App.vue', () => { + const files = generateFiles({ + intent: 'spa', + features: ['createSingle', 'useTheme'], + resolved: ['createContext', 'createModel'], + adapters: {}, + }) + + expect(files['src/App.vue']).toContain('4 features loaded') + }) +}) + +describe('toHashData', () => { + it('sets active to src/App.vue', () => { + const data = toHashData({ + intent: 'spa', + features: ['createSingle'], + resolved: ['createContext'], + adapters: {}, + }) + + expect(data.active).toBe('src/App.vue') + }) + + it('sets preset to default', () => { + const data = toHashData({ + intent: 'spa', + features: ['createSingle'], + resolved: ['createContext'], + adapters: {}, + }) + + expect(data.settings?.preset).toBe('default') + }) + + it('adds pinia addon when useStorage is selected', () => { + const data = toHashData({ + intent: 'spa', + features: ['useStorage'], + resolved: ['createContext'], + adapters: {}, + }) + + expect(data.settings?.addons).toContain('pinia') + }) + + it('adds router addon when createStep is selected', () => { + const data = toHashData({ + intent: 'spa', + features: ['createStep'], + resolved: ['createContext'], + adapters: {}, + }) + + expect(data.settings?.addons).toContain('router') + }) + + it('omits addons when none are needed', () => { + const data = toHashData({ + intent: 'spa', + features: ['createSingle'], + resolved: ['createContext'], + adapters: {}, + }) + + expect(data.settings?.addons).toBeUndefined() + }) +}) + +describe('generateImports', () => { + it('includes v0 CDN import', () => { + const imports = generateImports() + expect(imports['@vuetify/v0']).toContain('cdn.jsdelivr.net') + }) +}) diff --git a/apps/builder/src/engine/manifest.ts b/apps/builder/src/engine/manifest.ts new file mode 100644 index 000000000..46a485eff --- /dev/null +++ b/apps/builder/src/engine/manifest.ts @@ -0,0 +1,1118 @@ +// Types +import type { FrameworkManifest } from '@/data/types' + +interface PlaygroundHashData { + files: Record + active?: string + imports?: Record + settings?: { + preset?: string + addons?: string + } +} + +// Plugin id → factory function name. Used by generateMainTs to emit +// `app.use(createXPlugin(...))` calls for the user's selected plugins. +const FACTORY: Record = { + useTheme: 'createThemePlugin', + useBreakpoints: 'createBreakpointsPlugin', + useLocale: 'createLocalePlugin', + useRtl: 'createRtlPlugin', + useStorage: 'createStoragePlugin', + useHydration: 'createHydrationPlugin', + useLogger: 'createLoggerPlugin', + useStack: 'createStackPlugin', + useFeatures: 'createFeaturesPlugin', + usePermissions: 'createPermissionsPlugin', + useDate: 'createDatePlugin', + useNotifications: 'createNotificationsPlugin', + useRules: 'createRulesPlugin', +} + +const PLUGIN_IDS = Object.keys(FACTORY) + +// Per-plugin adapter handler. The `adapter` slot in saved config is a STRING +// (e.g. 'V0DateAdapter', 'none', 'KnockNotificationsAdapter'), but the +// generated code must emit a class INSTANCE — JSON-stringifying it would +// produce broken TS (`adapter: "V0DateAdapter"`). Each handler returns: +// - `raw`: the raw TS expression to inject into the config (or `null` to +// omit the adapter field entirely) +// - `imports`: v0 class names that need to be added to the import list +interface AdapterEmit { + raw: string | null + imports: string[] +} + +type AdapterHandler = (adapter: string | undefined) => AdapterEmit + +const ADAPTER_HANDLERS: Record = { + useDate: adapter => { + if (adapter === 'custom') { + return { + raw: 'new CustomDateAdapter() /* TODO: implement and replace */', + imports: [], + } + } + return { raw: 'new V0DateAdapter()', imports: ['V0DateAdapter'] } + }, + useLogger: adapter => { + if (!adapter || adapter === 'V0LoggerAdapter') { + return { raw: 'new V0LoggerAdapter()', imports: ['V0LoggerAdapter'] } + } + if (adapter === 'ConsolaLoggerAdapter') { + return { + raw: 'new ConsolaLoggerAdapter() /* pass your consola instance: new ConsolaLoggerAdapter(consola) */', + imports: ['ConsolaLoggerAdapter'], + } + } + if (adapter === 'PinoLoggerAdapter') { + return { + raw: 'new PinoLoggerAdapter() /* pass your pino instance: new PinoLoggerAdapter(pino()) */', + imports: ['PinoLoggerAdapter'], + } + } + return { raw: null, imports: [] } + }, + useNotifications: adapter => { + if (!adapter || adapter === 'none') return { raw: null, imports: [] } + if (adapter === 'KnockNotificationsAdapter') { + return { + raw: 'new KnockNotificationsAdapter() /* TODO: pass Knock config */', + imports: ['KnockNotificationsAdapter'], + } + } + if (adapter === 'NovuNotificationsAdapter') { + return { + raw: 'new NovuNotificationsAdapter() /* TODO: pass Novu config */', + imports: ['NovuNotificationsAdapter'], + } + } + return { raw: null, imports: [] } + }, + useFeatures: adapter => { + if (!adapter || adapter === 'none') return { raw: null, imports: [] } + const known = new Set([ + 'FlagsmithFeaturesAdapter', + 'LaunchDarklyFeaturesAdapter', + 'PostHogFeaturesAdapter', + ]) + if (!known.has(adapter)) return { raw: null, imports: [] } + return { + raw: `new ${adapter}() /* TODO: pass provider-specific config */`, + imports: [adapter], + } + }, +} + +function isEmpty (value: unknown): boolean { + if (value === null || value === undefined) return true + if (typeof value !== 'object') return false + if (Array.isArray(value)) return value.length === 0 + return Object.keys(value as Record).length === 0 +} + +function indent (text: string, spaces: number): string { + const pad = ' '.repeat(spaces) + return text.replaceAll('\n', `\n${pad}`) +} + +// Format the leftover (non-adapter) config fields as a list of `key: value` +// snippets, each line indented to sit inside an outer `{ … }` two spaces in. +function formatRestFields (rest: Record): string[] { + const fields: string[] = [] + for (const [key, value] of Object.entries(rest)) { + const json = JSON.stringify(value, null, 2) + fields.push(`${key}: ${indent(json, 2)}`) + } + return fields +} + +function emitPluginCall ( + pluginId: string, + factory: string, + config: unknown, +): { call: string, extraImports: string[] } { + const handler = ADAPTER_HANDLERS[pluginId] + + // No adapter handling: JSON-stringify the whole config as before. + if (!handler) { + if (isEmpty(config)) return { call: `app.use(${factory}())`, extraImports: [] } + const json = indent(JSON.stringify(config, null, 2), 2) + return { call: `app.use(${factory}(${json}))`, extraImports: [] } + } + + // Adapter-handling path: pull the adapter string off, build the raw expr, + // and inject it back into the emitted object alongside the JSON-ified rest. + const cfg = (config && typeof config === 'object' && !Array.isArray(config)) + ? config as Record + : {} + + const adapterValue = typeof cfg.adapter === 'string' ? cfg.adapter : undefined + const { adapter: _omitted, ...rest } = cfg + void _omitted + const { raw, imports } = handler(adapterValue) + + const fields: string[] = [] + if (raw !== null) fields.push(`adapter: ${raw}`) + fields.push(...formatRestFields(rest)) + + if (fields.length === 0) { + return { call: `app.use(${factory}())`, extraImports: imports } + } + + const body = fields.map(line => ` ${line},`).join('\n') + return { call: `app.use(${factory}({\n${body}\n}))`, extraImports: imports } +} + +function generatePluginCalls ( + selectedPlugins: Set | string[], + pluginConfig: Record, +): string { + const selected = selectedPlugins instanceof Set + ? selectedPlugins + : new Set(selectedPlugins) + + const lines: string[] = [] + + for (const id of PLUGIN_IDS) { + if (!selected.has(id)) continue + const { call } = emitPluginCall(id, FACTORY[id], pluginConfig[id]) + lines.push(call) + } + + return lines.join('\n') +} + +function collectExtraImports ( + selected: Set, + pluginConfig: Record, +): string[] { + const extras = new Set() + + for (const id of PLUGIN_IDS) { + if (!selected.has(id)) continue + const { extraImports } = emitPluginCall(id, FACTORY[id], pluginConfig[id]) + for (const name of extraImports) extras.add(name) + } + + return [...extras] +} + +function generatePluginImports ( + selected: Set, + pluginConfig: Record, +): string { + const factories: string[] = [] + + for (const id of PLUGIN_IDS) { + if (!selected.has(id)) continue + factories.push(FACTORY[id]) + } + + const extras = collectExtraImports(selected, pluginConfig) + + if (factories.length === 0 && extras.length === 0) { + return `import { createApp } from 'vue'` + } + + const v0Imports = [...factories, ...extras] + const formatted = v0Imports.map(name => ` ${name},`).join('\n') + + return `import { createApp } from 'vue' + +import { +${formatted} +} from '@vuetify/v0'` +} + +export function generateMainTs ( + selectedPlugins: Set | string[], + pluginConfig: Record, +): string { + const selected = selectedPlugins instanceof Set + ? selectedPlugins + : new Set(selectedPlugins) + + const imports = generatePluginImports(selected, pluginConfig) + const calls = generatePluginCalls(selected, pluginConfig) + + return `${imports} + +import App from './App.vue' + +const app = createApp(App) + +${calls} +app.mount('#app') +` +} + +// Feature → category mapping for demo file generation +const CATEGORY_MAP: Record = { + // Selection + createSelection: 'selection', + createSingle: 'selection', + createGroup: 'selection', + createStep: 'selection', + // Forms + createForm: 'forms', + createCombobox: 'forms', + createSlider: 'forms', + createRating: 'forms', + // Data + createDataTable: 'data', + createFilter: 'data', + createPagination: 'data', + createVirtual: 'data', + // Disclosure / overlay + useStack: 'disclosure', + useClickOutside: 'disclosure', + usePopover: 'disclosure', + // Observers + useResizeObserver: 'observers', + useIntersectionObserver: 'observers', +} + +// Features that imply pinia addon +const PINIA_FEATURES = new Set(['useStorage']) + +// Features that imply router addon +const ROUTER_FEATURES = new Set(['createStep']) + +export function generateImports (): Record { + return { + '@vuetify/v0': 'https://cdn.jsdelivr.net/npm/@vuetify/v0@latest/dist/index.mjs', + '@vue/devtools-api': 'https://esm.sh/@vue/devtools-api@6', + } +} + +// ---- Demo file generators per category ---- + +function generateSelectionDemo (features: string[]): string { + function has (f: string) { + return features.includes(f) + } + + if (has('createStep')) { + return ` + +` + } + + if (has('createGroup')) { + return ` + +` + } + + if (has('createSingle') || has('createSelection')) { + const factory = has('createSingle') ? 'createSingle' : 'createSelection' + return ` + +` + } + + return '' +} + +function generateFormDemo (features: string[]): string { + function has (f: string) { + return features.includes(f) + } + + if (has('createForm')) { + return ` + +` + } + + if (has('createSlider')) { + return ` + +` + } + + if (has('createRating')) { + return ` + +` + } + + if (has('createCombobox')) { + return ` + +` + } + + return '' +} + +function generateDataDemo (features: string[]): string { + function has (f: string) { + return features.includes(f) + } + + if (has('createDataTable')) { + return ` + +` + } + + if (has('createPagination') || has('createFilter') || has('createVirtual')) { + const feature = has('createPagination') ? 'createPagination' : (has('createFilter') ? 'createFilter' : 'createVirtual') + + if (feature === 'createPagination') { + return ` + +` + } + + if (feature === 'createFilter') { + return ` + +` + } + + return ` + +` + } + + return '' +} + +function generateDisclosureDemo (features: string[]): string { + function has (f: string) { + return features.includes(f) + } + + if (has('usePopover')) { + return ` + +` + } + + if (has('useStack') || has('useClickOutside')) { + return ` + +` + } + + return '' +} + +function generateObserverDemo (features: string[]): string { + function has (f: string) { + return features.includes(f) + } + + if (has('useResizeObserver')) { + return ` + +` + } + + if (has('useIntersectionObserver')) { + return ` + +` + } + + return '' +} + +// ---- Category to generator + file name mapping ---- + +interface DemoConfig { + file: string + component: string + generator: (features: string[]) => string +} + +const DEMO_CONFIGS: DemoConfig[] = [ + { file: 'src/SelectionDemo.vue', component: 'SelectionDemo', generator: generateSelectionDemo }, + { file: 'src/FormDemo.vue', component: 'FormDemo', generator: generateFormDemo }, + { file: 'src/DataDemo.vue', component: 'DataDemo', generator: generateDataDemo }, + { file: 'src/DialogDemo.vue', component: 'DialogDemo', generator: generateDisclosureDemo }, + { file: 'src/ObserverDemo.vue', component: 'ObserverDemo', generator: generateObserverDemo }, +] + +function categorizeFeatures (features: string[]): Set { + const categories = new Set() + + for (const feature of features) { + const category = CATEGORY_MAP[feature] + if (category) categories.add(category) + } + + return categories +} + +function generateAppVue (demos: Array<{ component: string, file: string }>, featureCount: number): string { + const imports = demos + .map(d => `import ${d.component} from './${d.component}.vue'`) + .join('\n') + + const components = demos + .map(d => ` <${d.component} />`) + .join('\n') + + if (demos.length === 0) { + return ` + +` + } + + return ` + +` +} + +export function generateFiles (manifest: FrameworkManifest): Record { + const allFeatures = [...manifest.features, ...manifest.resolved] + const categories = categorizeFeatures(allFeatures) + + const files: Record = {} + const demos: Array<{ component: string, file: string }> = [] + + for (const config of DEMO_CONFIGS) { + // Check if any features match this demo's category + const categoryKey = config.component + .replace('Demo', '') + .replace('Selection', 'selection') + .replace('Form', 'forms') + .replace('Data', 'data') + .replace('Dialog', 'disclosure') + .replace('Observer', 'observers') + .toLowerCase() + + if (!categories.has(categoryKey)) continue + + const content = config.generator(allFeatures) + if (!content) continue + + files[config.file] = content + demos.push({ component: config.component, file: config.file }) + } + + files['src/App.vue'] = generateAppVue(demos, allFeatures.length) + + return files +} + +function resolveAddons (features: string[]): string | undefined { + const addons: string[] = [] + + if (features.some(f => PINIA_FEATURES.has(f))) addons.push('pinia') + if (features.some(f => ROUTER_FEATURES.has(f))) addons.push('router') + + return addons.length > 0 ? addons.join(',') : undefined +} + +export function toHashData (manifest: FrameworkManifest): PlaygroundHashData { + const allFeatures = [...manifest.features, ...manifest.resolved] + const files = generateFiles(manifest) + const addons = resolveAddons(allFeatures) + + return { + files, + active: 'src/App.vue', + imports: generateImports(), + settings: { + preset: 'default', + ...(addons ? { addons } : {}), + }, + } +} + +async function encodeHash (data: PlaygroundHashData): Promise { + const { strToU8, strFromU8, zlibSync } = await import('fflate') + const buffer = strToU8(JSON.stringify(data)) + const zipped = zlibSync(buffer, { level: 9 }) + const binary = strFromU8(zipped, true) + return btoa(binary) +} + +export async function toPlaygroundUrl (manifest: FrameworkManifest, baseUrl: string): Promise { + const data = toHashData(manifest) + const hash = await encodeHash(data) + return `${baseUrl}#${hash}` +} diff --git a/apps/builder/src/engine/resolve.test.ts b/apps/builder/src/engine/resolve.test.ts new file mode 100644 index 000000000..bc5ac179f --- /dev/null +++ b/apps/builder/src/engine/resolve.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' + +import { resolve } from './resolve' + +// Types +import type { DependencyGraph } from '@/data/types' + +const graph: DependencyGraph = { + composables: { + createContext: [], + createTrinity: [], + createModel: ['createContext'], + createSelection: ['createContext', 'createModel', 'createTrinity'], + createSingle: ['createContext', 'createSelection', 'createTrinity'], + createStep: ['createContext', 'createSingle', 'createTrinity'], + }, + components: {}, +} + +describe('resolve', () => { + it('returns empty for empty selection', () => { + const result = resolve([], graph) + expect(result.selected).toEqual([]) + expect(result.autoIncluded).toEqual([]) + expect(result.warnings).toEqual([]) + }) + + it('selects a feature with no dependencies', () => { + const result = resolve(['createContext'], graph) + expect(result.selected).toEqual(['createContext']) + expect(result.autoIncluded).toEqual([]) + }) + + it('auto-includes transitive dependencies', () => { + const result = resolve(['createSelection'], graph) + expect(result.selected).toEqual(['createSelection']) + expect(result.autoIncluded.toSorted()).toEqual(['createContext', 'createModel', 'createTrinity']) + }) + + it('does not duplicate features in selected and autoIncluded', () => { + const result = resolve(['createSelection', 'createContext'], graph) + expect(result.selected.toSorted()).toEqual(['createContext', 'createSelection']) + expect(result.autoIncluded.toSorted()).toEqual(['createModel', 'createTrinity']) + }) + + it('resolves deep transitive chains', () => { + const result = resolve(['createStep'], graph) + expect(result.selected).toEqual(['createStep']) + expect(result.autoIncluded.toSorted()).toEqual([ + 'createContext', + 'createModel', + 'createSelection', + 'createSingle', + 'createTrinity', + ]) + }) + + it('tracks reasons for auto-included dependencies', () => { + const result = resolve(['createSelection'], graph) + expect(result.reasons.createContext).toBe('createSelection') + expect(result.reasons.createModel).toBe('createSelection') + expect(result.reasons.createTrinity).toBe('createSelection') + }) + + it('warns for features not in the graph', () => { + const result = resolve(['nonExistent'], graph) + expect(result.warnings).toEqual([ + { featureId: 'nonExistent', type: 'missing', message: 'Feature "nonExistent" not found in dependency graph' }, + ]) + }) +}) diff --git a/apps/builder/src/engine/resolve.ts b/apps/builder/src/engine/resolve.ts new file mode 100644 index 000000000..d87492751 --- /dev/null +++ b/apps/builder/src/engine/resolve.ts @@ -0,0 +1,51 @@ +// Types +import type { DependencyGraph, ResolvedSet, Warning } from '@/data/types' + +export function resolve (selected: string[], graph: DependencyGraph): ResolvedSet { + const selectedSet = new Set(selected) + const allDeps = new Set() + const reasons: Record = {} + const warnings: Warning[] = [] + + const allFeatures = { ...graph.composables, ...graph.components } + + function walk (id: string, parent?: string) { + if (allDeps.has(id)) return + + const deps = allFeatures[id] + if (!deps) { + warnings.push({ + featureId: id, + type: 'missing', + message: `Feature "${id}" not found in dependency graph`, + }) + return + } + + allDeps.add(id) + + // Track why this dep was pulled in (only for non-selected) + if (parent && !selectedSet.has(id) && !reasons[id]) { + reasons[id] = parent + } + + for (const dep of deps) { + walk(dep, id) + } + } + + for (const id of selected) { + walk(id) + } + + const autoIncluded = [...allDeps] + .filter(id => !selectedSet.has(id)) + .toSorted() + + return { + selected: [...selected], + autoIncluded, + reasons, + warnings, + } +} diff --git a/apps/builder/src/engine/zip.ts b/apps/builder/src/engine/zip.ts new file mode 100644 index 000000000..98273d63a --- /dev/null +++ b/apps/builder/src/engine/zip.ts @@ -0,0 +1,228 @@ +// Utilities +import { strToU8, zipSync } from 'fflate' + +// Engine +import { generateMainTs } from './manifest' + +export interface ZipManifest { + selectedPlugins: Set + pluginConfig: Record + selectedComponents: Set +} + +// Plugins that imply additional npm dependencies in the starter package.json +const PINIA_PLUGINS = new Set(['useStorage']) + +function generatePackageJson (m: ZipManifest): string { + const deps: Record = { + '@vuetify/v0': 'latest', + 'vue': '^3.5.0', + 'vue-router': '^4.4.0', + } + + let needsPinia = false + for (const id of m.selectedPlugins) { + if (PINIA_PLUGINS.has(id)) needsPinia = true + } + + if (needsPinia) deps.pinia = '^2.2.0' + + const devDeps: Record = { + '@vitejs/plugin-vue': '^5.1.0', + 'typescript': '^5.6.0', + 'unocss': '^0.65.0', + 'unplugin-vue-router': '^0.10.0', + 'vite': '^6.0.0', + 'vue-tsc': '^2.1.0', + } + + const pkg = { + name: 'my-v0-app', + version: '0.0.0', + private: true, + type: 'module', + scripts: { + dev: 'vite', + build: 'vue-tsc -b && vite build', + preview: 'vite preview', + }, + dependencies: deps, + devDependencies: devDeps, + } + + return JSON.stringify(pkg, null, 2) + '\n' +} + +function generateViteConfig (): string { + return `import { fileURLToPath, URL } from 'node:url' + +import vue from '@vitejs/plugin-vue' +import UnocssVitePlugin from 'unocss/vite' +import VueRouter from 'unplugin-vue-router/vite' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + VueRouter(), + vue(), + UnocssVitePlugin(), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, +}) +` +} + +function generateTsconfig (): string { + return `{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "lib": ["dom", "dom.iterable", "esnext"], + "strict": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "jsx": "preserve", + "paths": { "@/*": ["./src/*"] } + }, + "include": ["src/**/*.ts", "src/**/*.vue"] +} +` +} + +function generateUnoConfig (): string { + return `import { defineConfig, presetWind4 } from 'unocss' + +export default defineConfig({ + presets: [presetWind4()], +}) +` +} + +function generateIndexHtml (): string { + return ` + + + + + My v0 App + + +
+ + + +` +} + +function generateAppVue (): string { + return ` + + +` +} + +function generateIndexPage (m: ZipManifest): string { + const components = [...m.selectedComponents].slice(0, 3) + + if (components.length === 0) { + return ` + + +` + } + + const importList = components.map(c => ` // import { ${c} } from '@vuetify/v0'`).join('\n') + + return ` + + +` +} + +function generateReadme (m: ZipManifest): string { + const pluginCount = m.selectedPlugins.size + const componentCount = m.selectedComponents.size + + return `# my-v0-app + +Generated by the v0 Framework Builder. + +## What's inside + +- ${pluginCount} plugin${pluginCount === 1 ? '' : 's'} wired in \`src/main.ts\` +- ${componentCount} component${componentCount === 1 ? '' : 's'} available from \`@vuetify/v0\` + +## Quick start + +\`\`\`bash +pnpm install +pnpm dev +\`\`\` + +## Editing the config + +Your plugin configurations live in \`src/main.ts\`. Customize them +there or use the Builder again to regenerate. +` +} + +function generateGitignore (): string { + return `node_modules +dist +.DS_Store +*.log +` +} + +function generateZip (m: ZipManifest): Uint8Array { + const files: Record = { + 'my-v0-app/package.json': strToU8(generatePackageJson(m)), + 'my-v0-app/vite.config.ts': strToU8(generateViteConfig()), + 'my-v0-app/tsconfig.json': strToU8(generateTsconfig()), + 'my-v0-app/uno.config.ts': strToU8(generateUnoConfig()), + 'my-v0-app/index.html': strToU8(generateIndexHtml()), + 'my-v0-app/README.md': strToU8(generateReadme(m)), + 'my-v0-app/.gitignore': strToU8(generateGitignore()), + 'my-v0-app/src/main.ts': strToU8(generateMainTs(m.selectedPlugins, m.pluginConfig)), + 'my-v0-app/src/App.vue': strToU8(generateAppVue()), + 'my-v0-app/src/pages/index.vue': strToU8(generateIndexPage(m)), + } + + return zipSync(files) +} + +export function downloadZip (m: ZipManifest, filename = 'v0-starter.zip'): void { + const bytes = generateZip(m) + const blob = new Blob([bytes as BlobPart], { type: 'application/zip' }) + const url = URL.createObjectURL(blob) + + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.append(a) + a.click() + a.remove() + + URL.revokeObjectURL(url) +} diff --git a/apps/builder/src/main.ts b/apps/builder/src/main.ts new file mode 100644 index 000000000..607c26bc3 --- /dev/null +++ b/apps/builder/src/main.ts @@ -0,0 +1,77 @@ +import { setupLayouts } from 'virtual:generated-layouts' +import { routes } from 'vue-router/auto-routes' + +// Framework +import { createBreakpointsPlugin, createHydrationPlugin, createStoragePlugin, createThemePlugin, IN_BROWSER } from '@vuetify/v0' + +// Context +import App from './App.vue' + +// Router +import { builderGuard } from '@/router/guards' + +// Utilities +import { createPinia } from 'pinia' +import { createApp } from 'vue' +import { createRouter, createWebHistory } from 'vue-router' + +import 'virtual:uno.css' + +function getSystemTheme (): 'light' | 'dark' { + if (!IN_BROWSER) return 'light' + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' +} + +const app = createApp(App) + +const router = createRouter({ + history: createWebHistory(), + routes: setupLayouts(routes), +}) + +app.use(createPinia()) +router.beforeEach(builderGuard) +app.use(router) +app.use(createHydrationPlugin()) +app.use(createBreakpointsPlugin({ mobileBreakpoint: 768 })) +app.use(createStoragePlugin()) +app.use(createThemePlugin({ + default: getSystemTheme(), + target: 'html', + themes: { + light: { + dark: false, + colors: { + 'primary': '#3b82f6', + 'secondary': '#64748b', + 'accent': '#6366f1', + 'error': '#ef4444', + 'background': '#f5f5f5', + 'surface': '#ffffff', + 'surface-variant': '#f5f5f5', + 'divider': '#e0e0e0', + 'on-primary': '#ffffff', + 'on-surface': '#212121', + 'on-surface-variant': '#666666', + }, + }, + dark: { + dark: true, + colors: { + 'primary': '#c4b5fd', + 'secondary': '#94a3b8', + 'accent': '#c084fc', + 'error': '#f87171', + 'background': '#121212', + 'surface': '#1a1a1a', + 'surface-variant': '#1e1e1e', + 'divider': '#404040', + 'on-primary': '#1a1a1a', + 'on-surface': '#e0e0e0', + 'on-surface-variant': '#a0a0a0', + }, + }, + }, +})) + +app.mount('#app') diff --git a/apps/builder/src/pages/builder/[plugin].vue b/apps/builder/src/pages/builder/[plugin].vue new file mode 100644 index 000000000..e62e2a335 --- /dev/null +++ b/apps/builder/src/pages/builder/[plugin].vue @@ -0,0 +1,29 @@ + + + diff --git a/apps/builder/src/pages/builder/components.vue b/apps/builder/src/pages/builder/components.vue new file mode 100644 index 000000000..49d9a3555 --- /dev/null +++ b/apps/builder/src/pages/builder/components.vue @@ -0,0 +1,216 @@ + + + diff --git a/apps/builder/src/pages/builder/index.vue b/apps/builder/src/pages/builder/index.vue new file mode 100644 index 000000000..66b845e92 --- /dev/null +++ b/apps/builder/src/pages/builder/index.vue @@ -0,0 +1,99 @@ + + + diff --git a/apps/builder/src/pages/builder/review.vue b/apps/builder/src/pages/builder/review.vue new file mode 100644 index 000000000..9666adc5c --- /dev/null +++ b/apps/builder/src/pages/builder/review.vue @@ -0,0 +1,220 @@ + + + diff --git a/apps/builder/src/pages/index.vue b/apps/builder/src/pages/index.vue new file mode 100644 index 000000000..4bf66a373 --- /dev/null +++ b/apps/builder/src/pages/index.vue @@ -0,0 +1,40 @@ + + + diff --git a/apps/builder/src/plugins/breakpoints/BreakpointsConfig.vue b/apps/builder/src/plugins/breakpoints/BreakpointsConfig.vue new file mode 100644 index 000000000..b125e18a0 --- /dev/null +++ b/apps/builder/src/plugins/breakpoints/BreakpointsConfig.vue @@ -0,0 +1,150 @@ + + + diff --git a/apps/builder/src/plugins/breakpoints/defaults.ts b/apps/builder/src/plugins/breakpoints/defaults.ts new file mode 100644 index 000000000..189fa568c --- /dev/null +++ b/apps/builder/src/plugins/breakpoints/defaults.ts @@ -0,0 +1,21 @@ +// apps/builder/src/plugins/breakpoints/defaults.ts + +export type BreakpointName = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' + +export interface BreakpointsConfig { + mobileBreakpoint: BreakpointName | number + breakpoints: Record +} + +export const BREAKPOINT_NAMES: BreakpointName[] = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'] + +export const defaultConfig: BreakpointsConfig = { + mobileBreakpoint: 'lg', + breakpoints: { xs: 0, sm: 600, md: 840, lg: 1145, xl: 1545, xxl: 2138 }, +} + +export const PRESETS: Record> = { + Tailwind: { xs: 0, sm: 640, md: 768, lg: 1024, xl: 1280, xxl: 1536 }, + Bootstrap: { xs: 0, sm: 576, md: 768, lg: 992, xl: 1200, xxl: 1400 }, + Material: { xs: 0, sm: 600, md: 840, lg: 1145, xl: 1545, xxl: 2138 }, +} diff --git a/apps/builder/src/plugins/date/DateConfig.vue b/apps/builder/src/plugins/date/DateConfig.vue new file mode 100644 index 000000000..429906fb8 --- /dev/null +++ b/apps/builder/src/plugins/date/DateConfig.vue @@ -0,0 +1,168 @@ + + + diff --git a/apps/builder/src/plugins/date/defaults.ts b/apps/builder/src/plugins/date/defaults.ts new file mode 100644 index 000000000..b2e0830e0 --- /dev/null +++ b/apps/builder/src/plugins/date/defaults.ts @@ -0,0 +1,31 @@ +// apps/builder/src/plugins/date/defaults.ts + +export type DateAdapterKind = 'V0DateAdapter' | 'custom' + +export interface DateConfig { + adapter: DateAdapterKind + locale: string + locales: Record + firstDayOfWeek: number +} + +export const DATE_ADAPTERS: DateAdapterKind[] = ['V0DateAdapter', 'custom'] + +export const defaultConfig: DateConfig = { + adapter: 'V0DateAdapter', + locale: 'en', + locales: { + en: 'en-US', + es: 'es-ES', + fr: 'fr-FR', + de: 'de-DE', + it: 'it-IT', + pt: 'pt-PT', + ja: 'ja-JP', + ko: 'ko-KR', + zh: 'zh-CN', + ru: 'ru-RU', + ar: 'ar-SA', + }, + firstDayOfWeek: 0, +} diff --git a/apps/builder/src/plugins/features/FeaturesConfig.vue b/apps/builder/src/plugins/features/FeaturesConfig.vue new file mode 100644 index 000000000..88a801b23 --- /dev/null +++ b/apps/builder/src/plugins/features/FeaturesConfig.vue @@ -0,0 +1,135 @@ + + + diff --git a/apps/builder/src/plugins/features/defaults.ts b/apps/builder/src/plugins/features/defaults.ts new file mode 100644 index 000000000..77639c29d --- /dev/null +++ b/apps/builder/src/plugins/features/defaults.ts @@ -0,0 +1,24 @@ +// apps/builder/src/plugins/features/defaults.ts + +export type FeaturesAdapter = + | 'none' + | 'FlagsmithFeaturesAdapter' + | 'LaunchDarklyFeaturesAdapter' + | 'PostHogFeaturesAdapter' + +export interface FeaturesConfig { + features: Record + adapter: FeaturesAdapter +} + +export const FEATURES_ADAPTERS: FeaturesAdapter[] = [ + 'none', + 'FlagsmithFeaturesAdapter', + 'LaunchDarklyFeaturesAdapter', + 'PostHogFeaturesAdapter', +] + +export const defaultConfig: FeaturesConfig = { + features: {}, + adapter: 'none', +} diff --git a/apps/builder/src/plugins/hydration/HydrationConfig.vue b/apps/builder/src/plugins/hydration/HydrationConfig.vue new file mode 100644 index 000000000..e44b65200 --- /dev/null +++ b/apps/builder/src/plugins/hydration/HydrationConfig.vue @@ -0,0 +1,27 @@ + + + diff --git a/apps/builder/src/plugins/locale/LocaleConfig.vue b/apps/builder/src/plugins/locale/LocaleConfig.vue new file mode 100644 index 000000000..cb57f4af4 --- /dev/null +++ b/apps/builder/src/plugins/locale/LocaleConfig.vue @@ -0,0 +1,171 @@ + + +