@vunset/nuxt 0.0.0-stage → 1.0.0-rc.2

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Denis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,84 @@
1
- # Temporary Holding Version
1
+ # @vunset/nuxt
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Nuxt module for Vunset. Every component and composable is available in your
4
+ pages without importing it, and the styles are already loaded.
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ npm install @vunset/nuxt
10
+ ```
11
+
12
+ [`@vunset/vue`](https://www.npmjs.com/package/@vunset/vue),
13
+ [`@vunset/ui`](https://www.npmjs.com/package/@vunset/ui) and
14
+ [`@vunset/utils`](https://www.npmjs.com/package/@vunset/utils) come with it.
15
+
16
+ ```ts
17
+ // nuxt.config.ts
18
+ export default defineNuxtConfig({
19
+ modules: ['@vunset/nuxt']
20
+ });
21
+ ```
22
+
23
+ That's it:
24
+
25
+ ```vue
26
+ <script setup lang="ts">
27
+ const { setToast } = useToast();
28
+ </script>
29
+
30
+ <template>
31
+ <AppButton variant="solid" tone="accent" @click="setToast({ title: 'Saved' })">Save</AppButton>
32
+ <AppToaster />
33
+ </template>
34
+ ```
35
+
36
+ Charts and data tables need one more package each. Install the ones you use,
37
+ and the components show up on their own:
38
+
39
+ ```sh
40
+ npm install @tanstack/charts # ChartArea, ChartBar, ChartDual, ChartDonut
41
+ npm install @tanstack/vue-table # DataTable, AppDataTable
42
+ ```
43
+
44
+ ## What you get
45
+
46
+ - Every component, headless (`DialogRoot`, `SelectTrigger`) and styled
47
+ (`AppButton`, `FormSelect`), loaded only on the pages that use it.
48
+ - The composables (`useToast`, `useTheme`, `useClipboard`, `useInfinityFetch`, …)
49
+ and the `@vunset/utils` helpers, auto-imported, in server routes too.
50
+ - The stylesheet, loaded before your own CSS.
51
+ - Icons from a folder of SVG files, or a sprite built from one.
52
+ - Light and dark mode, remembered across visits.
53
+ - Server rendering, toasts included.
54
+
55
+ ## Options
56
+
57
+ ```ts
58
+ export default defineNuxtConfig({
59
+ modules: ['@vunset/nuxt'],
60
+ vunset: {
61
+ prefix: 'Vs'
62
+ }
63
+ });
64
+ ```
65
+
66
+ | Option | Default | |
67
+ | ------------- | --------- | -------------------------------------------------------------------- |
68
+ | `prefix` | `''` | Goes in front of every component name: `'Vs'` gives `<VsAppButton>`. |
69
+ | `vue` | `true` | Registers the headless parts. |
70
+ | `ui` | `true` | Registers the styled components and loads their stylesheet. |
71
+ | `css` | with `ui` | Loads `@vunset/ui/all.css`. |
72
+ | `preflight` | `false` | Loads ui's CSS reset. Turn it on if you don't use Tailwind. |
73
+ | `composables` | `true` | Auto-imports the composables. |
74
+ | `utils` | `true` | Auto-imports the `@vunset/utils` helpers. |
75
+ | `icons` | see docs | Where your icons live: `{ dir, spriteDir }`, both `~/assets/icons`. |
76
+ | `theme` | `true` | Sets up light and dark mode. `false` leaves it to you. |
77
+
78
+ ## Documentation
79
+
80
+ [vunset.com/nuxt/get-started](https://vunset.com/nuxt/get-started)
81
+
82
+ ## License
83
+
84
+ MIT
@@ -0,0 +1,13 @@
1
+ export interface Entry {
2
+ /** What the components are imported from. */
3
+ from: string;
4
+ /** The package it belongs to. */
5
+ package: string;
6
+ /** Optional peers the entry imports: without them installed, it is skipped. */
7
+ peers: string[];
8
+ components: string[];
9
+ composables: string[];
10
+ }
11
+ export declare const ENTRIES: Entry[];
12
+ /** `@vunset/utils`' helpers, imported in the app and on the server. */
13
+ export declare const UTILS: string[];
@@ -0,0 +1,46 @@
1
+ export const ENTRIES = [
2
+ {
3
+ from: '@vunset/vue',
4
+ package: '@vunset/vue',
5
+ peers: [],
6
+ components: ['SwitchRoot', 'SwitchThumb', 'CheckboxRoot', 'CheckboxIndicator', 'CheckboxGroup', 'RadioGroup', 'RadioRoot', 'RadioIndicator', 'CollapsibleRoot', 'CollapsibleTrigger', 'CollapsiblePanel', 'AccordionRoot', 'AccordionItem', 'AccordionHeader', 'AccordionTrigger', 'AccordionPanel', 'PopoverRoot', 'PopoverTrigger', 'PopoverArrow', 'PopoverClose', 'PopoverTitle', 'PopoverDescription', 'Portal', 'Positioner', 'Popup', 'Addon', 'Separator', 'DialogRoot', 'DialogTrigger', 'DialogBackdrop', 'DialogTitle', 'DialogDescription', 'DialogClose', 'DrawerRoot', 'DrawerTrigger', 'DrawerBackdrop', 'DrawerHandle', 'DrawerTitle', 'DrawerDescription', 'DrawerClose', 'TooltipGroup', 'TooltipRoot', 'TooltipTrigger', 'TooltipArrow', 'FieldRoot', 'FieldLabel', 'FieldGroup', 'Input', 'Textarea', 'FieldDescription', 'FieldError', 'SelectRoot', 'SelectTrigger', 'SelectValue', 'SelectClear', 'SelectIndicator', 'SelectSearch', 'SelectList', 'SelectItem', 'SelectEmpty', 'SelectScrollIndicator', 'SelectPresets', 'ComboboxRoot', 'ComboboxTrigger', 'ComboboxChips', 'ComboboxChip', 'ComboboxChipRemove', 'ComboboxInput', 'ComboboxClear', 'ComboboxIndicator', 'ComboboxList', 'ComboboxItem', 'ComboboxEmpty', 'ComboboxScrollIndicator', 'ComboboxPresets', 'RangeRoot', 'RangeLabel', 'RangeValue', 'RangeControl', 'RangeTrack', 'RangeIndicator', 'RangeThumb', 'ToastViewport', 'ToastRoot', 'ToastTitle', 'ToastDescription', 'ToastClose', 'ToastAction', 'ToastProgress', 'Button', 'Badge', 'Icon', 'TableRoot', 'TableCaption', 'TableHeader', 'TableBody', 'TableFooter', 'TableRow', 'TableHeadCell', 'TableCell', 'AsyncState', 'DragRoot', 'DragItem', 'DragHandle', 'Copy', 'AlertRoot', 'AlertTitle', 'AlertDescription', 'Group', 'Appear', 'Deferred', 'Toggle', 'ToggleGroup', 'ProgressRoot', 'ProgressTrack', 'ProgressIndicator', 'ProgressValue', 'ProgressLabel', 'MeterRoot', 'MeterTrack', 'MeterIndicator', 'MeterValue', 'MeterLabel', 'PaginationRoot', 'PaginationPrev', 'PaginationNext', 'PaginationItem', 'PaginationEllipsis', 'FloatingRoot', 'FloatingHeader', 'FloatingTitle', 'FloatingClose', 'FloatingResizer', 'CalendarRoot', 'CalendarPrev', 'CalendarNext', 'CalendarViewTrigger', 'CalendarGrid', 'CalendarRow', 'CalendarWeek', 'CalendarWeekDay', 'CalendarDay', 'CalendarMonth', 'CalendarYear', 'CalendarPresets', 'CalendarPreset', 'DatepickerRoot', 'DatepickerTrigger', 'DatepickerValue', 'DatepickerClear', 'TimepickerRoot', 'TimepickerInput', 'TimepickerSegment', 'TimepickerTrigger', 'TimepickerClear', 'TimepickerColumn', 'TabsRoot', 'TabsList', 'TabsTab', 'TabsPanel', 'TabsIndicator', 'ScrollArea', 'ContextMenuRoot', 'ContextMenuTrigger', 'MenubarRoot', 'MenubarLink', 'MenuRoot', 'MenuTrigger', 'MenuItem', 'MenuCheckboxItem', 'MenuRadioGroup', 'MenuRadioItem', 'MenuGroup', 'MenuGroupLabel', 'MenuArrow', 'MenuItemIndicator', 'Part'],
7
+ composables: ['useToast', 'useClipboard', 'useClickOutside', 'useLocalStorage', 'useDownload', 'useExportCsv', 'useTheme', 'useValidate', 'useVirtualList']
8
+ },
9
+ {
10
+ from: '@vunset/vue/chart',
11
+ package: '@vunset/vue',
12
+ peers: ['@tanstack/charts'],
13
+ components: ['ChartRoot', 'ChartArea', 'ChartLine', 'ChartBar', 'ChartAxis', 'ChartGrid', 'ChartCrosshair', 'ChartTooltip', 'ChartLegend', 'ChartDonut'],
14
+ composables: []
15
+ },
16
+ {
17
+ from: '@vunset/vue/data-table',
18
+ package: '@vunset/vue',
19
+ peers: ['@tanstack/vue-table'],
20
+ components: ['DataTable'],
21
+ composables: []
22
+ },
23
+ {
24
+ from: '@vunset/ui',
25
+ package: '@vunset/ui',
26
+ peers: [],
27
+ components: ['FormField', 'FormInput', 'FormTextarea', 'FormCheckbox', 'FormCheckboxGroup', 'FormRadioGroup', 'FormRadio', 'FormSwitch', 'FormSelect', 'FormCombobox', 'FormRange', 'FormCalendar', 'FormDatepicker', 'FormTimepicker', 'FormToggleGroup', 'AppIcon', 'AppButton', 'AppCopy', 'AppBadge', 'AppAlert', 'AppProgress', 'AppMeter', 'AppSpinner', 'AppDotMatrix', 'AppEmptyState', 'AppAsyncState', 'AppCard', 'AppAppear', 'AppNumberFlow', 'AppTable', 'AppGroup', 'AppDialog', 'AppDrawer', 'AppPopover', 'AppConfirm', 'AppTooltip', 'AppTabs', 'AppCollapsible', 'AppAccordion', 'AppPagination', 'AppMenu', 'AppMenuItems', 'AppMenubar', 'AppContextMenu', 'AppFloating', 'AppToaster'],
28
+ composables: []
29
+ },
30
+ {
31
+ from: '@vunset/ui/chart',
32
+ package: '@vunset/ui',
33
+ peers: ['@tanstack/charts'],
34
+ components: ['AppChartArea', 'AppChartLine', 'AppChartBar', 'AppChartDonut'],
35
+ composables: []
36
+ },
37
+ {
38
+ from: '@vunset/ui/data-table',
39
+ package: '@vunset/ui',
40
+ peers: ['@tanstack/vue-table'],
41
+ components: ['AppDataTable'],
42
+ composables: []
43
+ }
44
+ ];
45
+ /** `@vunset/utils`' helpers, imported in the app and on the server. */
46
+ export const UTILS = ['CHART_COLOR_PREFIX', 'chartColors', 'escapeCsvValue', 'formatCsv', 'DAYS_IN_WEEK', 'MONTHS_IN_YEAR', 'toDate', 'startOfDay', 'endOfDay', 'startOfMonth', 'endOfMonth', 'startOfYear', 'endOfYear', 'daysInMonth', 'addDays', 'addMonths', 'addYears', 'subDays', 'subMonths', 'subYears', 'compareDay', 'isSameDay', 'isSameMonth', 'isToday', 'isWeekend', 'isWithin', 'clampDate', 'monthNames', 'firstDayOfWeek', 'weekDayNames', 'formatDate', 'formatDateRange', 'formatISO', 'formatSafeISO', 'setDate', 'setDateRange', 'presetRange', 'debounce', 'toFixedNumber', 'calcTotal', 'calcAvg', 'calcPercentage', 'calcDiffPercentage', 'formatNumber', 'formatCurrency', 'formatPercentage', 'toSeparatedArray', 'toSeparatedString', 'toSeparatedList', 'getRandomString', 'generateRandomString', 'parseInlineSvg'];
@@ -0,0 +1,71 @@
1
+ export interface ModuleOptions {
2
+ /**
3
+ * Put before every component's name: `'Vs'` registers `<VsDialogRoot>` and
4
+ * `<VsAppButton>`. Empty, each keeps its own name.
5
+ * @default ''
6
+ */
7
+ prefix?: string;
8
+ /** Registers `@vunset/vue`'s headless parts. @default true */
9
+ vue?: boolean;
10
+ /** Registers `@vunset/ui`'s components and adds its stylesheet. `false` leaves ui out. @default true */
11
+ ui?: boolean;
12
+ /** Adds `@vunset/ui/all.css`, before the app's own stylesheets. On with `ui`. */
13
+ css?: boolean;
14
+ /**
15
+ * Adds `@vunset/ui/preflight.css`, before the app's own stylesheets: the reset
16
+ * the components are built on, for an app without Tailwind, whose Preflight is one.
17
+ * @default false
18
+ */
19
+ preflight?: boolean;
20
+ /** Auto-imports the public composables: `useToast`, `useTheme`, `useClipboard`, ... @default true */
21
+ composables?: boolean;
22
+ /**
23
+ * Auto-imports `@vunset/utils`' helpers (`formatDate`, `formatNumber`,
24
+ * `calcTotal`, ...) in the app and in server routes, as a layer's `shared/utils` are.
25
+ * @default true
26
+ */
27
+ utils?: boolean;
28
+ /**
29
+ * Where `Icon` and `AppIcon` find their icons, handed to `configureIcons` for the app.
30
+ * `false` leaves icons to the app.
31
+ */
32
+ icons?: IconsOptions | false;
33
+ /**
34
+ * Sets up `useTheme` before the app's components and provides it as `$theme`.
35
+ * With a `settings` object in `app.config` that has a `key`, the theme is
36
+ * `settings.theme`, and all of `settings` is kept in `localStorage` under that key.
37
+ * `false` leaves `useTheme` to the app. @default true
38
+ */
39
+ theme?: boolean | ThemeOptions;
40
+ }
41
+ export interface ThemeOptions {
42
+ /** The choice before anything is stored. @default 'system' */
43
+ default?: 'light' | 'dark' | 'system';
44
+ /** The `localStorage` key, without `settings` in `app.config`; `null` remembers nothing. @default 'theme' */
45
+ storageKey?: string | null;
46
+ /** `<meta name="theme-color">` for each scheme; `settings.themeColor` in `app.config` wins. */
47
+ themeColor?: {
48
+ light: string;
49
+ dark: string;
50
+ };
51
+ /** Cross-fades the page on a change, where the browser can. @default true */
52
+ viewTransition?: boolean;
53
+ }
54
+ export interface IconsOptions {
55
+ /**
56
+ * A folder of SVG files, drawn inline: `'~/assets/icons'`, or a path from the
57
+ * app's source folder. Several, and the first with a name wins.
58
+ * @default '~/assets/icons', where it exists
59
+ */
60
+ dir?: string | string[];
61
+ /** Where the sprite is served from. @default '/sprite.svg' */
62
+ sprite?: string;
63
+ /**
64
+ * A folder of SVG files to build the sprite from, served at `sprite` and
65
+ * rebuilt as they change. Several, and the first with a name wins.
66
+ * @default '~/assets/icons', where it exists and `sprite` isn't set
67
+ */
68
+ spriteDir?: string | string[];
69
+ }
70
+ declare const _default: import("@nuxt/schema").NuxtModule<ModuleOptions, ModuleOptions, false>;
71
+ export default _default;
package/dist/module.js ADDED
@@ -0,0 +1,270 @@
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
2
+ import { defu } from 'defu';
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { addComponent, addImports, addPlugin, addServerImports, addTemplate, createResolver, defineNuxtModule, directoryToURL, resolveAlias, tryResolveModule, useLogger } from '@nuxt/kit';
6
+ import { ENTRIES, UTILS } from './components.js';
7
+ import { buildSprite, readIcons } from './sprite.js';
8
+ /** The kit's own packages: dependencies of the module, so an app lists the module alone. */
9
+ const OWN = ['@vunset/vue', '@vunset/ui', '@vunset/utils'];
10
+ const isOwn = (id) => OWN.some((name) => id === name || id.startsWith(`${name}/`));
11
+ const toPath = (resolved) => (resolved.startsWith('file:') ? fileURLToPath(resolved) : resolved);
12
+ /**
13
+ * An alias per entry a package exports, to the file the entry names: the app
14
+ * imports `@vunset/vue` without listing it, so its own resolution would not
15
+ * find it under pnpm or through a `file:` link. Longest first: Vite and Nitro
16
+ * take the first alias that matches, and a bare name matches its subpaths too.
17
+ */
18
+ function exportAliases(name, resolved) {
19
+ let dir = dirname(toPath(resolved));
20
+ for (;;) {
21
+ const manifest = join(dir, 'package.json');
22
+ if (existsSync(manifest)) {
23
+ const json = JSON.parse(readFileSync(manifest, 'utf8'));
24
+ if (json.name === name) {
25
+ const entries = Object.entries(json.exports ?? {});
26
+ entries.sort(([a], [b]) => b.length - a.length);
27
+ return Object.fromEntries(entries.map(([key, target]) => {
28
+ const file = typeof target === 'string' ? target : (target.import ?? target.default ?? '');
29
+ // `./css/*` maps a folder: the alias is its prefix.
30
+ return key.endsWith('/*')
31
+ ? [`${name}${key.slice(1, -2)}`, join(dir, file.slice(0, -2))]
32
+ : [`${name}${key.slice(1)}`, join(dir, file)];
33
+ }));
34
+ }
35
+ }
36
+ const parent = dirname(dir);
37
+ if (parent === dir)
38
+ return {};
39
+ dir = parent;
40
+ }
41
+ }
42
+ /** Types an app uses without importing them, as Nuxt's own `Ref` is. The later entry wins: ui's, where an app may not list vue. */
43
+ const GLOBAL_TYPES = {
44
+ '@vunset/vue': [
45
+ 'ListboxItem',
46
+ 'ListboxOption',
47
+ 'ListboxOptionValue',
48
+ 'ToggleGroupValue',
49
+ 'CheckboxGroupValue',
50
+ 'RadioValue',
51
+ 'CalendarValueInput',
52
+ 'Toast',
53
+ 'ToastOptions'
54
+ ],
55
+ '@vunset/ui': [
56
+ 'AppMenubarEntry',
57
+ 'AppMenuEntry',
58
+ 'AppToastData',
59
+ 'AppTab',
60
+ 'AppAccordionSection',
61
+ 'FormToggleOption',
62
+ 'FormCheckboxOption',
63
+ 'FormRadioOption'
64
+ ],
65
+ '@vunset/vue/data-table': ['DataTableColumn', 'DataTableColumnHelper'],
66
+ '@vunset/ui/data-table': ['DataTableColumn', 'DataTableColumnHelper']
67
+ };
68
+ /** lightningcss' `Features.LightDark`. */
69
+ const LIGHT_DARK = 1 << 20;
70
+ /** Where `dir` and `spriteDir` look when they aren't set. */
71
+ const ICONS_DIR = '~/assets/icons';
72
+ export default defineNuxtModule({
73
+ meta: {
74
+ name: '@vunset/nuxt',
75
+ configKey: 'vunset',
76
+ compatibility: { nuxt: '>=3.13.0' }
77
+ },
78
+ defaults: {
79
+ prefix: '',
80
+ theme: true,
81
+ vue: true,
82
+ composables: true,
83
+ utils: true
84
+ },
85
+ async setup(options, nuxt) {
86
+ const logger = useLogger('@vunset/nuxt');
87
+ const resolver = createResolver(import.meta.url);
88
+ const roots = [nuxt.options.rootDir, ...nuxt.options.modulesDir].map((dir) => directoryToURL(dir));
89
+ // The kit's packages are found from the module, which depends on them; a peer such as TanStack, from the app.
90
+ const resolveFrom = (id) => tryResolveModule(id, isOwn(id) ? [new URL(import.meta.url)] : roots);
91
+ const installed = async (id) => Boolean(await resolveFrom(id));
92
+ // Linked from elsewhere (`file:` a checkout) rather than installed into `node_modules`.
93
+ const linked = async (id) => {
94
+ const path = await resolveFrom(id);
95
+ if (!path)
96
+ return false;
97
+ try {
98
+ return !realpathSync(toPath(path)).includes(`${sep}node_modules${sep}`);
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ };
104
+ for (const name of OWN) {
105
+ const path = await resolveFrom(name);
106
+ if (!path)
107
+ continue;
108
+ for (const [id, target] of Object.entries(exportAliases(name, path)))
109
+ nuxt.options.alias[id] ??= target;
110
+ }
111
+ const ui = options.ui ?? true;
112
+ if (ui && !(await installed('@vunset/ui'))) {
113
+ logger.warn('`ui` is on, but `@vunset/ui` is not installed: its components are left out.');
114
+ }
115
+ let missingPeers = false;
116
+ const types = new Map();
117
+ for (const entry of ENTRIES) {
118
+ if (entry.package === '@vunset/ui' && !ui)
119
+ continue;
120
+ if (!(await installed(entry.from)))
121
+ continue;
122
+ // A chart or data table needs its optional peers; without them, importing the entry fails.
123
+ if (!(await Promise.all(entry.peers.map(installed))).every(Boolean)) {
124
+ missingPeers = true;
125
+ continue;
126
+ }
127
+ // `vue: false` leaves out the headless parts, not the composables ui is used with.
128
+ const components = entry.package === '@vunset/vue' && !options.vue ? [] : entry.components;
129
+ for (const name of components) {
130
+ addComponent({
131
+ name: `${options.prefix}${name}`,
132
+ export: name,
133
+ filePath: entry.from,
134
+ // An app's own component of the same name wins, without a warning.
135
+ priority: -1
136
+ });
137
+ }
138
+ if (options.composables) {
139
+ addImports(entry.composables.map((name) => ({ name, from: entry.from })));
140
+ }
141
+ for (const name of GLOBAL_TYPES[entry.from] ?? [])
142
+ types.set(name, entry.from);
143
+ // Pre-bundled when the dev server starts, rather than found by the first page
144
+ // and reloaded. Not the chart or data table, which would pull TanStack into every app.
145
+ // A linked package is kept out: Vite would serve the copy it bundled once,
146
+ // through every rebuild, where served as source a rebuild shows at once.
147
+ const optimizeDeps = (nuxt.options.vite.optimizeDeps ??= {});
148
+ if (await linked(entry.from))
149
+ (optimizeDeps.exclude ??= []).push(entry.from);
150
+ else if (entry.peers.length === 0)
151
+ (optimizeDeps.include ??= []).push(entry.from);
152
+ }
153
+ addImports([...types].map(([name, from]) => ({ name, from, type: true })));
154
+ // Peers are looked for once, here: installing one restarts Nuxt, so its entry
155
+ // registers without the dev server being stopped and started by hand.
156
+ if (missingPeers && nuxt.options.dev)
157
+ nuxt.options.watch.push(join(nuxt.options.rootDir, 'package.json'));
158
+ if (options.composables) {
159
+ addImports({ name: 'useInfinityFetch', from: resolver.resolve('./runtime/composables/use-infinity-fetch') });
160
+ }
161
+ // Under the app's own: a helper of the same name in its `utils` or `shared` wins.
162
+ if (options.utils && (await installed('@vunset/utils'))) {
163
+ const imports = UTILS.map((name) => ({ name, from: '@vunset/utils', priority: -1 }));
164
+ addImports(imports);
165
+ addServerImports(imports);
166
+ }
167
+ if (await linked('@vunset/utils'))
168
+ ((nuxt.options.vite.optimizeDeps ??= {}).exclude ??= []).push('@vunset/utils');
169
+ // A store per server request, carried to the page; see the plugin.
170
+ nuxt.options.build.transpile.push(resolver.resolve('./runtime'));
171
+ addPlugin(resolver.resolve('./runtime/toasts'));
172
+ addPlugin(resolver.resolve('./runtime/link'));
173
+ addPlugin(resolver.resolve('./runtime/route-query'));
174
+ if (options.theme !== false) {
175
+ const runtime = nuxt.options.runtimeConfig.public;
176
+ const theme = options.theme === true || options.theme === undefined ? {} : options.theme;
177
+ runtime.vunset = defu(runtime.vunset, { theme });
178
+ addPlugin(resolver.resolve('./runtime/theme'));
179
+ }
180
+ if (options.icons !== false)
181
+ addIcons(options.icons ?? {});
182
+ // Before the app's own, so its `components` rules come after ui's and win. ui
183
+ // declares Tailwind's layer order, so going first doesn't change how they rank.
184
+ if (ui && (options.css ?? true))
185
+ nuxt.options.css.unshift('@vunset/ui/all.css');
186
+ if (ui && options.preflight)
187
+ nuxt.options.css.unshift('@vunset/ui/preflight.css');
188
+ // Vite minifies CSS with lightningcss, which rewrites `light-dark()` for older
189
+ // browsers into variables only a `color-scheme` in the same CSS sets. One set
190
+ // on `<html>` by `useTheme()`, or none, leaves ui's colors invalid.
191
+ if (ui) {
192
+ const lightningcss = ((nuxt.options.vite.css ??= {}).lightningcss ??= {});
193
+ lightningcss.exclude = (lightningcss.exclude ?? 0) | LIGHT_DARK;
194
+ }
195
+ /** The folders an option names, as absolute paths; a missing one is reported. */
196
+ function folders(option) {
197
+ return [option ?? []].flat().map((folder) => {
198
+ const path = resolveAlias(folder);
199
+ const absolute = isAbsolute(path) ? path : resolve(nuxt.options.srcDir, path);
200
+ if (!existsSync(absolute))
201
+ logger.warn(`The icons folder \`${folder}\` doesn't exist.`);
202
+ return absolute;
203
+ });
204
+ }
205
+ /** The default folder, when the app has it: unasked for, its absence isn't reported. */
206
+ function conventional() {
207
+ const path = resolveAlias(ICONS_DIR);
208
+ const absolute = isAbsolute(path) ? path : resolve(nuxt.options.srcDir, path);
209
+ return existsSync(absolute) ? [absolute] : [];
210
+ }
211
+ function addIcons({ dir, sprite, spriteDir }) {
212
+ const inlineDirs = dir === undefined ? conventional() : folders(dir);
213
+ // A `sprite` alone is one the app made itself: nothing is built over it.
214
+ const spriteDirs = spriteDir !== undefined ? folders(spriteDir) : sprite === undefined ? conventional() : [];
215
+ // Neither asked for nor there: the app's icons are its own business.
216
+ if (!options.icons && inlineDirs.length === 0 && spriteDirs.length === 0)
217
+ return;
218
+ // Nuxt's templates are virtual, so a glob can't be relative to one: it starts
219
+ // with `/`, from Vite's root, which is the app's source folder.
220
+ const root = nuxt.options.vite.root ?? nuxt.options.srcDir;
221
+ const globs = inlineDirs.map((folder) => {
222
+ const pattern = `/${relative(root, folder).replaceAll('\\', '/')}/*.svg`;
223
+ return `...import.meta.glob(${JSON.stringify(pattern)}, { query: '?raw', import: 'default' })`;
224
+ });
225
+ // Only the globs are written out: a glob takes no variable. Spread in order,
226
+ // so the first folder's file comes first, and is found first.
227
+ const template = addTemplate({
228
+ filename: 'vunset-icons.mjs',
229
+ getContents: () => `export const inline = ${globs.length > 0 ? `{ ${globs.join(', ')} }` : 'undefined'};\n`
230
+ });
231
+ nuxt.options.alias['#vunset/icons'] = template.dst;
232
+ const url = sprite ?? '/sprite.svg';
233
+ if (spriteDirs.length > 0)
234
+ addSprite(spriteDirs, url);
235
+ // The rest reaches the plugin through runtime config; an app's own value wins.
236
+ // A value, not `undefined`, which Nuxt would serve as `''` and no variable could set.
237
+ const runtime = nuxt.options.runtimeConfig.public;
238
+ runtime.vunset = defu(runtime.vunset, { icons: { sprite: url } });
239
+ addPlugin(resolver.resolve('./runtime/icons'));
240
+ }
241
+ // Built into the build folder and served from there, as a public asset: in
242
+ // development, and copied to whatever output the deploy preset makes.
243
+ function addSprite(sources, url) {
244
+ if (!url.startsWith('/')) {
245
+ logger.warn(`\`spriteDir\` builds a sprite the app serves, but \`sprite\` is \`${url}\`: give it a path.`);
246
+ return;
247
+ }
248
+ const dir = join(nuxt.options.buildDir, 'vunset-sprite');
249
+ const write = () => {
250
+ const sprite = buildSprite(readIcons(sources));
251
+ for (const name of sprite.invalid)
252
+ logger.warn(`The icon \`${name}.svg\` has no <svg> in it.`);
253
+ const file = join(dir, url);
254
+ mkdirSync(dirname(file), { recursive: true });
255
+ writeFileSync(file, sprite.svg);
256
+ };
257
+ nuxt.hook('nitro:config', (config) => {
258
+ const nitro = config;
259
+ (nitro.publicAssets ??= []).push({ dir, maxAge: 0 });
260
+ });
261
+ // Not sooner: the CLI empties the build folder once Nitro is set up.
262
+ nuxt.hook('build:before', write);
263
+ nuxt.hook('builder:watch', (_event, path) => {
264
+ const file = resolve(nuxt.options.srcDir, path);
265
+ if (file.endsWith('.svg') && sources.some((source) => file.startsWith(source + sep)))
266
+ write();
267
+ });
268
+ }
269
+ }
270
+ });
@@ -0,0 +1,30 @@
1
+ import { type ComponentPublicInstance, type MaybeRefOrGetter, type Ref } from 'vue';
2
+ import { type AsyncDataRequestStatus } from '#app';
3
+ export interface UseInfinityFetchOptions {
4
+ /**
5
+ * The list: an element or a component (its root element), a template ref or a getter. The next page loads
6
+ * once its end is in view: its bottom within the window, and, when it scrolls itself (a table capped to a
7
+ * height, a panel), scrolled to its own end.
8
+ */
9
+ el: MaybeRefOrGetter<Element | ComponentPublicInstance | null | undefined>;
10
+ /** How many pixels before the end the next page starts loading. @default 0 */
11
+ offset?: number;
12
+ /** Where the page's items are in the response, when it isn't the list itself. */
13
+ dataKey?: string;
14
+ /** `POST` sends the query, with `page`, as the body. @default 'GET' */
15
+ method?: 'GET' | 'POST';
16
+ /** Sent with every page; a change starts over from the first. */
17
+ query?: MaybeRefOrGetter<Record<string, unknown> | undefined>;
18
+ }
19
+ export interface UseInfinityFetchReturn<T> {
20
+ /** Every page loaded so far, in order. */
21
+ data: Ref<T[]>;
22
+ /** `end` once a page comes back empty. */
23
+ status: Ref<AsyncDataRequestStatus | 'end'>;
24
+ /** Starts over from the first page. */
25
+ refresh: () => void;
26
+ /** The next page to load. */
27
+ currentPage: Ref<number>;
28
+ }
29
+ /** A list fetched a page at a time, the next one as its end scrolls into view. */
30
+ export declare function useInfinityFetch<T>(url: string, options: UseInfinityFetchOptions): UseInfinityFetchReturn<T>;
@@ -0,0 +1,61 @@
1
+ import { computed, onMounted, onScopeDispose, ref, toValue, watch } from 'vue';
2
+ import { useRequestFetch } from '#app';
3
+ /** A list fetched a page at a time, the next one as its end scrolls into view. */
4
+ export function useInfinityFetch(url, options) {
5
+ // Untyped: the route is a runtime string, so Nuxt's typed routes can't check it.
6
+ const request = useRequestFetch();
7
+ const data = ref([]);
8
+ const page = ref(1);
9
+ const status = ref('idle');
10
+ const query = computed(() => toValue(options.query));
11
+ async function fetchPage() {
12
+ if (status.value === 'pending' || status.value === 'end')
13
+ return;
14
+ status.value = 'pending';
15
+ const params = { ...query.value, page: page.value };
16
+ const method = options.method ?? 'GET';
17
+ const response = await request(url, method === 'POST' ? { method, body: params } : { method, query: params });
18
+ const items = (options.dataKey ? response[options.dataKey] : response);
19
+ if (!items || items.length === 0) {
20
+ status.value = 'end';
21
+ return;
22
+ }
23
+ data.value = [...data.value, ...items];
24
+ page.value += 1;
25
+ status.value = 'idle';
26
+ }
27
+ function refresh() {
28
+ page.value = 1;
29
+ data.value = [];
30
+ status.value = 'idle';
31
+ void fetchPage();
32
+ }
33
+ watch(query, refresh, { deep: true });
34
+ onMounted(() => {
35
+ void fetchPage();
36
+ const check = () => {
37
+ const value = toValue(options.el);
38
+ const target = value instanceof Element ? value : value?.$el;
39
+ if (!(target instanceof Element))
40
+ return;
41
+ const offset = options.offset ?? 0;
42
+ // A list that doesn't scroll itself has nothing left to scroll, so only the window counts; a box capped
43
+ // to a height is always in the window, so only its own scroll counts.
44
+ const inWindow = target.getBoundingClientRect().bottom <= window.innerHeight + offset;
45
+ const scrolledToEnd = target.scrollHeight - target.scrollTop - target.clientHeight <= offset;
46
+ if (inWindow && scrolledToEnd)
47
+ void fetchPage();
48
+ };
49
+ // Captured, so a scroll inside any scrolling ancestor counts too.
50
+ window.addEventListener('scroll', check, { capture: true, passive: true });
51
+ window.addEventListener('resize', check, { passive: true });
52
+ // A page that leaves the end still in view loads the next at once. After the render, so the
53
+ // page's items are measured, not the list as it was before them.
54
+ watch(status, (value) => value === 'idle' && check(), { flush: 'post' });
55
+ onScopeDispose(() => {
56
+ window.removeEventListener('scroll', check, { capture: true });
57
+ window.removeEventListener('resize', check);
58
+ });
59
+ });
60
+ return { data, status, refresh, currentPage: page };
61
+ }
@@ -0,0 +1,2 @@
1
+ declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
2
+ export default _default;
@@ -0,0 +1,11 @@
1
+ import { defineNuxtPlugin, useRuntimeConfig } from '#app';
2
+ import { configureIcons } from '@vunset/vue';
3
+ // The icon folders, globbed: written by the module, since a glob takes no variable.
4
+ import { inline } from '#vunset/icons';
5
+ export default defineNuxtPlugin({
6
+ name: 'vunset:icons',
7
+ setup() {
8
+ const { sprite } = useRuntimeConfig().public.vunset.icons;
9
+ configureIcons({ sprite: sprite || undefined, inline });
10
+ }
11
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
2
+ export default _default;
@@ -0,0 +1,10 @@
1
+ import { defineNuxtPlugin } from '#app';
2
+ import { NuxtLink } from '#components';
3
+ import { configureLink } from '@vunset/vue';
4
+ // Every `to` the library turns into a link goes through NuxtLink, unless a component is told otherwise.
5
+ export default defineNuxtPlugin({
6
+ name: 'vunset:link',
7
+ setup() {
8
+ configureLink(NuxtLink);
9
+ }
10
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
2
+ export default _default;
@@ -0,0 +1,18 @@
1
+ import { defineNuxtPlugin, navigateTo, useRouter } from '#app';
2
+ import { routeQueryKey } from '@vunset/vue';
3
+ // The route a data table keeps its sort in, and a pagination its page, through Nuxt's router.
4
+ export default defineNuxtPlugin({
5
+ name: 'vunset:route-query',
6
+ setup(nuxtApp) {
7
+ const router = useRouter();
8
+ nuxtApp.vueApp.provide(routeQueryKey, {
9
+ get(key) {
10
+ const value = router.currentRoute.value.query[key];
11
+ return typeof value === 'string' ? value : undefined;
12
+ },
13
+ // `undefined` leaves the key out of the URL.
14
+ set: (key, value) => navigateTo({ query: { ...router.currentRoute.value.query, [key]: value } }),
15
+ setMany: (entries) => navigateTo({ query: { ...router.currentRoute.value.query, ...entries } })
16
+ });
17
+ }
18
+ });
@@ -0,0 +1,21 @@
1
+ import { type ThemeMode } from '@vunset/vue';
2
+ declare const _default: import("#app").Plugin<{
3
+ theme: {
4
+ current: "light" | "dark";
5
+ toggleTheme: () => void;
6
+ theme: ThemeMode;
7
+ resolved: "light" | "dark";
8
+ setTheme: (theme: ThemeMode) => void;
9
+ toggle: () => void;
10
+ };
11
+ }> & import("#app").ObjectPlugin<{
12
+ theme: {
13
+ current: "light" | "dark";
14
+ toggleTheme: () => void;
15
+ theme: ThemeMode;
16
+ resolved: "light" | "dark";
17
+ setTheme: (theme: ThemeMode) => void;
18
+ toggle: () => void;
19
+ };
20
+ }>;
21
+ export default _default;
@@ -0,0 +1,43 @@
1
+ import { defineNuxtPlugin, updateAppConfig, useAppConfig, useRuntimeConfig } from '#app';
2
+ import { reactive, watch } from 'vue';
3
+ import { useLocalStorage, useTheme } from '@vunset/vue';
4
+ const MODES = ['light', 'dark', 'system'];
5
+ // Before any component asks for the theme, so these options are the ones it starts with.
6
+ export default defineNuxtPlugin({
7
+ name: 'vunset:theme',
8
+ setup() {
9
+ const options = useRuntimeConfig().public.vunset?.theme ?? {};
10
+ const config = useAppConfig();
11
+ const key = config.settings?.key;
12
+ // The whole of `settings` is remembered, not only the theme: sidebar, bookmarks, ...
13
+ if (import.meta.client && key) {
14
+ const { getItem, setItem } = useLocalStorage();
15
+ const saved = getItem(key);
16
+ if (saved)
17
+ updateAppConfig({ settings: saved });
18
+ watch(() => config.settings, (settings) => setItem(key, settings), { deep: true });
19
+ }
20
+ const theme = useTheme({
21
+ defaultTheme: options.default,
22
+ themeColor: config.settings?.themeColor ?? options.themeColor,
23
+ viewTransition: options.viewTransition ?? true,
24
+ ...(key
25
+ ? { storage: { get: () => config.settings.theme, set: (value) => (config.settings.theme = value) } }
26
+ : { storageKey: options.storageKey })
27
+ });
28
+ // `settings.theme` set by the app itself, or by `updateAppConfig`, is followed too.
29
+ if (key) {
30
+ watch(() => config.settings?.theme, (value) => {
31
+ if (MODES.includes(value) && value !== theme.theme.value)
32
+ theme.setTheme(value);
33
+ });
34
+ }
35
+ return {
36
+ provide: {
37
+ // Reactive, so a template reads `$theme.current` without `.value`. `current` and
38
+ // `toggleTheme` are the old layer's names for `resolved` and `toggle`.
39
+ theme: reactive({ ...theme, current: theme.resolved, toggleTheme: theme.toggle })
40
+ }
41
+ };
42
+ }
43
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
2
+ export default _default;
@@ -0,0 +1,40 @@
1
+ import { defineNuxtPlugin, useState } from '#app';
2
+ import { createToastStore, defaultToastStore, provideToastStore } from '@vunset/vue';
3
+ // A server render gets a store of its own, so a toast set while rendering one
4
+ // request reaches no other. What it holds is handed to the page in the payload,
5
+ // and put in the shared store there: the one `useToast()` reaches from anywhere,
6
+ // a plain module included.
7
+ export default defineNuxtPlugin({
8
+ name: 'vunset:toasts',
9
+ setup(nuxtApp) {
10
+ const carried = useState('vunset:toasts', () => []);
11
+ if (import.meta.server) {
12
+ const store = provideToastStore(createToastStore(), nuxtApp.vueApp);
13
+ nuxtApp.hook('app:rendered', () => {
14
+ carried.value = store.toasts.value.filter((toast) => toast.transitionStatus !== 'leave').map(plain);
15
+ });
16
+ return;
17
+ }
18
+ // Before the app mounts, so the page hydrates with the toasts the server drew.
19
+ for (const toast of carried.value.slice().reverse())
20
+ defaultToastStore.add(toast);
21
+ carried.value = [];
22
+ }
23
+ });
24
+ /** What survives the payload: functions don't, so `onClose`, `slot` and a `data` holding one stay behind. */
25
+ function plain({ id, title, text, type, manualClose, timeout, data, onClose, slot }) {
26
+ const toast = { id, title, text, type, manualClose, timeout };
27
+ let dropped = Boolean(onClose || slot);
28
+ if (data !== undefined) {
29
+ try {
30
+ toast.data = structuredClone(data);
31
+ }
32
+ catch {
33
+ dropped = true;
34
+ }
35
+ }
36
+ if (dropped && import.meta.dev) {
37
+ console.warn(`[vunset] The toast \`${title ?? id}\` set during a server render reaches the page without its functions (\`onClose\`, \`slot\`, or one in \`data\`).`);
38
+ }
39
+ return toast;
40
+ }
@@ -0,0 +1,19 @@
1
+ export interface SpriteIcon {
2
+ name: string;
3
+ svg: string;
4
+ }
5
+ export interface Sprite {
6
+ svg: string;
7
+ /** The names drawn, in order. */
8
+ names: string[];
9
+ /** Files with no `<svg>` in them. */
10
+ invalid: string[];
11
+ }
12
+ /** The `.svg` files of each folder, in order; a name an earlier folder has is left out. */
13
+ export declare function readIcons(folders: string[]): SpriteIcon[];
14
+ /**
15
+ * One `<symbol>` per icon, parsed as an inline icon is: fills become
16
+ * `currentColor`, and the root's own attributes (`viewBox`, a stroke) move to
17
+ * the symbol, so a stroke icon keeps its stroke.
18
+ */
19
+ export declare function buildSprite(icons: SpriteIcon[]): Sprite;
package/dist/sprite.js ADDED
@@ -0,0 +1,47 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { parseInlineSvg } from '@vunset/utils';
4
+ /** The `.svg` files of each folder, in order; a name an earlier folder has is left out. */
5
+ export function readIcons(folders) {
6
+ const icons = new Map();
7
+ for (const folder of folders) {
8
+ if (!existsSync(folder))
9
+ continue;
10
+ for (const file of readdirSync(folder).filter((file) => file.endsWith('.svg')).sort()) {
11
+ const name = basename(file, '.svg');
12
+ if (!icons.has(name))
13
+ icons.set(name, readFileSync(join(folder, file), 'utf8'));
14
+ }
15
+ }
16
+ return [...icons].map(([name, svg]) => ({ name, svg }));
17
+ }
18
+ /**
19
+ * One `<symbol>` per icon, parsed as an inline icon is: fills become
20
+ * `currentColor`, and the root's own attributes (`viewBox`, a stroke) move to
21
+ * the symbol, so a stroke icon keeps its stroke.
22
+ */
23
+ export function buildSprite(icons) {
24
+ const names = [];
25
+ const invalid = [];
26
+ const symbols = [];
27
+ for (const { name, svg } of icons) {
28
+ const parsed = parseInlineSvg(svg);
29
+ if (!parsed) {
30
+ invalid.push(name);
31
+ continue;
32
+ }
33
+ // `Icon` draws a sprite icon with `fill="none"`, which a shape with no fill
34
+ // of its own would inherit through `<use>`: such a file is filled, as it is alone.
35
+ const attrs = { fill: 'currentColor', ...parsed.attrs };
36
+ const attributes = Object.entries(attrs)
37
+ .map(([key, value]) => ` ${key}="${value}"`)
38
+ .join('');
39
+ symbols.push(`<symbol id="${name}"${attributes}>${parsed.content.trim()}</symbol>`);
40
+ names.push(name);
41
+ }
42
+ return {
43
+ svg: `<svg xmlns="http://www.w3.org/2000/svg" style="display: none">\n${symbols.join('\n')}\n</svg>\n`,
44
+ names,
45
+ invalid
46
+ };
47
+ }
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "@vunset/nuxt",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0-rc.2",
4
+ "description": "Nuxt module for Vunset: every component, composable and helper auto-imported, styles loaded.",
5
+ "keywords": [
6
+ "nuxt",
7
+ "nuxt-module",
8
+ "vue",
9
+ "headless",
10
+ "components",
11
+ "ui",
12
+ "accessibility"
13
+ ],
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/vunset/vunset.git",
18
+ "directory": "packages/nuxt"
19
+ },
20
+ "homepage": "https://vunset.com/nuxt/get-started",
21
+ "bugs": "https://github.com/vunset/vunset/issues",
22
+ "type": "module",
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/module.d.ts",
29
+ "import": "./dist/module.js"
30
+ }
31
+ },
32
+ "scripts": {
33
+ "build": "node scripts/components.ts && tsc -p tsconfig.build.json",
34
+ "prepublishOnly": "npm run build"
35
+ },
36
+ "dependencies": {
37
+ "@vunset/ui": "1.0.0-rc.2",
38
+ "@vunset/utils": "1.0.0-rc.2",
39
+ "@vunset/vue": "1.0.0-rc.2",
40
+ "@nuxt/kit": "^4.6.1",
41
+ "defu": "^6.1.7"
42
+ },
43
+ "peerDependencies": {
44
+ "@tanstack/charts": "^1.1.0",
45
+ "@tanstack/vue-table": "^9.2.4"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "@tanstack/charts": {
49
+ "optional": true
50
+ },
51
+ "@tanstack/vue-table": {
52
+ "optional": true
53
+ }
54
+ },
55
+ "devDependencies": {
56
+ "@nuxt/schema": "^4.6.1",
57
+ "nuxt": "^4.6.1"
58
+ },
59
+ "publishConfig": {
60
+ "access": "public"
61
+ }
62
+ }