agnosticui-cli 2.0.0-alpha.15 → 2.0.0-alpha.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,960 @@
1
+ /**
2
+ * Viewer app template generation utilities for `ag view`
3
+ *
4
+ * Generates a lightweight Vite-powered component viewer app in
5
+ * .agnosticui-viewer/ that renders all ejected components using
6
+ * the project's own framework (React, Vue, or Lit/vanilla).
7
+ */
8
+ import path from 'node:path';
9
+ import { existsSync } from 'node:fs';
10
+ import { writeFile } from 'node:fs/promises';
11
+ import { ensureDir } from './files.js';
12
+ // ---------------------------------------------------------------------------
13
+ // Component render defaults
14
+ // ---------------------------------------------------------------------------
15
+ const TEXT_CHILD_COMPONENTS = new Set([
16
+ 'Alert', 'Badge', 'BadgeFx', 'Button', 'ButtonFx', 'Card',
17
+ 'Kbd', 'Link', 'Mark', 'MessageBubble', 'Tag',
18
+ ]);
19
+ // Components whose main React export name differs from React${name}.
20
+ // Maps component name to the actual exported identifier to import and render.
21
+ const REACT_EXPORT_OVERRIDES = {
22
+ Flex: 'ReactFlexRow',
23
+ };
24
+ // React-specific minimal renders for form/display components
25
+ const REACT_SPECIFIC = {
26
+ Avatar: '<ReactAvatar text="AG" />',
27
+ Checkbox: '<ReactCheckbox id="viewer-cb" label="Checkbox" />',
28
+ Collapsible: '<ReactCollapsible><span slot="summary">Toggle details</span><div>Content goes here.</div></ReactCollapsible>',
29
+ CopyButton: '<ReactCopyButton text="ag add button" />',
30
+ Divider: '<ReactDivider />',
31
+ EmptyState: '<ReactEmptyState />',
32
+ Flex: '<ReactFlexRow><span>Item 1</span><span>Item 2</span><span>Item 3</span></ReactFlexRow>',
33
+ IconButton: '<ReactIconButton aria-label="settings"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg></ReactIconButton>',
34
+ IconButtonFx: '<ReactIconButtonFx aria-label="settings"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/></svg></ReactIconButtonFx>',
35
+ Input: '<ReactInput id="viewer-input" label="Label" type="text" />',
36
+ Loader: '<ReactLoader />',
37
+ Progress: '<ReactProgress value={50} max={100} />',
38
+ ProgressRing: '<ReactProgressRing value={50} />',
39
+ Radio: '<ReactRadio id="viewer-radio" label="Radio" name="viewer-group" />',
40
+ Rating: '<ReactRating value={3} />',
41
+ ScrollProgress: '<ReactScrollProgress />',
42
+ ScrollToButton: '<ReactScrollToButton />',
43
+ Select: '<ReactSelect id="viewer-select" label="Select" />',
44
+ SkeletonLoader: '<ReactSkeletonLoader />',
45
+ Slider: '<ReactSlider id="viewer-slider" label="Slider" min={0} max={100} value={50} />',
46
+ Spinner: '<ReactSpinner />',
47
+ Toggle: '<ReactToggle id="viewer-toggle" label="Toggle" />',
48
+ VisuallyHidden: '<ReactVisuallyHidden>Screen reader only text</ReactVisuallyHidden>',
49
+ };
50
+ // Components whose main Vue export name differs from Vue${name}.vue.
51
+ // Maps component name to the actual .vue file basename to import and render.
52
+ const VUE_EXPORT_OVERRIDES = {
53
+ Flex: 'VueFlexRow',
54
+ };
55
+ // Vue-specific minimal renders
56
+ const VUE_SPECIFIC = {
57
+ Avatar: { props: 'text="AG"', slot: '' },
58
+ Checkbox: { props: 'id="viewer-cb" label="Checkbox"', slot: '' },
59
+ CopyButton: { props: 'text="ag add button"', slot: '' },
60
+ Divider: { props: '', slot: '' },
61
+ EmptyState: { props: '', slot: '' },
62
+ Flex: { props: '', slot: '<span>Item 1</span><span>Item 2</span><span>Item 3</span>' },
63
+ IconButton: { props: 'aria-label="settings"', slot: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/></svg>' },
64
+ IconButtonFx: { props: 'aria-label="settings"', slot: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/></svg>' },
65
+ Input: { props: 'id="viewer-input" label="Label" type="text"', slot: '' },
66
+ Loader: { props: '', slot: '' },
67
+ Progress: { props: ':value="50" :max="100"', slot: '' },
68
+ ProgressRing: { props: ':value="50"', slot: '' },
69
+ Radio: { props: 'id="viewer-radio" label="Radio" name="viewer-group"', slot: '' },
70
+ Rating: { props: ':value="3"', slot: '' },
71
+ ScrollProgress: { props: '', slot: '' },
72
+ ScrollToButton: { props: '', slot: '' },
73
+ Select: { props: 'id="viewer-select" label="Select"', slot: '' },
74
+ SkeletonLoader: { props: '', slot: '' },
75
+ Slider: { props: 'id="viewer-slider" label="Slider" :min="0" :max="100" :value="50"', slot: '' },
76
+ Spinner: { props: '', slot: '' },
77
+ Toggle: { props: 'id="viewer-toggle" label="Toggle"', slot: '' },
78
+ VisuallyHidden: { props: '', slot: 'Screen reader only text' },
79
+ };
80
+ // Lit/vanilla minimal HTML renders (uses ag-* tag names)
81
+ const LIT_SPECIFIC = {
82
+ Avatar: (tag) => `<${tag} text="AG"></${tag}>`,
83
+ Checkbox: (tag) => `<${tag} id="viewer-cb" label="Checkbox"></${tag}>`,
84
+ CopyButton: (tag) => `<${tag} text="ag add button"></${tag}>`,
85
+ Divider: (tag) => `<${tag}></${tag}>`,
86
+ EmptyState: (tag) => `<${tag}></${tag}>`,
87
+ Input: (tag) => `<${tag} id="viewer-input" label="Label" type="text"></${tag}>`,
88
+ Loader: (tag) => `<${tag}></${tag}>`,
89
+ Progress: (tag) => `<${tag} value="50" max="100"></${tag}>`,
90
+ ProgressRing: (tag) => `<${tag} value="50"></${tag}>`,
91
+ Radio: (tag) => `<${tag} id="viewer-radio" label="Radio" name="viewer-group"></${tag}>`,
92
+ Rating: (tag) => `<${tag} value="3"></${tag}>`,
93
+ ScrollProgress: (tag) => `<${tag}></${tag}>`,
94
+ ScrollToButton: (tag) => `<${tag}></${tag}>`,
95
+ Select: (tag) => `<${tag} id="viewer-select" label="Select"></${tag}>`,
96
+ SkeletonLoader: (tag) => `<${tag}></${tag}>`,
97
+ Slider: (tag) => `<${tag} id="viewer-slider" label="Slider" min="0" max="100" value="50"></${tag}>`,
98
+ Spinner: (tag) => `<${tag}></${tag}>`,
99
+ Toggle: (tag) => `<${tag} id="viewer-toggle" label="Toggle"></${tag}>`,
100
+ VisuallyHidden: (tag) => `<${tag}>Screen reader only text</${tag}>`,
101
+ };
102
+ // Components also used as viewer chrome — import aliases needed to avoid duplicate identifiers
103
+ const VIEWER_CHROME_COMPONENTS = new Set(['CopyButton', 'Header', 'Tabs']);
104
+ function toKebabCase(name) {
105
+ return name.replace(/([A-Z])/g, (match, char, offset) => (offset > 0 ? '-' : '') + char.toLowerCase());
106
+ }
107
+ function getAgTagName(name) {
108
+ return `ag-${toKebabCase(name)}`;
109
+ }
110
+ function getReactRender(name, localName) {
111
+ const wrapper = localName ?? `React${name}`;
112
+ if (TEXT_CHILD_COMPONENTS.has(name))
113
+ return `<${wrapper}>${name}</${wrapper}>`;
114
+ if (REACT_SPECIFIC[name]) {
115
+ return localName
116
+ ? REACT_SPECIFIC[name].replace(`React${name}`, localName)
117
+ : REACT_SPECIFIC[name];
118
+ }
119
+ return `<${wrapper} />`;
120
+ }
121
+ function getVueRenderBlock(name, localName) {
122
+ const wrapper = localName ?? `Vue${name}`;
123
+ if (TEXT_CHILD_COMPONENTS.has(name)) {
124
+ return `<${wrapper}>${name}</${wrapper}>`;
125
+ }
126
+ const specific = VUE_SPECIFIC[name];
127
+ if (specific) {
128
+ if (specific.slot) {
129
+ return `<${wrapper}${specific.props ? ' ' + specific.props : ''}>${specific.slot}</${wrapper}>`;
130
+ }
131
+ return `<${wrapper}${specific.props ? ' ' + specific.props : ''} />`;
132
+ }
133
+ return `<${wrapper} />`;
134
+ }
135
+ function getLitRenderHtml(name) {
136
+ const tag = getAgTagName(name);
137
+ if (TEXT_CHILD_COMPONENTS.has(name))
138
+ return `<${tag}>${name}</${tag}>`;
139
+ const specificFn = LIT_SPECIFIC[name];
140
+ if (specificFn)
141
+ return specificFn(tag);
142
+ return `<${tag}></${tag}>`;
143
+ }
144
+ // ---------------------------------------------------------------------------
145
+ // Public: detect optional theme file
146
+ // ---------------------------------------------------------------------------
147
+ export function detectThemeFile(componentsAbsPath) {
148
+ return existsSync(path.join(componentsAbsPath, 'styles', 'ag-theme.css'));
149
+ }
150
+ // ---------------------------------------------------------------------------
151
+ // Template generators
152
+ // ---------------------------------------------------------------------------
153
+ function generatePackageJson(framework) {
154
+ const deps = {
155
+ 'lit': '^3.0.0',
156
+ 'focus-trap': '^7.0.0',
157
+ '@floating-ui/dom': '^1.0.0',
158
+ };
159
+ const devDeps = {
160
+ 'vite': '^5.0.0',
161
+ 'typescript': '^5.0.0',
162
+ };
163
+ if (framework === 'react') {
164
+ deps['react'] = '^18.0.0';
165
+ deps['react-dom'] = '^18.0.0';
166
+ deps['@lit/react'] = '^1.0.0';
167
+ devDeps['@vitejs/plugin-react'] = '^4.0.0';
168
+ devDeps['@types/react'] = '^18.0.0';
169
+ devDeps['@types/react-dom'] = '^18.0.0';
170
+ }
171
+ else if (framework === 'vue') {
172
+ deps['vue'] = '^3.0.0';
173
+ devDeps['@vitejs/plugin-vue'] = '^5.0.0';
174
+ deps['@lit/react'] = '^1.0.0'; // needed by some ag-ref core files
175
+ }
176
+ else {
177
+ // lit / svelte / other
178
+ deps['@lit/react'] = '^1.0.0'; // pulled in by reference lib React wrappers if resolved
179
+ }
180
+ return JSON.stringify({
181
+ name: 'agnosticui-viewer',
182
+ version: '1.0.0',
183
+ private: true,
184
+ type: 'module',
185
+ scripts: { dev: 'vite' },
186
+ dependencies: deps,
187
+ devDependencies: devDeps,
188
+ }, null, 2) + '\n';
189
+ }
190
+ function generateTsConfig(framework) {
191
+ return JSON.stringify({
192
+ compilerOptions: {
193
+ target: 'ES2020',
194
+ lib: ['ES2020', 'DOM', 'DOM.Iterable'],
195
+ module: 'ESNext',
196
+ skipLibCheck: true,
197
+ moduleResolution: 'bundler',
198
+ allowImportingTsExtensions: true,
199
+ resolveJsonModule: true,
200
+ isolatedModules: true,
201
+ noEmit: true,
202
+ ...(framework === 'react' ? { jsx: 'react-jsx' } : {}),
203
+ strict: false,
204
+ experimentalDecorators: true,
205
+ useDefineForClassFields: false,
206
+ },
207
+ include: ['src'],
208
+ }, null, 2) + '\n';
209
+ }
210
+ function generateViteConfig(framework, componentsAbsPath, refLibAbsPath) {
211
+ const refComponentsPath = path.join(refLibAbsPath, 'src', 'components');
212
+ const agRefAlias = JSON.stringify(refComponentsPath);
213
+ const agComponentsAlias = JSON.stringify(componentsAbsPath);
214
+ const optimizeIncludes = framework === 'react'
215
+ ? `['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', 'lit', 'lit/decorators.js', '@lit/react', '@floating-ui/dom', 'focus-trap']`
216
+ : framework === 'vue'
217
+ ? `['vue', 'lit', 'lit/decorators.js', '@floating-ui/dom', 'focus-trap']`
218
+ : `['lit', 'lit/decorators.js', '@floating-ui/dom', 'focus-trap']`;
219
+ const pluginImport = framework === 'react'
220
+ ? `import react from '@vitejs/plugin-react'`
221
+ : framework === 'vue'
222
+ ? `import vue from '@vitejs/plugin-vue'`
223
+ : '';
224
+ const pluginUsage = framework === 'react'
225
+ ? `react()`
226
+ : framework === 'vue'
227
+ ? `vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('ag-') } } })`
228
+ : '';
229
+ const pluginsLine = pluginUsage
230
+ ? ` plugins: [${pluginUsage}],\n`
231
+ : '';
232
+ return `import { defineConfig } from 'vite'
233
+ ${pluginImport ? pluginImport + '\n' : ''}
234
+ export default defineConfig({
235
+ ${pluginsLine} resolve: {
236
+ alias: {
237
+ '@ag-ref': ${agRefAlias},
238
+ '@ag-components': ${agComponentsAlias},
239
+ },
240
+ dedupe: ['lit', 'focus-trap', '@floating-ui/dom'],
241
+ },
242
+ server: {
243
+ fs: {
244
+ allow: ['..'],
245
+ },
246
+ },
247
+ optimizeDeps: {
248
+ noDiscovery: true,
249
+ holdUntilCrawlEnd: false,
250
+ include: ${optimizeIncludes},
251
+ },
252
+ })
253
+ `;
254
+ }
255
+ function generateIndexHtml(framework) {
256
+ const scriptSrc = framework === 'react' ? '/src/main.tsx' : '/src/main.ts';
257
+ return `<!DOCTYPE html>
258
+ <html lang="en">
259
+ <head>
260
+ <meta charset="UTF-8" />
261
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
262
+ <title>AgnosticUI Component Viewer</title>
263
+ </head>
264
+ <body>
265
+ ${framework === 'react' || framework === 'vue' ? '<div id="root"></div>' : ''}
266
+ <script type="module" src="${scriptSrc}"></script>
267
+ </body>
268
+ </html>
269
+ `;
270
+ }
271
+ function generateReactMain() {
272
+ return `import React from 'react'
273
+ import ReactDOM from 'react-dom/client'
274
+ import App from './App'
275
+
276
+ ReactDOM.createRoot(document.getElementById('root')!).render(
277
+ <React.StrictMode>
278
+ <App />
279
+ </React.StrictMode>
280
+ )
281
+ `;
282
+ }
283
+ function generateVueMain() {
284
+ return `import { createApp } from 'vue'
285
+ import App from './App.vue'
286
+
287
+ createApp(App).mount('#root')
288
+ `;
289
+ }
290
+ function generateLitMain() {
291
+ return `import './App'
292
+ `;
293
+ }
294
+ // ---------------------------------------------------------------------------
295
+ // React App (App.tsx)
296
+ // ---------------------------------------------------------------------------
297
+ function generateReactApp(installedComponents, componentsPath, hasTheme, componentsAbsPath) {
298
+ const names = Object.keys(installedComponents).sort();
299
+ // Determine import path: if React${n}.tsx/ts exists use that file, otherwise fall back to the
300
+ // directory index (some components like Flex export multiple sub-components from index.ts).
301
+ function getReactImportPath(n) {
302
+ const base = path.join(componentsAbsPath, n, 'react');
303
+ if (existsSync(path.join(base, `React${n}.tsx`)) || existsSync(path.join(base, `React${n}.ts`))) {
304
+ return `@ag-components/${n}/react/React${n}`;
305
+ }
306
+ return `@ag-components/${n}/react`;
307
+ }
308
+ const componentImports = names
309
+ .map(n => {
310
+ const importPath = getReactImportPath(n);
311
+ const exportName = REACT_EXPORT_OVERRIDES[n] ?? `React${n}`;
312
+ if (VIEWER_CHROME_COMPONENTS.has(n)) {
313
+ // Alias to avoid conflict with the viewer chrome import of the same name
314
+ return `import { ${exportName} as UserReact${n} } from '${importPath}'`;
315
+ }
316
+ if (REACT_EXPORT_OVERRIDES[n]) {
317
+ // Import under the real export name (e.g. ReactFlexContainer)
318
+ return `import { ${exportName} } from '${importPath}'`;
319
+ }
320
+ return `import { React${n} } from '${importPath}'`;
321
+ })
322
+ .join('\n');
323
+ const themeImport = hasTheme
324
+ ? `import '@ag-components/styles/ag-theme.css'`
325
+ : '';
326
+ const componentEntries = names.map(n => {
327
+ const meta = installedComponents[n];
328
+ const snippet = `import { React${n} } from '${componentsPath}/${n}/react/React${n}'`;
329
+ const localName = VIEWER_CHROME_COMPONENTS.has(n)
330
+ ? `UserReact${n}`
331
+ : REACT_EXPORT_OVERRIDES[n] ?? undefined;
332
+ const render = getReactRender(n, localName);
333
+ return ` {
334
+ name: ${JSON.stringify(n)},
335
+ version: ${JSON.stringify(meta.version)},
336
+ addedDate: ${JSON.stringify(meta.added)},
337
+ files: ${JSON.stringify(meta.files)},
338
+ importSnippet: ${JSON.stringify(snippet)},
339
+ render: () => (${render}),
340
+ }`;
341
+ }).join(',\n');
342
+ return `// Auto-generated by \`ag view\`. Do not edit manually.
343
+ import { useState } from 'react'
344
+ import { ReactHeader } from '@ag-ref/Header/react/ReactHeader'
345
+ import { ReactTabs, ReactTab, ReactTabPanel } from '@ag-ref/Tabs/react/ReactTabs'
346
+ import { ReactCopyButton } from '@ag-ref/CopyButton/react/ReactCopyButton'
347
+ import '@ag-components/styles/ag-tokens.css'
348
+ import '@ag-components/styles/ag-tokens-dark.css'
349
+ ${themeImport}
350
+ import './viewer.css'
351
+
352
+ ${componentImports}
353
+
354
+ type ComponentInfo = {
355
+ name: string
356
+ version: string
357
+ addedDate: string
358
+ files: string[]
359
+ importSnippet: string
360
+ render: () => any
361
+ }
362
+
363
+ const COMPONENTS: ComponentInfo[] = [
364
+ ${componentEntries}
365
+ ]
366
+
367
+ export default function App() {
368
+ const [selected, setSelected] = useState(COMPONENTS[0]?.name ?? '')
369
+ const [activeTab, setActiveTab] = useState(0)
370
+ const current = COMPONENTS.find(c => c.name === selected) ?? COMPONENTS[0]
371
+
372
+ return (
373
+ <div className="av-layout">
374
+ <ReactHeader>
375
+ <span className="av-brand">AgnosticUI Component Viewer</span>
376
+ </ReactHeader>
377
+ <div className="av-body">
378
+ <aside className="av-sidebar">
379
+ <div className="av-overview">
380
+ <p>Browse your ejected components. Click a name to preview.</p>
381
+ <p>
382
+ Add more with <code>ag add &lt;name&gt;</code>.{' '}
383
+ <a
384
+ href="https://www.agnosticui.com/installation.html#agnosticui-cli-recommended"
385
+ target="_blank"
386
+ rel="noopener noreferrer"
387
+ >
388
+ CLI docs →
389
+ </a>
390
+ </p>
391
+ </div>
392
+ <nav className="av-nav">
393
+ {COMPONENTS.map(c => (
394
+ <button
395
+ key={c.name}
396
+ onClick={() => { setSelected(c.name); setActiveTab(0) }}
397
+ className={\`av-nav-item\${c.name === selected ? ' av-nav-item--active' : ''}\`}
398
+ >
399
+ {c.name}
400
+ </button>
401
+ ))}
402
+ </nav>
403
+ </aside>
404
+ <main className="av-main">
405
+ {current && (
406
+ <>
407
+ <h2 className="av-component-title">{current.name}</h2>
408
+ <ReactTabs
409
+ activeTab={activeTab}
410
+ ariaLabel={\`\${current.name} tabs\`}
411
+ onTabChange={(e: any) => setActiveTab(e.detail.activeTab)}
412
+ >
413
+ <ReactTab slot="tab" panel="preview">Preview</ReactTab>
414
+ <ReactTab slot="tab" panel="html">HTML</ReactTab>
415
+ <ReactTab slot="tab" panel="info">Info</ReactTab>
416
+ <ReactTabPanel slot="panel" id="preview">
417
+ <div className="av-preview">{current.render()}</div>
418
+ </ReactTabPanel>
419
+ <ReactTabPanel slot="panel" id="html">
420
+ <div className="av-code-block">
421
+ <pre><code>{current.importSnippet}</code></pre>
422
+ <ReactCopyButton text={current.importSnippet} size="sm" />
423
+ </div>
424
+ </ReactTabPanel>
425
+ <ReactTabPanel slot="panel" id="info">
426
+ <div className="av-info">
427
+ <dl>
428
+ <dt>Version</dt>
429
+ <dd>{current.version}</dd>
430
+ <dt>Added</dt>
431
+ <dd>{new Date(current.addedDate).toLocaleDateString()}</dd>
432
+ </dl>
433
+ </div>
434
+ </ReactTabPanel>
435
+ </ReactTabs>
436
+ </>
437
+ )}
438
+ </main>
439
+ </div>
440
+ </div>
441
+ )
442
+ }
443
+ `;
444
+ }
445
+ // ---------------------------------------------------------------------------
446
+ // Vue App (App.vue)
447
+ // ---------------------------------------------------------------------------
448
+ function generateVueApp(installedComponents, componentsPath, hasTheme, componentsAbsPath) {
449
+ const names = Object.keys(installedComponents).sort();
450
+ // For Vue, Flex and similar components use a different .vue filename than Vue${name}.vue
451
+ function getVueImportFile(n) {
452
+ const override = VUE_EXPORT_OVERRIDES[n];
453
+ if (override)
454
+ return `${override}.vue`;
455
+ return `Vue${n}.vue`;
456
+ }
457
+ const componentImports = names
458
+ .map(n => {
459
+ const file = getVueImportFile(n);
460
+ const localName = VIEWER_CHROME_COMPONENTS.has(n) ? `UserVue${n}` : (VUE_EXPORT_OVERRIDES[n] ?? `Vue${n}`);
461
+ return `import ${localName} from '@ag-components/${n}/vue/${file}'`;
462
+ })
463
+ .join('\n');
464
+ const themeImport = hasTheme
465
+ ? `import '@ag-components/styles/ag-theme.css'` : '';
466
+ const componentMapEntries = names
467
+ .map(n => {
468
+ const localName = VIEWER_CHROME_COMPONENTS.has(n)
469
+ ? `UserVue${n}`
470
+ : (VUE_EXPORT_OVERRIDES[n] ?? `Vue${n}`);
471
+ return ` ${JSON.stringify(n)}: ${localName}`;
472
+ })
473
+ .join(',\n');
474
+ const componentDataEntries = names.map(n => {
475
+ const meta = installedComponents[n];
476
+ const snippet = `import Vue${n} from '${componentsPath}/${n}/vue/Vue${n}.vue'`;
477
+ return ` {
478
+ name: ${JSON.stringify(n)},
479
+ version: ${JSON.stringify(meta.version)},
480
+ addedDate: ${JSON.stringify(meta.added)},
481
+ files: ${JSON.stringify(meta.files)},
482
+ importSnippet: ${JSON.stringify(snippet)},
483
+ }`;
484
+ }).join(',\n');
485
+ // Build per-component v-if render blocks
486
+ const previewBlocks = names.map((n, i) => {
487
+ const localName = VIEWER_CHROME_COMPONENTS.has(n)
488
+ ? `UserVue${n}`
489
+ : VUE_EXPORT_OVERRIDES[n] ?? undefined;
490
+ const block = getVueRenderBlock(n, localName);
491
+ const condition = i === 0 ? `v-if="current?.name === ${JSON.stringify(n)}"` : `v-else-if="current?.name === ${JSON.stringify(n)}"`;
492
+ return ` <template ${condition}>${block}</template>`;
493
+ }).join('\n');
494
+ return `<!-- Auto-generated by \`ag view\`. Do not edit manually. -->
495
+ <script setup lang="ts">
496
+ import { ref, computed } from 'vue'
497
+ import { VueHeader } from '@ag-ref/Header/vue'
498
+ import { VueTabs, VueTab, VueTabPanel } from '@ag-ref/Tabs/vue'
499
+ import { VueCopyButton } from '@ag-ref/CopyButton/vue'
500
+ import '@ag-components/styles/ag-tokens.css'
501
+ import '@ag-components/styles/ag-tokens-dark.css'
502
+ ${themeImport}
503
+ import './viewer.css'
504
+
505
+ ${componentImports}
506
+
507
+ const componentMap: Record<string, any> = {
508
+ ${componentMapEntries}
509
+ }
510
+
511
+ type ComponentInfo = {
512
+ name: string
513
+ version: string
514
+ addedDate: string
515
+ files: string[]
516
+ importSnippet: string
517
+ }
518
+
519
+ const COMPONENTS: ComponentInfo[] = [
520
+ ${componentDataEntries}
521
+ ]
522
+
523
+ const selected = ref(COMPONENTS[0]?.name ?? '')
524
+ const activeTab = ref(0)
525
+ const current = computed(() => COMPONENTS.find(c => c.name === selected.value) ?? COMPONENTS[0])
526
+
527
+ function selectComponent(name: string) {
528
+ selected.value = name
529
+ activeTab.value = 0
530
+ }
531
+ </script>
532
+
533
+ <template>
534
+ <div class="av-layout">
535
+ <VueHeader>
536
+ <span class="av-brand">AgnosticUI Component Viewer</span>
537
+ </VueHeader>
538
+ <div class="av-body">
539
+ <aside class="av-sidebar">
540
+ <div class="av-overview">
541
+ <p>Browse your ejected components. Click a name to preview.</p>
542
+ <p>
543
+ Add more with <code>ag add &lt;name&gt;</code>.
544
+ <a
545
+ href="https://www.agnosticui.com/installation.html#agnosticui-cli-recommended"
546
+ target="_blank"
547
+ rel="noopener noreferrer"
548
+ >CLI docs →</a>
549
+ </p>
550
+ </div>
551
+ <nav class="av-nav">
552
+ <button
553
+ v-for="c in COMPONENTS"
554
+ :key="c.name"
555
+ @click="selectComponent(c.name)"
556
+ :class="['av-nav-item', { 'av-nav-item--active': c.name === selected }]"
557
+ >{{ c.name }}</button>
558
+ </nav>
559
+ </aside>
560
+ <main class="av-main">
561
+ <template v-if="current">
562
+ <h2 class="av-component-title">{{ current.name }}</h2>
563
+ <VueTabs
564
+ :active-tab="activeTab"
565
+ :aria-label="\`\${current.name} tabs\`"
566
+ @tab-change="activeTab = $event.activeTab"
567
+ >
568
+ <VueTab panel="preview">Preview</VueTab>
569
+ <VueTab panel="html">HTML</VueTab>
570
+ <VueTab panel="info">Info</VueTab>
571
+ <VueTabPanel panel="preview" id="preview">
572
+ <div class="av-preview">
573
+ ${previewBlocks}
574
+ <template v-else>
575
+ <component
576
+ v-if="componentMap[current.name]"
577
+ :is="componentMap[current.name]"
578
+ />
579
+ <div v-else class="av-complex-note">
580
+ <p>{{ current.name }} requires additional props or interaction to preview.</p>
581
+ </div>
582
+ </template>
583
+ </div>
584
+ </VueTabPanel>
585
+ <VueTabPanel panel="html" id="html">
586
+ <div class="av-code-block">
587
+ <pre><code>{{ current.importSnippet }}</code></pre>
588
+ <VueCopyButton :text="current.importSnippet" size="sm" />
589
+ </div>
590
+ </VueTabPanel>
591
+ <VueTabPanel panel="info" id="info">
592
+ <div class="av-info">
593
+ <dl>
594
+ <dt>Version</dt>
595
+ <dd>{{ current.version }}</dd>
596
+ <dt>Added</dt>
597
+ <dd>{{ new Date(current.addedDate).toLocaleDateString() }}</dd>
598
+ </dl>
599
+ </div>
600
+ </VueTabPanel>
601
+ </VueTabs>
602
+ </template>
603
+ </main>
604
+ </div>
605
+ </div>
606
+ </template>
607
+ `;
608
+ }
609
+ // ---------------------------------------------------------------------------
610
+ // Lit / vanilla App (App.ts) — vanilla DOM approach
611
+ // ---------------------------------------------------------------------------
612
+ function generateLitApp(installedComponents, componentsPath, hasTheme) {
613
+ const names = Object.keys(installedComponents).sort();
614
+ const coreImports = names
615
+ .map(n => `import '@ag-components/${n}/core/${n}'`)
616
+ .join('\n');
617
+ const themeImport = hasTheme
618
+ ? `import '@ag-components/styles/ag-theme.css'` : '';
619
+ const componentEntries = names.map(n => {
620
+ const meta = installedComponents[n];
621
+ const snippet = `import '${componentsPath}/${n}/core/${n}'`;
622
+ const renderHtml = getLitRenderHtml(n);
623
+ return ` {
624
+ name: ${JSON.stringify(n)},
625
+ tagName: ${JSON.stringify(getAgTagName(n))},
626
+ version: ${JSON.stringify(meta.version)},
627
+ addedDate: ${JSON.stringify(meta.added)},
628
+ files: ${JSON.stringify(meta.files)},
629
+ importSnippet: ${JSON.stringify(snippet)},
630
+ renderHTML: ${JSON.stringify(renderHtml)},
631
+ }`;
632
+ }).join(',\n');
633
+ // Only import chrome components from @ag-ref if they are NOT already in user components.
634
+ // Both would register the same ag-* custom element tag, causing a double-define error.
635
+ const chromeImports = ['Header', 'Tabs', 'CopyButton']
636
+ .filter(n => !names.includes(n))
637
+ .map(n => `import '@ag-ref/${n}/core/${n}'`)
638
+ .join('\n');
639
+ return `// Auto-generated by \`ag view\`. Do not edit manually.
640
+ ${chromeImports ? chromeImports + '\n' : ''}import '@ag-components/styles/ag-tokens.css'
641
+ import '@ag-components/styles/ag-tokens-dark.css'
642
+ ${themeImport}
643
+ import './viewer.css'
644
+
645
+ ${coreImports}
646
+
647
+ type ComponentInfo = {
648
+ name: string
649
+ tagName: string
650
+ version: string
651
+ addedDate: string
652
+ files: string[]
653
+ importSnippet: string
654
+ renderHTML: string
655
+ }
656
+
657
+ const COMPONENTS: ComponentInfo[] = [
658
+ ${componentEntries}
659
+ ]
660
+
661
+ let selectedName = COMPONENTS[0]?.name ?? ''
662
+
663
+ function escHtml(str: string): string {
664
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
665
+ }
666
+
667
+ function render(): void {
668
+ const current = COMPONENTS.find(c => c.name === selectedName) ?? COMPONENTS[0]
669
+ if (!current) return
670
+
671
+ const navItems = COMPONENTS.map(c =>
672
+ \`<button class="av-nav-item\${c.name === selectedName ? ' av-nav-item--active' : ''}" data-component="\${c.name}">\${c.name}</button>\`
673
+ ).join('\\n')
674
+
675
+ document.body.innerHTML = \`
676
+ <div class="av-layout">
677
+ <ag-header>
678
+ <span class="av-brand">AgnosticUI Component Viewer</span>
679
+ </ag-header>
680
+ <div class="av-body">
681
+ <aside class="av-sidebar">
682
+ <div class="av-overview">
683
+ <p>Browse your ejected components. Click a name to preview.</p>
684
+ <p>Add more with <<code>ag add &lt;name&gt;</code>. <a href="https://www.agnosticui.com/installation.html#agnosticui-cli-recommended" target="_blank" rel="noopener">CLI docs &rarr;</a></p>
685
+ </div>
686
+ <nav class="av-nav">
687
+ \${navItems}
688
+ </nav>
689
+ </aside>
690
+ <main class="av-main">
691
+ <h2 class="av-component-title">\${current.name}</h2>
692
+ <ag-tabs aria-label="\${current.name} tabs">
693
+ <ag-tab slot="tab" panel="preview">Preview</ag-tab>
694
+ <ag-tab slot="tab" panel="html">HTML</ag-tab>
695
+ <ag-tab slot="tab" panel="info">Info</ag-tab>
696
+ <ag-tab-panel slot="panel" id="preview">
697
+ <div class="av-preview">\${current.renderHTML}</div>
698
+ </ag-tab-panel>
699
+ <ag-tab-panel slot="panel" id="html">
700
+ <div class="av-code-block">
701
+ <pre><code>\${escHtml(current.importSnippet)}</code></pre>
702
+ <ag-copy-button text="\${current.importSnippet.replace(/"/g, '&quot;')}" size="sm"></ag-copy-button>
703
+ </div>
704
+ </ag-tab-panel>
705
+ <ag-tab-panel slot="panel" id="info">
706
+ <div class="av-info">
707
+ <dl>
708
+ <dt>Version</dt><dd>\${current.version}</dd>
709
+ <dt>Added</dt><dd>\${new Date(current.addedDate).toLocaleDateString()}</dd>
710
+ </dl>
711
+ </div>
712
+ </ag-tab-panel>
713
+ </ag-tabs>
714
+ </main>
715
+ </div>
716
+ </div>
717
+ \`
718
+
719
+ // Re-attach nav listeners after each re-render
720
+ document.querySelectorAll<HTMLElement>('.av-nav-item').forEach(btn => {
721
+ btn.addEventListener('click', () => {
722
+ selectedName = btn.dataset.component ?? ''
723
+ render()
724
+ })
725
+ })
726
+ }
727
+
728
+ render()
729
+ `;
730
+ }
731
+ // ---------------------------------------------------------------------------
732
+ // Viewer CSS (same for all frameworks)
733
+ // ---------------------------------------------------------------------------
734
+ function generateViewerCss() {
735
+ return `/* AgnosticUI Component Viewer — Chrome Styles */
736
+ *, *::before, *::after { box-sizing: border-box; }
737
+
738
+ body {
739
+ margin: 0;
740
+ font-family: var(--ag-font-family-base, system-ui, -apple-system, sans-serif);
741
+ background: var(--ag-background-primary, #ffffff);
742
+ color: var(--ag-text-primary, #111827);
743
+ font-size: 16px;
744
+ line-height: 1.5;
745
+ }
746
+
747
+ .av-layout {
748
+ display: flex;
749
+ flex-direction: column;
750
+ min-height: 100vh;
751
+ }
752
+
753
+ .av-body {
754
+ display: flex;
755
+ flex: 1;
756
+ min-height: 0;
757
+ overflow: hidden;
758
+ }
759
+
760
+ /* Sidebar */
761
+ .av-sidebar {
762
+ width: 220px;
763
+ flex-shrink: 0;
764
+ border-right: 1px solid var(--ag-border-color, #e5e7eb);
765
+ overflow-y: auto;
766
+ padding: 1.25rem 1rem;
767
+ background: var(--ag-background-secondary, #f9fafb);
768
+ }
769
+
770
+ .av-overview {
771
+ font-size: 0.8125rem;
772
+ color: var(--ag-text-secondary, #6b7280);
773
+ margin-bottom: 1.25rem;
774
+ padding-bottom: 1rem;
775
+ border-bottom: 1px solid var(--ag-border-color, #e5e7eb);
776
+ line-height: 1.55;
777
+ }
778
+
779
+ .av-overview p { margin: 0 0 0.5rem; }
780
+ .av-overview p:last-child { margin-bottom: 0; }
781
+
782
+ .av-overview a {
783
+ color: var(--ag-color-primary, #2563eb);
784
+ text-decoration: none;
785
+ }
786
+ .av-overview a:hover { text-decoration: underline; }
787
+
788
+ .av-overview code {
789
+ font-family: var(--ag-font-family-mono, monospace);
790
+ font-size: 0.75rem;
791
+ background: var(--ag-background-muted, #f3f4f6);
792
+ padding: 0.125rem 0.25rem;
793
+ border-radius: 3px;
794
+ }
795
+
796
+ /* Nav */
797
+ .av-nav {
798
+ display: flex;
799
+ flex-direction: column;
800
+ gap: 2px;
801
+ }
802
+
803
+ .av-nav-item {
804
+ display: block;
805
+ width: 100%;
806
+ text-align: left;
807
+ padding: 0.375rem 0.625rem;
808
+ border: none;
809
+ border-radius: 0.25rem;
810
+ background: transparent;
811
+ color: var(--ag-text-secondary, #374151);
812
+ cursor: pointer;
813
+ font-size: 0.875rem;
814
+ font-family: inherit;
815
+ transition: background 0.1s, color 0.1s;
816
+ }
817
+
818
+ .av-nav-item:hover {
819
+ background: var(--ag-background-muted, #f3f4f6);
820
+ color: var(--ag-text-primary, #111827);
821
+ }
822
+
823
+ .av-nav-item--active {
824
+ background: var(--ag-color-primary-50, #eff6ff);
825
+ color: var(--ag-color-primary-700, #1d4ed8);
826
+ font-weight: 600;
827
+ }
828
+
829
+ /* Main content */
830
+ .av-main {
831
+ flex: 1;
832
+ padding: 2rem;
833
+ overflow-y: auto;
834
+ }
835
+
836
+ .av-component-title {
837
+ margin: 0 0 1.25rem;
838
+ font-size: 1.5rem;
839
+ font-weight: 700;
840
+ color: var(--ag-text-primary, #111827);
841
+ }
842
+
843
+ /* Header brand */
844
+ .av-brand {
845
+ font-weight: 600;
846
+ font-size: 1rem;
847
+ }
848
+
849
+ /* Preview area */
850
+ .av-preview {
851
+ padding: 2rem;
852
+ border: 1px solid var(--ag-border-color, #e5e7eb);
853
+ border-radius: 0.5rem;
854
+ background: var(--ag-background-primary, #ffffff);
855
+ min-height: 120px;
856
+ display: flex;
857
+ flex-wrap: wrap;
858
+ align-items: flex-start;
859
+ gap: 1rem;
860
+ }
861
+
862
+ /* Code block */
863
+ .av-code-block {
864
+ background: var(--ag-background-secondary, #f9fafb);
865
+ border: 1px solid var(--ag-border-color, #e5e7eb);
866
+ border-radius: 0.5rem;
867
+ padding: 1.25rem;
868
+ }
869
+
870
+ .av-code-block pre {
871
+ margin: 0 0 1rem;
872
+ overflow-x: auto;
873
+ }
874
+
875
+ .av-code-block code {
876
+ font-family: var(--ag-font-family-mono, 'Fira Code', 'Cascadia Code', monospace);
877
+ font-size: 0.875rem;
878
+ color: var(--ag-text-primary, #111827);
879
+ }
880
+
881
+ /* Info panel */
882
+ .av-info { font-size: 0.875rem; }
883
+
884
+ .av-info dl {
885
+ display: grid;
886
+ grid-template-columns: auto 1fr;
887
+ gap: 0.5rem 1.5rem;
888
+ margin: 0;
889
+ }
890
+
891
+ .av-info dt {
892
+ font-weight: 600;
893
+ color: var(--ag-text-secondary, #6b7280);
894
+ }
895
+
896
+ .av-info dd {
897
+ margin: 0;
898
+ color: var(--ag-text-primary, #374151);
899
+ word-break: break-all;
900
+ }
901
+
902
+ /* Complex component placeholder */
903
+ .av-complex-note {
904
+ color: var(--ag-text-secondary, #6b7280);
905
+ font-size: 0.875rem;
906
+ font-style: italic;
907
+ }
908
+ `;
909
+ }
910
+ // ---------------------------------------------------------------------------
911
+ // Public: orchestrator
912
+ // ---------------------------------------------------------------------------
913
+ export async function generateViewerApp(config, viewerPath, cwd) {
914
+ const componentsAbsPath = path.resolve(cwd, config.paths.components);
915
+ const refLibAbsPath = path.resolve(cwd, config.paths.reference);
916
+ const hasTheme = detectThemeFile(componentsAbsPath);
917
+ // Extract component metadata
918
+ const installedComponents = {};
919
+ for (const [name, entry] of Object.entries(config.components)) {
920
+ installedComponents[name] = {
921
+ version: entry.version,
922
+ added: entry.added,
923
+ files: entry.files,
924
+ };
925
+ }
926
+ const { framework } = config;
927
+ const srcPath = path.join(viewerPath, 'src');
928
+ await ensureDir(srcPath);
929
+ // Root files
930
+ await writeFile(path.join(viewerPath, 'package.json'), generatePackageJson(framework));
931
+ await writeFile(path.join(viewerPath, 'tsconfig.json'), generateTsConfig(framework));
932
+ await writeFile(path.join(viewerPath, 'vite.config.ts'), generateViteConfig(framework, componentsAbsPath, refLibAbsPath));
933
+ await writeFile(path.join(viewerPath, 'index.html'), generateIndexHtml(framework));
934
+ // src/ files
935
+ let mainContent;
936
+ let appContent;
937
+ let appFileName;
938
+ if (framework === 'react') {
939
+ mainContent = generateReactMain();
940
+ appContent = generateReactApp(installedComponents, config.paths.components, hasTheme, componentsAbsPath);
941
+ appFileName = 'App.tsx';
942
+ await writeFile(path.join(srcPath, 'main.tsx'), mainContent);
943
+ }
944
+ else if (framework === 'vue') {
945
+ mainContent = generateVueMain();
946
+ appContent = generateVueApp(installedComponents, config.paths.components, hasTheme, componentsAbsPath);
947
+ appFileName = 'App.vue';
948
+ await writeFile(path.join(srcPath, 'main.ts'), mainContent);
949
+ }
950
+ else {
951
+ // lit / svelte / other
952
+ mainContent = generateLitMain();
953
+ appContent = generateLitApp(installedComponents, config.paths.components, hasTheme);
954
+ appFileName = 'App.ts';
955
+ await writeFile(path.join(srcPath, 'main.ts'), mainContent);
956
+ }
957
+ await writeFile(path.join(srcPath, appFileName), appContent);
958
+ await writeFile(path.join(srcPath, 'viewer.css'), generateViewerCss());
959
+ }
960
+ //# sourceMappingURL=viewer.js.map