@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 CHANGED
@@ -1,3 +1,146 @@
1
- # Temporary Holding Version
1
+ # @frigear-nu/menu
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
+ 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 };
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@frigear-nu/menu",
3
+ "configKey": "menus",
4
+ "version": "0.0.3",
5
+ "builder": {
6
+ "@nuxt/module-builder": "1.0.3",
7
+ "unbuild": "unknown"
8
+ }
9
+ }
@@ -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,3 @@
1
+ import { type MenuItem } from '../utils/menu.js';
2
+ import type { H3Event } from 'h3';
3
+ export declare const useMenu: (event: H3Event, path: string) => Promise<MenuItem[]>;
@@ -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,3 @@
1
+ export { extendMenu, defineMenuSource, registerMenuSource, parseMenuPath, deriveMenuItemId } from "./menu.js";
2
+ export { extendMenuItem } from "../app/utils/extend.js";
3
+ export const defineMenuItem = (item) => item;
@@ -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
+ };
@@ -0,0 +1,3 @@
1
+ export { default } from './module.mjs'
2
+
3
+ export { type Menu, type MenuContentSourceOptions, type MenuItem, type MenuSource, type ModuleOptions, type ResolvedMenuContentSource } from './module.mjs'
package/package.json CHANGED
@@ -1,6 +1,70 @@
1
1
  {
2
2
  "name": "@frigear-nu/menu",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
  }