@frigear-nu/menu 0.0.0-stage → 0.0.3
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/README.md +145 -2
- package/dist/module.d.mts +100 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +66 -0
- package/dist/runtime/app/composables/useMenu.d.ts +22 -0
- package/dist/runtime/app/composables/useMenu.js +40 -0
- package/dist/runtime/app/plugins/menus.d.ts +9 -0
- package/dist/runtime/app/plugins/menus.js +19 -0
- package/dist/runtime/app/utils/extend.d.ts +8 -0
- package/dist/runtime/app/utils/extend.js +9 -0
- package/dist/runtime/app/utils/state.d.ts +14 -0
- package/dist/runtime/app/utils/state.js +12 -0
- package/dist/runtime/server/utils.d.ts +3 -0
- package/dist/runtime/server/utils.js +27 -0
- package/dist/runtime/utils/index.d.ts +5 -0
- package/dist/runtime/utils/index.js +3 -0
- package/dist/runtime/utils/menu.d.ts +85 -0
- package/dist/runtime/utils/menu.js +83 -0
- package/dist/types.d.mts +3 -0
- package/package.json +67 -3
package/README.md
CHANGED
|
@@ -1,3 +1,146 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @frigear-nu/menu
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Named, shareable navigation menus. A **menu** (`main`, `dashboard`, `account`, ...) is a named
|
|
4
|
+
collection of items, filled by one or more **sources** - Nuxt Content, other modules (at build
|
|
5
|
+
time, or dynamically at runtime), or both at once (content as the base, modules appending). This
|
|
6
|
+
package is presentation-free: it only exposes data (`useMenu`) - `description`/`icon` are optional
|
|
7
|
+
fields a consumer may render however it likes, or ignore.
|
|
8
|
+
|
|
9
|
+
## Quick Setup
|
|
10
|
+
|
|
11
|
+
Install the module to your Nuxt application with one command:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx nuxt module add @frigear-nu/menu
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Vocabulary
|
|
18
|
+
|
|
19
|
+
- **Menu** - a named collection of items (`main`, `dashboard`, `account`, ...).
|
|
20
|
+
- **Group** - the part after a menu's first dot. `dashboard.more` addresses group `more` of menu
|
|
21
|
+
`dashboard`.
|
|
22
|
+
- **Source** - anything that fills a menu: Nuxt Content, `extendMenu`, or a module's
|
|
23
|
+
`runtime/menus.ts` file.
|
|
24
|
+
|
|
25
|
+
## Contributing items from another module
|
|
26
|
+
|
|
27
|
+
### Static items, known at build time
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { createResolver, defineNuxtModule } from '@nuxt/kit'
|
|
31
|
+
import { extendMenu } from '@frigear-nu/menu/utils'
|
|
32
|
+
|
|
33
|
+
export default defineNuxtModule({
|
|
34
|
+
meta: { name: '@frigear-nu/example' },
|
|
35
|
+
setup(options, nuxt) {
|
|
36
|
+
// `'dashboard.more'` is parsed as menu `dashboard`, group `more` (split on the first dot only).
|
|
37
|
+
extendMenu(nuxt, 'dashboard.more', [
|
|
38
|
+
{ label: 'Billing', description: 'Manage your subscription', to: '/billing' },
|
|
39
|
+
])
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Dynamic items, needing runtime context (e.g. `useRuntimeConfig`)
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { createResolver, defineNuxtModule } from '@nuxt/kit'
|
|
48
|
+
import { registerMenuSource } from '@frigear-nu/menu/utils'
|
|
49
|
+
|
|
50
|
+
export default defineNuxtModule({
|
|
51
|
+
meta: { name: '@frigear-nu/example' },
|
|
52
|
+
setup(options, nuxt) {
|
|
53
|
+
const resolver = createResolver(import.meta.url)
|
|
54
|
+
|
|
55
|
+
registerMenuSource(nuxt, {
|
|
56
|
+
file: resolver.resolve('./runtime/menus'),
|
|
57
|
+
menu: 'dashboard',
|
|
58
|
+
})
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// runtime/menus.ts
|
|
65
|
+
import { defineMenuSource, extendMenuItem } from '@frigear-nu/menu/utils'
|
|
66
|
+
|
|
67
|
+
export default defineMenuSource((items) => {
|
|
68
|
+
const config = useRuntimeConfig().public.example
|
|
69
|
+
|
|
70
|
+
// Pushed into the `dashboard` menu's root group.
|
|
71
|
+
if (config?.enabled) items.push({ label: 'Example', to: '/example' })
|
|
72
|
+
|
|
73
|
+
// Pushed into a named group (in this or another menu) instead - created automatically if it
|
|
74
|
+
// doesn't already exist. `extendMenuItem` pushes into shared app state, so it only works from
|
|
75
|
+
// code that actually runs as part of the Nuxt app (like this file) - not from a module's own
|
|
76
|
+
// build-time `setup()` (use `extendMenu` there instead).
|
|
77
|
+
extendMenuItem('dashboard.more', { label: 'Security', to: '/security' })
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Registration order between modules doesn't matter for either approach - items are only merged
|
|
82
|
+
once every module has finished setting up.
|
|
83
|
+
|
|
84
|
+
## Nuxt Content source
|
|
85
|
+
|
|
86
|
+
Enable the `@nuxt/content`-backed source for a menu (requires `@nuxt/content` to be installed):
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
export default defineNuxtConfig({
|
|
90
|
+
menus: {
|
|
91
|
+
content: {
|
|
92
|
+
// Reads `config.navigation` (the collection/field defined by `@frigear-nu/base`).
|
|
93
|
+
main: true,
|
|
94
|
+
// ...or a custom collection/field:
|
|
95
|
+
dashboard: { collection: 'dashboard', field: 'tiles' },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Content items are localized automatically when `@nuxtjs/i18n` is configured with `locales`,
|
|
102
|
+
following the same `config` -> `config_en` collection-suffix convention as `@frigear-nu/base`.
|
|
103
|
+
|
|
104
|
+
## Reading a menu
|
|
105
|
+
|
|
106
|
+
`useMenu` is auto-imported. It returns the same shape as `useAsyncData`
|
|
107
|
+
(`{ data, pending, error, refresh }`) - rendering is entirely up to the consumer:
|
|
108
|
+
|
|
109
|
+
```vue
|
|
110
|
+
<script setup lang="ts">
|
|
111
|
+
const { data: items } = useMenu('main')
|
|
112
|
+
const { data: more } = useMenu('dashboard.more')
|
|
113
|
+
</script>
|
|
114
|
+
|
|
115
|
+
<template>
|
|
116
|
+
<nav>
|
|
117
|
+
<NuxtLink
|
|
118
|
+
v-for="item in items"
|
|
119
|
+
:key="item.id"
|
|
120
|
+
:to="item.to"
|
|
121
|
+
>
|
|
122
|
+
{{ item.label }}
|
|
123
|
+
</NuxtLink>
|
|
124
|
+
</nav>
|
|
125
|
+
</template>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Items are sorted by `order` (ascending; items without an `order` render last, in registration
|
|
129
|
+
order).
|
|
130
|
+
|
|
131
|
+
### Wildcard reads
|
|
132
|
+
|
|
133
|
+
A path ending in `.*` reads every matching group, flattened into one list, instead of a single
|
|
134
|
+
exact group - useful for rendering a whole menu "tree" at once:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
// Every group of the `dashboard` menu (root group included).
|
|
138
|
+
const { data: all } = useMenu('dashboard.*')
|
|
139
|
+
|
|
140
|
+
// `account` and every group nested under it (e.g. `dashboard.account.sub`), but not sibling
|
|
141
|
+
// groups like `dashboard.more`.
|
|
142
|
+
const { data: accountTree } = useMenu('dashboard.account.*')
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Wildcards are only supported when reading a menu - `extendMenu`/`extendMenuItem` reject a
|
|
146
|
+
wildcard path, since items can only ever be registered into one exact group.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as _nuxt_schema from '@nuxt/schema';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A single menu entry. Deliberately framework-agnostic (no Vue/Nuxt types) and presentation-free -
|
|
5
|
+
* `description`/`icon` are optional fields a view may use or ignore, but the data never encodes
|
|
6
|
+
* how it's rendered (no colors, no markup).
|
|
7
|
+
*/
|
|
8
|
+
type MenuItem = {
|
|
9
|
+
/**
|
|
10
|
+
* Stable identifier, useful for `:key` bindings or for overriding/removing an item later.
|
|
11
|
+
* When omitted it's derived deterministically from `to` (if present) or a slug of `label`.
|
|
12
|
+
*/
|
|
13
|
+
id?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Matches the `label` field of `@frigear-nu/base`'s `config` content collection `navigation`
|
|
16
|
+
* entries, so content-sourced and module-sourced items share the exact same shape.
|
|
17
|
+
*/
|
|
18
|
+
label: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
icon?: string;
|
|
21
|
+
to?: string;
|
|
22
|
+
external?: boolean;
|
|
23
|
+
tags?: string[];
|
|
24
|
+
/** Lower numbers render first. Items without an `order` are rendered last, in registration order. */
|
|
25
|
+
order?: number;
|
|
26
|
+
/**
|
|
27
|
+
* The group (the part after the menu's first dot) this item belongs to. Set automatically from
|
|
28
|
+
* the path passed to `extendMenu`/`extendMenuItem`, but an explicit value here always wins.
|
|
29
|
+
*/
|
|
30
|
+
group?: string;
|
|
31
|
+
meta?: Record<string, unknown>;
|
|
32
|
+
};
|
|
33
|
+
/** A named collection of `MenuItem`s, keyed by group (`''` for the root group). */
|
|
34
|
+
type Menu = Record<string, MenuItem[]>;
|
|
35
|
+
/**
|
|
36
|
+
* A module's `runtime/menus.ts` file exports a default function matching this signature, wrapped
|
|
37
|
+
* in `defineMenuSource` for typing/DX. It receives the registered menu's root group items array
|
|
38
|
+
* directly, so pushing an item there is as simple as `items.push({ ... })`. Use the auto-imported
|
|
39
|
+
* `extendMenuItem(path, item)` to push into a named group (in this or another menu) instead.
|
|
40
|
+
*/
|
|
41
|
+
type MenuSource = (items: MenuItem[]) => void | Promise<void>;
|
|
42
|
+
type MenuContentSourceOptions = {
|
|
43
|
+
/** Content collection to read the menu's base items from. Defaults to `'config'`. */
|
|
44
|
+
collection?: string;
|
|
45
|
+
/** Field on the collection holding the `MenuItem[]`. Defaults to `'navigation'`. */
|
|
46
|
+
field?: string;
|
|
47
|
+
};
|
|
48
|
+
type ModuleOptions = {
|
|
49
|
+
/**
|
|
50
|
+
* Enables the `@nuxt/content`-backed source for one or more menus. Maps a menu name to `true`
|
|
51
|
+
* (use the default `config` collection's `navigation` field) or a `{ collection, field }` pair
|
|
52
|
+
* to read from a different collection/field. Requires `@nuxt/content` to be installed - a
|
|
53
|
+
* clear build-time error is thrown otherwise.
|
|
54
|
+
*/
|
|
55
|
+
content?: Record<string, boolean | MenuContentSourceOptions>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type ResolvedMenuContentSource = { collection: string, field: string }
|
|
59
|
+
|
|
60
|
+
declare module '@nuxt/schema' {
|
|
61
|
+
interface NuxtConfig {
|
|
62
|
+
menus?: ModuleOptions
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface NuxtOptions {
|
|
66
|
+
menus?: ModuleOptions & {
|
|
67
|
+
_extensions?: Record<string, Record<string, MenuItem[]>>
|
|
68
|
+
_sources?: Array<{ menu: string, file: string }>
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface PublicRuntimeConfig {
|
|
73
|
+
menus?: {
|
|
74
|
+
content?: Record<string, ResolvedMenuContentSource>
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface NuxtHooks {
|
|
79
|
+
/** Called every time `extendMenu` registers items into a menu. */
|
|
80
|
+
'menus:extend': (menu: string, items: MenuItem[]) => void
|
|
81
|
+
/**
|
|
82
|
+
* Called once, after every module has finished setting up, with every menu's final
|
|
83
|
+
* statically-registered items (menu name -> group -> items). Dynamic `runtime/menus.ts`
|
|
84
|
+
* sources and content-sourced items aren't included, since those only resolve at render time.
|
|
85
|
+
*/
|
|
86
|
+
'menus:resolved': (menus: Record<string, Record<string, MenuItem[]>>) => void
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
declare module '#app' {
|
|
91
|
+
interface RuntimeNuxtHooks {
|
|
92
|
+
/** Called whenever `useMenu` resolves a menu's items, right before they're handed to a view. */
|
|
93
|
+
'menus:visible': (menu: string) => void
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
|
|
98
|
+
|
|
99
|
+
export { _default as default };
|
|
100
|
+
export type { Menu, MenuContentSourceOptions, MenuItem, MenuSource, ModuleOptions, ResolvedMenuContentSource };
|
package/dist/module.json
ADDED
package/dist/module.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { defineNuxtModule, createResolver, addImports, hasNuxtModule, addTypeTemplate, addTemplate, addPlugin, addServerImports } from '@nuxt/kit';
|
|
2
|
+
import { registerModule } from '@frigear-nu/module-kit/utils';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_CONTENT_COLLECTION = "config";
|
|
5
|
+
const DEFAULT_CONTENT_FIELD = "navigation";
|
|
6
|
+
const module$1 = defineNuxtModule({
|
|
7
|
+
meta: { name: "@frigear-nu/menu", configKey: "menus" },
|
|
8
|
+
async setup(options, nuxt) {
|
|
9
|
+
registerModule("@frigear-nu/menu", nuxt);
|
|
10
|
+
const resolver = createResolver(import.meta.url);
|
|
11
|
+
addImports([
|
|
12
|
+
{ name: "useMenu", from: resolver.resolve("./runtime/app/composables/useMenu") },
|
|
13
|
+
{ name: "extendMenuItem", from: resolver.resolve("./runtime/app/utils/extend") }
|
|
14
|
+
]);
|
|
15
|
+
const contentEntries = Object.entries(options.content ?? {});
|
|
16
|
+
if (contentEntries.length > 0 && !hasNuxtModule("@nuxt/content")) {
|
|
17
|
+
throw new Error("[@frigear-nu/menu] `content` is configured for one or more menus, but `@nuxt/content` module is not installed. Please install @nuxt/content or remove the `content` option.");
|
|
18
|
+
}
|
|
19
|
+
nuxt.options.runtimeConfig.public.menus = {
|
|
20
|
+
content: Object.fromEntries(
|
|
21
|
+
contentEntries.map(([menu, source]) => {
|
|
22
|
+
const explicit = typeof source === "object" ? source : void 0;
|
|
23
|
+
return [
|
|
24
|
+
menu,
|
|
25
|
+
{
|
|
26
|
+
collection: explicit?.collection ?? DEFAULT_CONTENT_COLLECTION,
|
|
27
|
+
field: explicit?.field ?? DEFAULT_CONTENT_FIELD
|
|
28
|
+
}
|
|
29
|
+
];
|
|
30
|
+
})
|
|
31
|
+
)
|
|
32
|
+
};
|
|
33
|
+
addTypeTemplate({
|
|
34
|
+
filename: "types/menus-sources.d.ts",
|
|
35
|
+
getContents: () => `declare module '#menus/sources' {
|
|
36
|
+
import type { MenuItem, MenuSource } from ${JSON.stringify(resolver.resolve("./types"))}
|
|
37
|
+
export const staticItems: Record<string, Record<string, MenuItem[]>>
|
|
38
|
+
export const sources: Array<{ menu: string, source: MenuSource }>
|
|
39
|
+
}
|
|
40
|
+
`
|
|
41
|
+
});
|
|
42
|
+
nuxt.hook("modules:done", () => {
|
|
43
|
+
const staticItems = nuxt.options.menus?._extensions ?? {};
|
|
44
|
+
const sources = Array.isArray(nuxt.options.menus?._sources) ? nuxt.options.menus._sources : [];
|
|
45
|
+
const sourcesTemplate = addTemplate({
|
|
46
|
+
filename: "#menus/sources.mjs",
|
|
47
|
+
write: true,
|
|
48
|
+
getContents: () => `
|
|
49
|
+
export const staticItems = ${JSON.stringify(staticItems)}
|
|
50
|
+
${sources.map((source, index) => `import source${index} from ${JSON.stringify(source.file)}`).join("\n")}
|
|
51
|
+
export const sources = [${sources.map((source, index) => `{ menu: ${JSON.stringify(source.menu)}, source: source${index} }`).join(", ")}]
|
|
52
|
+
`
|
|
53
|
+
});
|
|
54
|
+
nuxt.options.alias["#menus/sources"] = sourcesTemplate.dst;
|
|
55
|
+
nuxt.callHook("menus:resolved", staticItems);
|
|
56
|
+
});
|
|
57
|
+
addPlugin(resolver.resolve("./runtime/app/plugins/menus"));
|
|
58
|
+
addServerImports({
|
|
59
|
+
name: "useMenu",
|
|
60
|
+
from: resolver.resolve("./runtime/server/utils"),
|
|
61
|
+
as: "useMenu"
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export { module$1 as default };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { MenuItem } from '../../../types.js';
|
|
2
|
+
import type { AsyncData, NuxtError } from 'nuxt/app';
|
|
3
|
+
/**
|
|
4
|
+
* Reads a menu's (or menu group's) items, merged from every configured source - items registered
|
|
5
|
+
* at build time through `extendMenu`, items pushed by a module's `runtime/menus.ts` source, and
|
|
6
|
+
* (when enabled) the `@nuxt/content`-backed source - sorted by `order` (ascending, undefined
|
|
7
|
+
* last, in registration order).
|
|
8
|
+
*
|
|
9
|
+
* Addressed as `menu` (root group) or `menu.group` (parsed on the first dot only, e.g.
|
|
10
|
+
* `'dashboard.more'` reads group `more` of menu `dashboard`). A path ending in `.*` reads every
|
|
11
|
+
* matching group instead, flattened into a single list: `'dashboard.*'` reads every group of
|
|
12
|
+
* `dashboard`, `'dashboard.account.*'` reads `account` and every group nested under it. Re-evaluates
|
|
13
|
+
* when the content locale changes, and returns the same shape as `useAsyncData` (`{ data, pending,
|
|
14
|
+
* error, refresh }`).
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* const { data: items } = useMenu('main')
|
|
18
|
+
* const { data: more } = useMenu('dashboard.more')
|
|
19
|
+
* const { data: all } = useMenu('dashboard.*')
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export declare const useMenu: (path: string) => AsyncData<MenuItem[], NuxtError<unknown> | undefined>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { computed, unref } from "vue";
|
|
2
|
+
import { useAsyncData, useNuxtApp, useRuntimeConfig } from "#imports";
|
|
3
|
+
import { collectMenuItems, matchesMenuGroup, parseMenuPath, sortByOrder } from "../../utils/menu.js";
|
|
4
|
+
import { menus } from "../utils/state.js";
|
|
5
|
+
function useMenuLocale() {
|
|
6
|
+
const nuxtApp = useNuxtApp();
|
|
7
|
+
return computed(() => nuxtApp.$i18n?.locale?.value);
|
|
8
|
+
}
|
|
9
|
+
function localizedCollectionName(name, locale) {
|
|
10
|
+
return locale ? `${name}_${locale.replace(/-/g, "_")}` : name;
|
|
11
|
+
}
|
|
12
|
+
async function loadContentItems(menu, parsed, locale) {
|
|
13
|
+
const config = useRuntimeConfig().public.menus ?? {};
|
|
14
|
+
const source = config.content?.[menu];
|
|
15
|
+
if (!source) return [];
|
|
16
|
+
const collection = localizedCollectionName(source.collection, locale);
|
|
17
|
+
const entry = await queryCollection(collection).first();
|
|
18
|
+
const items = entry?.[source.field] ?? [];
|
|
19
|
+
return items.filter((item) => {
|
|
20
|
+
const group = item.group ?? "";
|
|
21
|
+
return parsed.wildcard ? matchesMenuGroup(group, parsed.group) : group === parsed.group;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export const useMenu = (path) => {
|
|
25
|
+
const parsed = parseMenuPath(path);
|
|
26
|
+
const { menu } = parsed;
|
|
27
|
+
const locale = useMenuLocale();
|
|
28
|
+
const nuxtApp = useNuxtApp();
|
|
29
|
+
return useAsyncData(
|
|
30
|
+
() => `menu:${path}:${locale.value ?? ""}`,
|
|
31
|
+
async () => {
|
|
32
|
+
const registered = collectMenuItems(menus[menu], parsed);
|
|
33
|
+
const content = await loadContentItems(menu, parsed, unref(locale));
|
|
34
|
+
const result = sortByOrder([...content, ...registered]);
|
|
35
|
+
await nuxtApp.callHook("menus:visible", menu);
|
|
36
|
+
return result;
|
|
37
|
+
},
|
|
38
|
+
{ watch: [locale] }
|
|
39
|
+
);
|
|
40
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Populates the shared menu state consumed by `useMenu`: first with every item registered at
|
|
3
|
+
* build time (`extendMenu`), then by running every registered module's dynamic `runtime/menus.ts`
|
|
4
|
+
* source once per app instance (server: once per request, client: once on hydration). Resets the
|
|
5
|
+
* state first so repeated runs within the same server process (e.g. concurrent requests) don't
|
|
6
|
+
* accumulate duplicate items.
|
|
7
|
+
*/
|
|
8
|
+
declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
|
|
9
|
+
export default _default;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineNuxtPlugin } from "#imports";
|
|
2
|
+
import { staticItems, sources } from "#menus/sources";
|
|
3
|
+
import { pushMenuItems, resetMenus } from "../utils/state.js";
|
|
4
|
+
export default defineNuxtPlugin({
|
|
5
|
+
name: "frigear-nu:menus",
|
|
6
|
+
async setup() {
|
|
7
|
+
resetMenus();
|
|
8
|
+
for (const [menu, groups] of Object.entries(staticItems)) {
|
|
9
|
+
for (const [group, items] of Object.entries(groups)) {
|
|
10
|
+
if (items.length > 0) pushMenuItems(menu, group, items);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
for (const { menu, source } of sources) {
|
|
14
|
+
const items = [];
|
|
15
|
+
await source(items);
|
|
16
|
+
if (items.length > 0) pushMenuItems(menu, "", items);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { MenuItem } from '../../../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Pushes one (or more) item(s) into a menu's group, creating both if they don't exist yet. Paths
|
|
4
|
+
* are addressed as `menu` (root group) or `menu.group` (parsed on the first dot only - e.g.
|
|
5
|
+
* `'dashboard.more'` is menu `dashboard`, group `more`). Auto-imported, so it can be called
|
|
6
|
+
* directly from a module's `runtime/menus.ts` source (see `defineMenuSource`) or from app code.
|
|
7
|
+
*/
|
|
8
|
+
export declare function extendMenuItem(path: string, item: MenuItem | MenuItem[]): void;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { parseMenuPath } from "../../utils/menu.js";
|
|
2
|
+
import { pushMenuItems } from "./state.js";
|
|
3
|
+
export function extendMenuItem(path, item) {
|
|
4
|
+
const { menu, group, wildcard } = parseMenuPath(path);
|
|
5
|
+
if (wildcard) {
|
|
6
|
+
throw new Error(`[@frigear-nu/menu] Cannot register items into wildcard path "${path}" - wildcards are only supported when reading a menu via \`useMenu\`.`);
|
|
7
|
+
}
|
|
8
|
+
pushMenuItems(menu, group, item);
|
|
9
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { MenuItem } from '../../../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Shared, module-scoped reactive state holding every menu's items, keyed by menu name then by
|
|
4
|
+
* group (`''` for the root group). Populated client-side (and once per SSR request) by the
|
|
5
|
+
* `menus` plugin, which runs every registered dynamic source.
|
|
6
|
+
*/
|
|
7
|
+
export declare const menus: Record<string, Record<string, MenuItem[]>>;
|
|
8
|
+
export declare function pushMenuItems(menu: string, group: string, items: MenuItem | MenuItem[]): void;
|
|
9
|
+
/**
|
|
10
|
+
* Empties every registered menu/group's items in place, keeping the same reactive object/array
|
|
11
|
+
* instances around rather than deleting keys, so each source run starts from a clean slate
|
|
12
|
+
* without accumulating duplicates across repeated runs (e.g. concurrent server requests).
|
|
13
|
+
*/
|
|
14
|
+
export declare function resetMenus(): void;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { reactive } from "vue";
|
|
2
|
+
export const menus = reactive({});
|
|
3
|
+
export function pushMenuItems(menu, group, items) {
|
|
4
|
+
const groups = menus[menu] ??= {};
|
|
5
|
+
const list = groups[group] ??= [];
|
|
6
|
+
list.push(...Array.isArray(items) ? items : [items]);
|
|
7
|
+
}
|
|
8
|
+
export function resetMenus() {
|
|
9
|
+
for (const groups of Object.values(menus)) {
|
|
10
|
+
for (const list of Object.values(groups)) list.length = 0;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { collectMenuItems, matchesMenuGroup, parseMenuPath, sortByOrder } from "../utils/menu.js";
|
|
2
|
+
import { useRuntimeConfig } from "nitropack/runtime";
|
|
3
|
+
export const useMenu = async (event, path) => {
|
|
4
|
+
const parsed = parseMenuPath(path);
|
|
5
|
+
const { menu } = parsed;
|
|
6
|
+
const config = useRuntimeConfig(event);
|
|
7
|
+
const menus = config.public.menus;
|
|
8
|
+
const registered = collectMenuItems(menus[menu], parsed);
|
|
9
|
+
const content = await loadContentItems(menu, parsed, "");
|
|
10
|
+
const result = sortByOrder([...registered, ...content]);
|
|
11
|
+
return result;
|
|
12
|
+
};
|
|
13
|
+
function localizedCollectionName(name, locale) {
|
|
14
|
+
return locale ? `${name}_${locale.replace(/-/g, "_")}` : name;
|
|
15
|
+
}
|
|
16
|
+
async function loadContentItems(menu, parsed, locale) {
|
|
17
|
+
const config = useRuntimeConfig().public.menus ?? {};
|
|
18
|
+
const source = config.content?.[menu];
|
|
19
|
+
if (!source) return [];
|
|
20
|
+
const collection = localizedCollectionName(source.collection, locale);
|
|
21
|
+
const entry = await queryCollection(collection).first();
|
|
22
|
+
const items = entry?.[source.field] ?? [];
|
|
23
|
+
return items.filter((item) => {
|
|
24
|
+
const group = item.group ?? "";
|
|
25
|
+
return parsed.wildcard ? matchesMenuGroup(group, parsed.group) : group === parsed.group;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { MenuItem } from './menu.js';
|
|
2
|
+
export { extendMenu, defineMenuSource, registerMenuSource, parseMenuPath, deriveMenuItemId } from './menu.js';
|
|
3
|
+
export type { MenuItem, MenuSource, RegisterMenuSourceOptions, ParsedMenuPath } from './menu.js';
|
|
4
|
+
export { extendMenuItem } from '../app/utils/extend.js';
|
|
5
|
+
export declare const defineMenuItem: <T extends MenuItem>(item: T) => T;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Nuxt } from '@nuxt/schema';
|
|
2
|
+
import type { MenuItem, MenuSource } from '../../types.js';
|
|
3
|
+
export type { MenuItem, MenuSource } from '../../types.js';
|
|
4
|
+
export type ParsedMenuPath = {
|
|
5
|
+
menu: string;
|
|
6
|
+
group: string;
|
|
7
|
+
/**
|
|
8
|
+
* Whether `path` ended in a wildcard group (`'dashboard.*'` or `'dashboard.account.*'`) -
|
|
9
|
+
* matching that group and every group nested under it, rather than a single exact group.
|
|
10
|
+
*/
|
|
11
|
+
wildcard: boolean;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Splits a menu path on its *first* dot only, so `'a.b.c'` resolves to menu `'a'`, group `'b.c'`.
|
|
15
|
+
* The group becomes the default group for items registered through that path - an explicit
|
|
16
|
+
* `item.group` always wins.
|
|
17
|
+
*
|
|
18
|
+
* A path ending in `.*` is parsed as a wildcard instead: `'dashboard.*'` resolves to menu
|
|
19
|
+
* `dashboard`, every group (`wildcard: true`, `group: ''`); `'dashboard.account.*'` resolves to
|
|
20
|
+
* every group that is (or is nested under) `account`. Only `useMenu` understands wildcards -
|
|
21
|
+
* `extendMenu`/`extendMenuItem` reject them, since items can only ever be registered into one
|
|
22
|
+
* exact group.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseMenuPath(path: string): ParsedMenuPath;
|
|
25
|
+
/**
|
|
26
|
+
* Whether `group` is matched by a wildcard's `prefix` (the `group` resolved by `parseMenuPath`
|
|
27
|
+
* for a wildcard path) - the prefix itself, or anything nested under it. An empty `prefix`
|
|
28
|
+
* (`'dashboard.*'`) matches every group.
|
|
29
|
+
*/
|
|
30
|
+
export declare function matchesMenuGroup(group: string, prefix: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Resolves every item addressed by a parsed menu path out of that menu's groups - a single
|
|
33
|
+
* group's items for a plain path, or every matching group's items (flattened, unsorted) for a
|
|
34
|
+
* wildcard path.
|
|
35
|
+
*/
|
|
36
|
+
export declare function collectMenuItems(groups: Record<string, MenuItem[]> | undefined, parsed: ParsedMenuPath): MenuItem[];
|
|
37
|
+
export declare function sortByOrder(items: MenuItem[]): MenuItem[];
|
|
38
|
+
/** Deterministic id for an item missing one: its `to` if present, else a slug of its `label`. */
|
|
39
|
+
export declare function deriveMenuItemId(item: MenuItem): string;
|
|
40
|
+
/**
|
|
41
|
+
* Build-time API for other modules to contribute static items to a menu, without the host app
|
|
42
|
+
* wiring anything. Call it once during a module's own `setup()`:
|
|
43
|
+
*
|
|
44
|
+
* ```ts
|
|
45
|
+
* import { extendMenu } from '@frigear-nu/menu/utils'
|
|
46
|
+
*
|
|
47
|
+
* export default defineNuxtModule({
|
|
48
|
+
* setup(options, nuxt) {
|
|
49
|
+
* extendMenu(nuxt, 'dashboard.more', [
|
|
50
|
+
* { label: 'Billing', description: 'Manage your subscription' },
|
|
51
|
+
* ])
|
|
52
|
+
* },
|
|
53
|
+
* })
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* `'dashboard.more'` is parsed as menu `dashboard`, group `more` (split on the *first* dot only).
|
|
57
|
+
*/
|
|
58
|
+
export declare function extendMenu(nuxt: Nuxt, path: string, items: MenuItem | MenuItem[]): void;
|
|
59
|
+
/**
|
|
60
|
+
* A typed identity helper for authoring a module's `runtime/menus.ts` source file. It exists
|
|
61
|
+
* purely for type inference/DX - it returns the function unchanged.
|
|
62
|
+
*
|
|
63
|
+
* ```ts
|
|
64
|
+
* // runtime/menus.ts
|
|
65
|
+
* export default defineMenuSource((items) => {
|
|
66
|
+
* const config = useRuntimeConfig().public.example
|
|
67
|
+
* if (config?.enabled) items.push({ label: 'Example', to: '/example' })
|
|
68
|
+
* })
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export declare const defineMenuSource: (source: MenuSource) => MenuSource;
|
|
72
|
+
export type RegisterMenuSourceOptions = {
|
|
73
|
+
/** Resolved path to the module's `runtime/menus.ts` source file. */
|
|
74
|
+
file: string;
|
|
75
|
+
/** Menu this source's root group items are pushed into. */
|
|
76
|
+
menu: string;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Registers a module's menu source so `@frigear-nu/menu` runs it alongside every other
|
|
80
|
+
* registered source, merging the result into the named menu. Modules call this once during their
|
|
81
|
+
* own `setup()` - registration order doesn't matter, since sources are only resolved once every
|
|
82
|
+
* module has finished setting up. `options.file` must already be a resolved path (for example via
|
|
83
|
+
* `createResolver(import.meta.url).resolve(...)`).
|
|
84
|
+
*/
|
|
85
|
+
export declare const registerMenuSource: (nuxt: Nuxt, options: RegisterMenuSourceOptions) => void;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export function parseMenuPath(path) {
|
|
2
|
+
const dotIndex = path.indexOf(".");
|
|
3
|
+
if (dotIndex === -1) return { menu: path, group: "", wildcard: false };
|
|
4
|
+
const menu = path.slice(0, dotIndex);
|
|
5
|
+
const rest = path.slice(dotIndex + 1);
|
|
6
|
+
if (rest === "*") return { menu, group: "", wildcard: true };
|
|
7
|
+
if (rest.endsWith(".*")) return { menu, group: rest.slice(0, -2), wildcard: true };
|
|
8
|
+
return { menu, group: rest, wildcard: false };
|
|
9
|
+
}
|
|
10
|
+
export function matchesMenuGroup(group, prefix) {
|
|
11
|
+
if (prefix === "") return true;
|
|
12
|
+
return group === prefix || group.startsWith(`${prefix}.`);
|
|
13
|
+
}
|
|
14
|
+
export function collectMenuItems(groups, parsed) {
|
|
15
|
+
if (!groups) return [];
|
|
16
|
+
if (!parsed.wildcard) return groups[parsed.group] ?? [];
|
|
17
|
+
const matched = [];
|
|
18
|
+
for (const [group, items] of Object.entries(groups)) {
|
|
19
|
+
if (matchesMenuGroup(group, parsed.group)) matched.push(...items);
|
|
20
|
+
}
|
|
21
|
+
return matched;
|
|
22
|
+
}
|
|
23
|
+
export function sortByOrder(items) {
|
|
24
|
+
return [...items].sort((a, b) => {
|
|
25
|
+
if (a.order === void 0 && b.order === void 0) return 0;
|
|
26
|
+
if (a.order === void 0) return 1;
|
|
27
|
+
if (b.order === void 0) return -1;
|
|
28
|
+
return a.order - b.order;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function slugify(value) {
|
|
32
|
+
let result = "";
|
|
33
|
+
let lastWasDash = true;
|
|
34
|
+
for (const char of value.toLowerCase()) {
|
|
35
|
+
const isAlphanumeric = char >= "a" && char <= "z" || char >= "0" && char <= "9";
|
|
36
|
+
if (isAlphanumeric) {
|
|
37
|
+
result += char;
|
|
38
|
+
lastWasDash = false;
|
|
39
|
+
} else if (!lastWasDash) {
|
|
40
|
+
result += "-";
|
|
41
|
+
lastWasDash = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return lastWasDash ? result.slice(0, -1) : result;
|
|
45
|
+
}
|
|
46
|
+
export function deriveMenuItemId(item) {
|
|
47
|
+
return item.id ?? item.to ?? slugify(item.label);
|
|
48
|
+
}
|
|
49
|
+
function normalizeItems(group, items) {
|
|
50
|
+
const list = Array.isArray(items) ? items : [items];
|
|
51
|
+
const seen = /* @__PURE__ */ new Set();
|
|
52
|
+
return list.map((item) => {
|
|
53
|
+
const normalized = { group, ...item };
|
|
54
|
+
const id = deriveMenuItemId(normalized);
|
|
55
|
+
if (import.meta.dev !== false && seen.has(id)) {
|
|
56
|
+
console.warn(`[@frigear-nu/menu] Duplicate menu item id "${id}" registered in the same batch.`);
|
|
57
|
+
}
|
|
58
|
+
seen.add(id);
|
|
59
|
+
return { ...normalized, id };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
export function extendMenu(nuxt, path, items) {
|
|
63
|
+
const { menu, group, wildcard } = parseMenuPath(path);
|
|
64
|
+
if (wildcard) {
|
|
65
|
+
throw new Error(`[@frigear-nu/menu] Cannot register items into wildcard path "${path}" - wildcards are only supported when reading a menu via \`useMenu\`.`);
|
|
66
|
+
}
|
|
67
|
+
const normalized = normalizeItems(group, items);
|
|
68
|
+
nuxt.options.menus ||= {};
|
|
69
|
+
const extensions = nuxt.options.menus._extensions ||= {};
|
|
70
|
+
const groups = extensions[menu] ||= {};
|
|
71
|
+
const list = groups[group] ||= [];
|
|
72
|
+
list.push(...normalized);
|
|
73
|
+
nuxt.callHook("menus:extend", menu, normalized);
|
|
74
|
+
}
|
|
75
|
+
export const defineMenuSource = (source) => source;
|
|
76
|
+
export const registerMenuSource = (nuxt, options) => {
|
|
77
|
+
nuxt.options.menus ||= {};
|
|
78
|
+
const sources = Array.isArray(nuxt.options.menus._sources) ? nuxt.options.menus._sources : [];
|
|
79
|
+
nuxt.options.menus._sources = sources;
|
|
80
|
+
if (!sources.some((source) => source.menu === options.menu && source.file === options.file)) {
|
|
81
|
+
sources.push({ menu: options.menu, file: options.file });
|
|
82
|
+
}
|
|
83
|
+
};
|
package/dist/types.d.mts
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frigear-nu/menu",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "Named, shareable navigation menus - modules contribute items (extendMenu / a runtime/menus.ts source) and/or Nuxt Content, consumers read them via useMenu and render them however they like.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/frigear-nu/frigear-nu.git",
|
|
8
|
+
"directory": "packages/menu"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "./dist/module.mjs",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/types.d.mts",
|
|
16
|
+
"import": "./dist/module.mjs",
|
|
17
|
+
"default": "./dist/module.mjs"
|
|
18
|
+
},
|
|
19
|
+
"./utils": {
|
|
20
|
+
"types": "./dist/runtime/utils/index.d.ts",
|
|
21
|
+
"import": "./dist/runtime/utils/index.js",
|
|
22
|
+
"default": "./dist/runtime/utils/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"workspaces": [
|
|
29
|
+
"playground"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@nuxt/kit": "^4.6.0",
|
|
33
|
+
"@frigear-nu/module-kit": "0.1.1",
|
|
34
|
+
"zod": "^4.6.5"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@nuxt/devtools": "^3.4.2",
|
|
38
|
+
"@nuxt/eslint-config": "^1.17.0",
|
|
39
|
+
"@nuxt/module-builder": "^1.0.3",
|
|
40
|
+
"@nuxt/schema": "^4.6.0",
|
|
41
|
+
"@nuxt/test-utils": "^4.3.2",
|
|
42
|
+
"@types/node": "latest",
|
|
43
|
+
"changelogen": "^0.6.2",
|
|
44
|
+
"eslint": "^10.10.0",
|
|
45
|
+
"nuxt": "^4.6.0",
|
|
46
|
+
"typescript": "^6.0.3",
|
|
47
|
+
"vitest": "^5.0.1",
|
|
48
|
+
"vue-tsc": "^3.3.11"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"postinstall": "nuxt-module-build build --stub && nuxt prepare",
|
|
52
|
+
"build": "nuxt-module-build build",
|
|
53
|
+
"dev": "npm run dev:prepare && nuxt dev playground",
|
|
54
|
+
"dev:build": "nuxt build playground",
|
|
55
|
+
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
|
|
56
|
+
"release": "npm run lint && npm run test && npm run prepack && changelogen --release && npm publish && git push --follow-tags",
|
|
57
|
+
"lint": "eslint .",
|
|
58
|
+
"lint:fix": "eslint . --fix",
|
|
59
|
+
"test": "vitest run",
|
|
60
|
+
"test:watch": "vitest watch",
|
|
61
|
+
"test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
|
|
62
|
+
},
|
|
63
|
+
"typesVersions": {
|
|
64
|
+
"*": {
|
|
65
|
+
".": [
|
|
66
|
+
"./dist/types.d.mts"
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
}
|
|
6
70
|
}
|