domma-cms 1.6.59 → 1.7.0
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/CHANGELOG.md +4344 -0
- package/README.md +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +2 -2
- package/admin/js/lib/draft-owner.js +1 -0
- package/admin/js/lib/effect-defs.js +1 -1
- package/admin/js/lib/effects-builder.js +2 -2
- package/admin/js/lib/page-editor-reference.js +5 -5
- package/admin/js/lib/shortcode-tags.js +1 -1
- package/admin/js/templates/docs/usage-shortcodes.html +39 -0
- package/admin/js/views/user-editor.js +1 -1
- package/docs/DommaFeatures.md +3005 -0
- package/docs/MultiTenant.md +176 -0
- package/docs/api-reference.md +1667 -0
- package/docs/cache.md +96 -0
- package/docs/configuration.md +1811 -0
- package/docs/editions-and-licences.md +190 -0
- package/docs/getting-started.md +201 -0
- package/docs/guides/form-triggers.md +260 -0
- package/docs/guides/mongodb-storage-adapter.md +282 -0
- package/docs/guides/multi-page-forms.md +121 -0
- package/docs/images/scribe-composer.png +0 -0
- package/docs/markdown-shortcodes.md +1738 -0
- package/docs/pentest-report-2026-04-03.md +181 -0
- package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
- package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
- package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
- package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
- package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
- package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
- package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
- package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
- package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
- package/docs/plans/2026-03-05-user-types-design.md +139 -0
- package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
- package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
- package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
- package/docs/plans/2026-06-10-core-project-design.md +240 -0
- package/docs/plugin-development.md +827 -0
- package/docs/recruitment-recipe.md +501 -0
- package/docs/scaffolding.md +312 -0
- package/docs/screenshots/calculator-floating.png +0 -0
- package/docs/screenshots/calculator-standard.png +0 -0
- package/docs/screenshots/email-settings.png +0 -0
- package/docs/screenshots/messages-count.png +0 -0
- package/docs/structure/user-types.md +107 -0
- package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
- package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
- package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
- package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
- package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
- package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
- package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
- package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
- package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
- package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
- package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
- package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
- package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
- package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
- package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
- package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
- package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
- package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
- package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
- package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
- package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
- package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
- package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
- package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
- package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
- package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
- package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
- package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
- package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
- package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
- package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
- package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
- package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
- package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
- package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
- package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
- package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
- package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
- package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
- package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
- package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
- package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
- package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
- package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
- package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
- package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
- package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
- package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
- package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
- package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
- package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
- package/docs/theming.md +320 -0
- package/package.json +2 -2
- package/plugins/_lib/admin/mail/keyboard.js +49 -0
- package/plugins/_lib/admin/mail/reader-view.js +4 -2
- package/public/js/celebrations/core/canvas.js +1 -1
- package/public/js/celebrations/index.js +1 -1
- package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
- package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
- package/public/js/celebrations/message-attrs.mjs +1 -0
- package/public/js/celebrations/themes/anniversary.js +1 -0
- package/public/js/celebrations/themes/autumn.js +1 -0
- package/public/js/celebrations/themes/birthday.js +1 -0
- package/public/js/celebrations/themes/butterfly-flight.js +1 -0
- package/public/js/celebrations/themes/calendar.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year.js +1 -0
- package/public/js/celebrations/themes/christmas.js +1 -1
- package/public/js/celebrations/themes/easter.js +1 -0
- package/public/js/celebrations/themes/garden-drawing.js +1 -0
- package/public/js/celebrations/themes/garden.js +1 -0
- package/public/js/celebrations/themes/graduation.js +1 -0
- package/public/js/celebrations/themes/greeting.js +1 -0
- package/public/js/celebrations/themes/make-it-rain.js +1 -0
- package/public/js/celebrations/themes/money-rain.js +1 -0
- package/public/js/celebrations/themes/new-year.js +1 -0
- package/public/js/celebrations/themes/occasion-drawing.js +1 -0
- package/public/js/celebrations/themes/occasions.js +1 -0
- package/public/js/celebrations/themes/rain-control.js +1 -0
- package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
- package/public/js/celebrations/themes/sparkler-font.js +1 -0
- package/public/js/celebrations/themes/sparkler-writing.js +1 -0
- package/public/js/celebrations/themes/summer-garden.js +1 -0
- package/public/js/celebrations/themes/winter.js +1 -0
- package/public/js/celebrations/trait-data.mjs +1 -1
- package/public/js/effects.js +1 -1
- package/scripts/build.js +4 -0
- package/server/services/markdown.js +5 -2
- package/server/templates/page.html +1 -1
|
@@ -0,0 +1,3096 @@
|
|
|
1
|
+
# Menus Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Replace single-navbar `config/navigation.json` + single-footer `site.json.footer.links` with a multi-menu system. Arbitrary named menus, each a tree of items with per-menu styling, mapped to named locations (slots) and renderable inline via a `[menu]` shortcode.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Per-menu JSON files in `config/menus/<slug>.json`. A slot-to-menu map in `config/menu-locations.json`. A code-level locations registry seeded by core (`navbar`, `footer-primary`, `footer-legal`) and extended at runtime through a new `registerMenuLocation()` hook for plugins. Server-side renderer composes the navbar from a site-wide brand (new `site.json.brand`) + the menu mapped to `navbar`. A new `[menu]` shortcode renders any menu inline. Auto-migration runs once on first boot, converting the two legacy configs and leaving `.bak` files for fallback. The user-visible navbar flyout (depth ≥ 2) and `position: "floating"` are upstream `domma-js` work and are tracked separately - the CMS-side plan assumes they land first.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Fastify 5 (ESM), Domma SPA admin (Domma JS framework `$`/`E`/`F`/`T`/`I`/`H`/`S`), `node:test` runner, gray-matter + marked for markdown.
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-05-24-menus-design.md`
|
|
12
|
+
|
|
13
|
+
**Critical project rules** (from `CLAUDE.md` + memory):
|
|
14
|
+
|
|
15
|
+
- Server runs on port **4096** and is **always running** - never start/stop. Test via `curl` only.
|
|
16
|
+
- Domma admin uses `S.set/get`, `H.get/post/put/delete`, `E.toast/confirm/modal/tabs`, `T.create`, `I.scan`, `F.create`. Never vanilla equivalents.
|
|
17
|
+
- `.get(0)` to unwrap Domma collections, not `[0]`.
|
|
18
|
+
- Tabs class names: `.tabs` / `.tab-list` / `button.tab-item` / `.tab-panel` (NOT `.tabs-nav` etc).
|
|
19
|
+
- Admin JS/MJS/CSS uses `Cache-Control: no-store` - no `?v=N` cache-busting query strings.
|
|
20
|
+
- UI changes must be browser-tested (memory: `feedback_no_verified_without_browser.md`). The server is reachable at `192.168.1.4:4096` from the user's browser; the visual companion (if invoked) needs `--host 0.0.0.0 --url-host 192.168.1.4`.
|
|
21
|
+
- Do NOT commit during implementation - user commits separately (memory: `feedback_no_commits.md`).
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## File Map
|
|
26
|
+
|
|
27
|
+
| Path | Purpose | Action |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `server/services/menus.js` | New service: CRUD, validation, location registry, `resolveLocation(slot, user)` | Create |
|
|
30
|
+
| `server/services/menus-migration.js` | New service: one-shot migration of `navigation.json` + `site.json.footer.links` | Create |
|
|
31
|
+
| `server/services/hooks.js` | Add `registerMenuLocation()` export | Modify |
|
|
32
|
+
| `server/services/permissionRegistry.js` | Add `menus` family with `read/create/update/delete` actions | Modify |
|
|
33
|
+
| `server/services/renderer.js` | Replace `getConfig('navigation')` flow with menu-location-driven navbar; inject `window.__CMS_FOOTER__` global | Modify |
|
|
34
|
+
| `server/services/markdown.js` | Register `[menu]` shortcode handler | Modify |
|
|
35
|
+
| `server/services/scaffolder.js` | Add `menus` recipe branch (pre-flight + apply between roles and collection) | Modify |
|
|
36
|
+
| `server/routes/api/menus.js` | New: REST API for `/api/menus/*` | Create |
|
|
37
|
+
| `server/routes/api/menu-locations.js` | New: REST API for `/api/menu-locations/*` | Create |
|
|
38
|
+
| `server/server.js` | Call `migrateLegacyNavIfPresent()` at startup; register the two new route modules | Modify |
|
|
39
|
+
| `public/js/site.js` | Read `window.__CMS_FOOTER__` for primary/legal footer columns | Modify |
|
|
40
|
+
| `admin/js/api.js` | Add `api.menus.*` and `api.menuLocations.*` clients | Modify |
|
|
41
|
+
| `admin/js/config/sidebar-config.js` | Rename "Navigation" → "Menus", point at `#/menus`, gate on `menus` permission | Modify |
|
|
42
|
+
| `admin/js/app.js` | Add menu routes (`/menus`, `/menus/edit/:slug`, `/menu-locations`); hash redirect from `/navigation` | Modify |
|
|
43
|
+
| `admin/js/views/index.js` | Export new views | Modify |
|
|
44
|
+
| `admin/js/views/menus.js` | New: list view of menus | Create |
|
|
45
|
+
| `admin/js/views/menu-editor.js` | New: per-menu tree editor (Items / Behaviour / Meta tabs) | Create |
|
|
46
|
+
| `admin/js/views/menu-locations.js` | New: slot → menu mapping editor | Create |
|
|
47
|
+
| `admin/js/templates/menus.html` | New: list template | Create |
|
|
48
|
+
| `admin/js/templates/menu-editor.html` | New: editor template | Create |
|
|
49
|
+
| `admin/js/templates/menu-locations.html` | New: locations template | Create |
|
|
50
|
+
| `admin/css/admin.css` | Tree-row styles for menu editor | Modify |
|
|
51
|
+
| `tests/services/menus.test.js` | New: service tests | Create |
|
|
52
|
+
| `tests/services/menus-migration.test.js` | New: migration tests | Create |
|
|
53
|
+
| `tests/services/menus-shortcode.test.js` | New: `[menu]` shortcode tests | Create |
|
|
54
|
+
| `tests/routes/menus.test.js` | New: API smoke tests via Fastify `.inject` | Create |
|
|
55
|
+
| `docs/configuration.md` | Replace `config/navigation.json` section | Modify |
|
|
56
|
+
| `docs/markdown-shortcodes.md` | Add `[menu]` entry | Modify |
|
|
57
|
+
| `docs/plugin-development.md` | Document `registerMenuLocation` | Modify |
|
|
58
|
+
| `docs/scaffolding.md` | Add `menus` recipe branch | Modify |
|
|
59
|
+
| `CLAUDE.md` | Update navbar/menus section | Modify |
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Task 1: Service skeleton - file IO + slug validation
|
|
64
|
+
|
|
65
|
+
**Files:**
|
|
66
|
+
|
|
67
|
+
- Create: `server/services/menus.js`
|
|
68
|
+
- Create: `tests/services/menus.test.js`
|
|
69
|
+
|
|
70
|
+
The service uses the same file-IO pattern as `server/services/forms.js` and `server/services/users.js`. Menus live under `config/menus/<slug>.json`. The skeleton starts with slug validation, paths, and the two basic readers; CRUD comes in later tasks.
|
|
71
|
+
|
|
72
|
+
- [ ] **Step 1.1: Write the slug + path test**
|
|
73
|
+
|
|
74
|
+
Create `tests/services/menus.test.js`:
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
/**
|
|
78
|
+
* Menus service - file IO, validation, location registry, visibility filtering.
|
|
79
|
+
* Uses unique slugs per test so runs are independent and self-cleaning.
|
|
80
|
+
*/
|
|
81
|
+
import {test, after, before} from 'node:test';
|
|
82
|
+
import assert from 'node:assert/strict';
|
|
83
|
+
import fs from 'fs/promises';
|
|
84
|
+
import path from 'path';
|
|
85
|
+
import {
|
|
86
|
+
MENUS_DIR,
|
|
87
|
+
validateSlug
|
|
88
|
+
} from '../../server/services/menus.js';
|
|
89
|
+
|
|
90
|
+
test('validateSlug - accepts lowercase alphanumeric + hyphen', () => {
|
|
91
|
+
assert.equal(validateSlug('main'), true);
|
|
92
|
+
assert.equal(validateSlug('main-nav'), true);
|
|
93
|
+
assert.equal(validateSlug('footer-2'), true);
|
|
94
|
+
assert.equal(validateSlug('1nav'), true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('validateSlug - rejects empty, capitals, spaces, traversal', () => {
|
|
98
|
+
assert.equal(validateSlug(''), false);
|
|
99
|
+
assert.equal(validateSlug('Main'), false);
|
|
100
|
+
assert.equal(validateSlug('main nav'), false);
|
|
101
|
+
assert.equal(validateSlug('../etc'), false);
|
|
102
|
+
assert.equal(validateSlug('main/sub'), false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('MENUS_DIR - resolves to config/menus inside the project', () => {
|
|
106
|
+
assert.ok(MENUS_DIR.endsWith('/config/menus'));
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- [ ] **Step 1.2: Run the test to confirm it fails**
|
|
111
|
+
|
|
112
|
+
Run: `node --test tests/services/menus.test.js`
|
|
113
|
+
|
|
114
|
+
Expected: import failure (`Cannot find module '../../server/services/menus.js'`).
|
|
115
|
+
|
|
116
|
+
- [ ] **Step 1.3: Create the service skeleton**
|
|
117
|
+
|
|
118
|
+
Create `server/services/menus.js`:
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
/**
|
|
122
|
+
* Menus service.
|
|
123
|
+
*
|
|
124
|
+
* Multi-menu storage backing the public navbar, footer, plugin-registered
|
|
125
|
+
* slots, and the `[menu]` shortcode. Each menu lives in its own JSON file
|
|
126
|
+
* under `config/menus/<slug>.json`. A slot → menu-slug map lives in
|
|
127
|
+
* `config/menu-locations.json`. The list of registered slots (a separate
|
|
128
|
+
* concept from the map) is held in process memory and seeded by core +
|
|
129
|
+
* plugin hooks at startup.
|
|
130
|
+
*/
|
|
131
|
+
import fs from 'fs/promises';
|
|
132
|
+
import path from 'path';
|
|
133
|
+
import {fileURLToPath} from 'url';
|
|
134
|
+
|
|
135
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
136
|
+
const __dirname = path.dirname(__filename);
|
|
137
|
+
|
|
138
|
+
/** Project root config directory. */
|
|
139
|
+
const CONFIG_DIR = path.resolve(__dirname, '..', '..', 'config');
|
|
140
|
+
|
|
141
|
+
/** Per-menu files live here. */
|
|
142
|
+
export const MENUS_DIR = path.join(CONFIG_DIR, 'menus');
|
|
143
|
+
|
|
144
|
+
/** Slot → menu-slug map. */
|
|
145
|
+
export const LOCATIONS_FILE = path.join(CONFIG_DIR, 'menu-locations.json');
|
|
146
|
+
|
|
147
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Validate a slug (used for both menu slugs and location slot names).
|
|
151
|
+
*
|
|
152
|
+
* @param {unknown} slug
|
|
153
|
+
* @returns {boolean}
|
|
154
|
+
*/
|
|
155
|
+
export function validateSlug(slug) {
|
|
156
|
+
return typeof slug === 'string' && SLUG_RE.test(slug);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Ensure MENUS_DIR exists. Safe to call repeatedly.
|
|
161
|
+
*/
|
|
162
|
+
export async function ensureMenusDir() {
|
|
163
|
+
await fs.mkdir(MENUS_DIR, {recursive: true});
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- [ ] **Step 1.4: Run the test to confirm it passes**
|
|
168
|
+
|
|
169
|
+
Run: `node --test tests/services/menus.test.js`
|
|
170
|
+
|
|
171
|
+
Expected: all three tests pass.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Task 2: Menu file IO - list, get, create, update, delete, duplicate
|
|
176
|
+
|
|
177
|
+
**Files:**
|
|
178
|
+
|
|
179
|
+
- Modify: `server/services/menus.js`
|
|
180
|
+
- Modify: `tests/services/menus.test.js`
|
|
181
|
+
|
|
182
|
+
- [ ] **Step 2.1: Write the CRUD tests**
|
|
183
|
+
|
|
184
|
+
Append to `tests/services/menus.test.js`:
|
|
185
|
+
|
|
186
|
+
```js
|
|
187
|
+
import {
|
|
188
|
+
listMenus, getMenu, createMenu, updateMenu, deleteMenu, duplicateMenu
|
|
189
|
+
} from '../../server/services/menus.js';
|
|
190
|
+
|
|
191
|
+
// Unique slug per test run so reruns don't collide.
|
|
192
|
+
const T = () => 'test-menus-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
|
|
193
|
+
|
|
194
|
+
test('createMenu → getMenu round-trip preserves all fields', async () => {
|
|
195
|
+
const slug = T();
|
|
196
|
+
const menu = {
|
|
197
|
+
slug, name: 'Test', description: 'd',
|
|
198
|
+
variant: 'dark', position: 'sticky',
|
|
199
|
+
style: {fontSize: '14px'},
|
|
200
|
+
items: [{text: 'Home', url: '/'}]
|
|
201
|
+
};
|
|
202
|
+
await createMenu(menu);
|
|
203
|
+
try {
|
|
204
|
+
const fetched = await getMenu(slug);
|
|
205
|
+
assert.equal(fetched.slug, slug);
|
|
206
|
+
assert.equal(fetched.name, 'Test');
|
|
207
|
+
assert.equal(fetched.variant, 'dark');
|
|
208
|
+
assert.equal(fetched.items[0].text, 'Home');
|
|
209
|
+
assert.ok(fetched.meta.createdAt);
|
|
210
|
+
assert.ok(fetched.meta.updatedAt);
|
|
211
|
+
} finally {
|
|
212
|
+
await deleteMenu(slug);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('createMenu refuses duplicate slug', async () => {
|
|
217
|
+
const slug = T();
|
|
218
|
+
await createMenu({slug, name: 'A', items: []});
|
|
219
|
+
try {
|
|
220
|
+
await assert.rejects(
|
|
221
|
+
() => createMenu({slug, name: 'A', items: []}),
|
|
222
|
+
/already exists/
|
|
223
|
+
);
|
|
224
|
+
} finally {
|
|
225
|
+
await deleteMenu(slug);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('updateMenu preserves meta.createdAt, bumps meta.updatedAt', async () => {
|
|
230
|
+
const slug = T();
|
|
231
|
+
await createMenu({slug, name: 'A', items: []});
|
|
232
|
+
try {
|
|
233
|
+
const original = await getMenu(slug);
|
|
234
|
+
await new Promise(r => setTimeout(r, 10));
|
|
235
|
+
await updateMenu(slug, {slug, name: 'B', items: []});
|
|
236
|
+
const updated = await getMenu(slug);
|
|
237
|
+
assert.equal(updated.meta.createdAt, original.meta.createdAt);
|
|
238
|
+
assert.notEqual(updated.meta.updatedAt, original.meta.updatedAt);
|
|
239
|
+
assert.equal(updated.name, 'B');
|
|
240
|
+
} finally {
|
|
241
|
+
await deleteMenu(slug);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('listMenus returns slug+name+description+meta only (no items)', async () => {
|
|
246
|
+
const slug = T();
|
|
247
|
+
await createMenu({slug, name: 'A', description: 'd', items: [{text: 'x', url: '/x'}]});
|
|
248
|
+
try {
|
|
249
|
+
const all = await listMenus();
|
|
250
|
+
const found = all.find(m => m.slug === slug);
|
|
251
|
+
assert.ok(found);
|
|
252
|
+
assert.equal(found.name, 'A');
|
|
253
|
+
assert.equal(found.description, 'd');
|
|
254
|
+
assert.equal(found.items, undefined);
|
|
255
|
+
} finally {
|
|
256
|
+
await deleteMenu(slug);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('getMenu returns null for unknown slug', async () => {
|
|
261
|
+
assert.equal(await getMenu('nope-' + Date.now()), null);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('getMenu rejects path traversal', async () => {
|
|
265
|
+
assert.equal(await getMenu('../../etc/passwd'), null);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('duplicateMenu copies content under <slug>-copy (or -copy-N)', async () => {
|
|
269
|
+
const slug = T();
|
|
270
|
+
await createMenu({slug, name: 'Orig', items: [{text: 'x', url: '/x'}]});
|
|
271
|
+
try {
|
|
272
|
+
const dup1 = await duplicateMenu(slug);
|
|
273
|
+
assert.equal(dup1.slug, slug + '-copy');
|
|
274
|
+
assert.equal(dup1.items[0].text, 'x');
|
|
275
|
+
assert.notEqual(dup1.name, 'Orig'); // name suffixed
|
|
276
|
+
const dup2 = await duplicateMenu(slug);
|
|
277
|
+
assert.equal(dup2.slug, slug + '-copy-2');
|
|
278
|
+
await deleteMenu(dup1.slug);
|
|
279
|
+
await deleteMenu(dup2.slug);
|
|
280
|
+
} finally {
|
|
281
|
+
await deleteMenu(slug);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
- [ ] **Step 2.2: Run to confirm tests fail with "is not a function"**
|
|
287
|
+
|
|
288
|
+
Run: `node --test tests/services/menus.test.js`
|
|
289
|
+
|
|
290
|
+
Expected: failures referencing `listMenus`, `getMenu`, etc.
|
|
291
|
+
|
|
292
|
+
- [ ] **Step 2.3: Implement the CRUD functions**
|
|
293
|
+
|
|
294
|
+
Append to `server/services/menus.js`:
|
|
295
|
+
|
|
296
|
+
```js
|
|
297
|
+
function menuPath(slug) {
|
|
298
|
+
return path.join(MENUS_DIR, `${slug}.json`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function readMenuFile(slug) {
|
|
302
|
+
const raw = await fs.readFile(menuPath(slug), 'utf8');
|
|
303
|
+
return JSON.parse(raw);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function writeMenuFile(menu) {
|
|
307
|
+
await ensureMenusDir();
|
|
308
|
+
await fs.writeFile(menuPath(menu.slug), JSON.stringify(menu, null, 2) + '\n', 'utf8');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* List all menus. Returns metadata only (no items) - use getMenu(slug) for
|
|
313
|
+
* the full payload. Sorted alphabetically by slug.
|
|
314
|
+
*
|
|
315
|
+
* @returns {Promise<Array<{slug:string,name:string,description:string,meta:object}>>}
|
|
316
|
+
*/
|
|
317
|
+
export async function listMenus() {
|
|
318
|
+
await ensureMenusDir();
|
|
319
|
+
const entries = await fs.readdir(MENUS_DIR);
|
|
320
|
+
const out = [];
|
|
321
|
+
for (const file of entries) {
|
|
322
|
+
if (!file.endsWith('.json')) continue;
|
|
323
|
+
const slug = file.slice(0, -5);
|
|
324
|
+
if (!validateSlug(slug)) continue;
|
|
325
|
+
try {
|
|
326
|
+
const menu = await readMenuFile(slug);
|
|
327
|
+
out.push({
|
|
328
|
+
slug: menu.slug || slug,
|
|
329
|
+
name: menu.name || '',
|
|
330
|
+
description: menu.description || '',
|
|
331
|
+
itemsCount: Array.isArray(menu.items) ? menu.items.length : 0,
|
|
332
|
+
meta: menu.meta || {}
|
|
333
|
+
});
|
|
334
|
+
} catch { /* skip malformed file */ }
|
|
335
|
+
}
|
|
336
|
+
return out.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Read a full menu by slug. Returns null if missing or slug is invalid.
|
|
341
|
+
*
|
|
342
|
+
* @param {string} slug
|
|
343
|
+
* @returns {Promise<object|null>}
|
|
344
|
+
*/
|
|
345
|
+
export async function getMenu(slug) {
|
|
346
|
+
if (!validateSlug(slug)) return null;
|
|
347
|
+
try {
|
|
348
|
+
return await readMenuFile(slug);
|
|
349
|
+
} catch (err) {
|
|
350
|
+
if (err.code === 'ENOENT') return null;
|
|
351
|
+
throw err;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Create a new menu. Refuses on slug collision.
|
|
357
|
+
*
|
|
358
|
+
* @param {object} input - { slug, name, description?, variant?, position?, style?, items, meta? }
|
|
359
|
+
* @returns {Promise<object>} the persisted menu
|
|
360
|
+
*/
|
|
361
|
+
export async function createMenu(input) {
|
|
362
|
+
if (!validateSlug(input?.slug)) {
|
|
363
|
+
throw new Error(`Invalid menu slug: ${input?.slug}`);
|
|
364
|
+
}
|
|
365
|
+
if (await getMenu(input.slug)) {
|
|
366
|
+
throw new Error(`Menu "${input.slug}" already exists`);
|
|
367
|
+
}
|
|
368
|
+
const now = new Date().toISOString();
|
|
369
|
+
const menu = {
|
|
370
|
+
slug: input.slug,
|
|
371
|
+
name: input.name || input.slug,
|
|
372
|
+
description: input.description || '',
|
|
373
|
+
...(input.variant != null && {variant: input.variant}),
|
|
374
|
+
...(input.position != null && {position: input.position}),
|
|
375
|
+
...(input.style != null && {style: input.style}),
|
|
376
|
+
items: Array.isArray(input.items) ? input.items : [],
|
|
377
|
+
meta: {
|
|
378
|
+
createdAt: now,
|
|
379
|
+
updatedAt: now,
|
|
380
|
+
bundled: Boolean(input.meta?.bundled),
|
|
381
|
+
presetOwner: input.meta?.presetOwner || null
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
await writeMenuFile(menu);
|
|
385
|
+
return menu;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Replace an existing menu. Preserves meta.createdAt; updates meta.updatedAt.
|
|
390
|
+
* The slug in the path wins over any slug in the body.
|
|
391
|
+
*
|
|
392
|
+
* @param {string} slug
|
|
393
|
+
* @param {object} input
|
|
394
|
+
* @returns {Promise<object>} the persisted menu
|
|
395
|
+
*/
|
|
396
|
+
export async function updateMenu(slug, input) {
|
|
397
|
+
const existing = await getMenu(slug);
|
|
398
|
+
if (!existing) throw new Error(`Menu "${slug}" not found`);
|
|
399
|
+
const merged = {
|
|
400
|
+
...input,
|
|
401
|
+
slug,
|
|
402
|
+
meta: {
|
|
403
|
+
...existing.meta,
|
|
404
|
+
...(input.meta || {}),
|
|
405
|
+
createdAt: existing.meta?.createdAt || new Date().toISOString(),
|
|
406
|
+
updatedAt: new Date().toISOString()
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
await writeMenuFile(merged);
|
|
410
|
+
return merged;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Delete a menu file. Returns true if removed, false if it didn't exist.
|
|
415
|
+
* Callers must check the locations map first - service does not.
|
|
416
|
+
*
|
|
417
|
+
* @param {string} slug
|
|
418
|
+
* @returns {Promise<boolean>}
|
|
419
|
+
*/
|
|
420
|
+
export async function deleteMenu(slug) {
|
|
421
|
+
if (!validateSlug(slug)) return false;
|
|
422
|
+
try {
|
|
423
|
+
await fs.unlink(menuPath(slug));
|
|
424
|
+
return true;
|
|
425
|
+
} catch (err) {
|
|
426
|
+
if (err.code === 'ENOENT') return false;
|
|
427
|
+
throw err;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Duplicate a menu as <slug>-copy, falling back to -copy-2, -copy-3, … if
|
|
433
|
+
* needed. The duplicate's name is suffixed with " (copy)" for clarity.
|
|
434
|
+
*
|
|
435
|
+
* @param {string} slug
|
|
436
|
+
* @returns {Promise<object>} the new menu
|
|
437
|
+
*/
|
|
438
|
+
export async function duplicateMenu(slug) {
|
|
439
|
+
const source = await getMenu(slug);
|
|
440
|
+
if (!source) throw new Error(`Menu "${slug}" not found`);
|
|
441
|
+
let candidate = slug + '-copy';
|
|
442
|
+
let n = 2;
|
|
443
|
+
while (await getMenu(candidate)) {
|
|
444
|
+
candidate = slug + '-copy-' + n++;
|
|
445
|
+
}
|
|
446
|
+
return createMenu({
|
|
447
|
+
...source,
|
|
448
|
+
slug: candidate,
|
|
449
|
+
name: source.name + ' (copy)',
|
|
450
|
+
meta: {bundled: false, presetOwner: null}
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
- [ ] **Step 2.4: Run all tests in the file**
|
|
456
|
+
|
|
457
|
+
Run: `node --test tests/services/menus.test.js`
|
|
458
|
+
|
|
459
|
+
Expected: all tests pass.
|
|
460
|
+
|
|
461
|
+
---
|
|
462
|
+
|
|
463
|
+
## Task 3: Menu validation - items, urls, position, visibility
|
|
464
|
+
|
|
465
|
+
**Files:**
|
|
466
|
+
|
|
467
|
+
- Modify: `server/services/menus.js`
|
|
468
|
+
- Modify: `tests/services/menus.test.js`
|
|
469
|
+
|
|
470
|
+
- [ ] **Step 3.1: Write the validation tests**
|
|
471
|
+
|
|
472
|
+
Append to `tests/services/menus.test.js`:
|
|
473
|
+
|
|
474
|
+
```js
|
|
475
|
+
import {validateMenu} from '../../server/services/menus.js';
|
|
476
|
+
|
|
477
|
+
test('validateMenu - accepts a minimal menu', () => {
|
|
478
|
+
const errors = validateMenu({slug: 'a', name: 'A', items: []});
|
|
479
|
+
assert.deepEqual(errors, []);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('validateMenu - rejects bad slug', () => {
|
|
483
|
+
const errors = validateMenu({slug: 'Bad Slug', name: 'A', items: []});
|
|
484
|
+
assert.ok(errors.some(e => /slug/i.test(e)));
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test('validateMenu - rejects bad position', () => {
|
|
488
|
+
const errors = validateMenu({slug: 'a', name: 'A', position: 'wobble', items: []});
|
|
489
|
+
assert.ok(errors.some(e => /position/i.test(e)));
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test('validateMenu - accepts all four position values', () => {
|
|
493
|
+
for (const p of ['sticky', 'fixed', 'static', 'floating']) {
|
|
494
|
+
const errors = validateMenu({slug: 'a', name: 'A', position: p, items: []});
|
|
495
|
+
assert.deepEqual(errors, [], `position "${p}" should be valid`);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('validateMenu - items must have text + url', () => {
|
|
500
|
+
const errors = validateMenu({slug: 'a', name: 'A', items: [{text: ''}]});
|
|
501
|
+
assert.ok(errors.some(e => /text/i.test(e)));
|
|
502
|
+
const errors2 = validateMenu({slug: 'a', name: 'A', items: [{text: 'X', url: 'no-scheme-or-slash'}]});
|
|
503
|
+
assert.ok(errors2.some(e => /url/i.test(e)));
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test('validateMenu - accepts /, #, http, https, mailto urls', () => {
|
|
507
|
+
const urls = ['/x', '#frag', 'http://e.com', 'https://e.com', 'mailto:a@b.c'];
|
|
508
|
+
for (const url of urls) {
|
|
509
|
+
const errors = validateMenu({slug: 'a', name: 'A', items: [{text: 't', url}]});
|
|
510
|
+
assert.deepEqual(errors, [], `url ${url} should pass`);
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test('validateMenu - recursively validates nested children', () => {
|
|
515
|
+
const errors = validateMenu({
|
|
516
|
+
slug: 'a', name: 'A',
|
|
517
|
+
items: [{text: 'top', url: '/', items: [{text: '', url: '/x'}]}]
|
|
518
|
+
});
|
|
519
|
+
assert.ok(errors.some(e => /text/i.test(e)));
|
|
520
|
+
});
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
- [ ] **Step 3.2: Run to confirm validation tests fail**
|
|
524
|
+
|
|
525
|
+
Run: `node --test tests/services/menus.test.js`
|
|
526
|
+
|
|
527
|
+
Expected: 7 new tests fail with "validateMenu is not a function".
|
|
528
|
+
|
|
529
|
+
- [ ] **Step 3.3: Implement `validateMenu`**
|
|
530
|
+
|
|
531
|
+
Append to `server/services/menus.js`:
|
|
532
|
+
|
|
533
|
+
```js
|
|
534
|
+
const POSITIONS = new Set(['sticky', 'fixed', 'static', 'floating']);
|
|
535
|
+
const URL_PREFIX_RE = /^(\/|#|https?:\/\/|mailto:)/;
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Validate a menu object. Returns an array of human-readable error strings
|
|
539
|
+
* (empty on success). Performs structural + per-item recursive checks; does
|
|
540
|
+
* NOT validate visibility role names (those are checked at render time).
|
|
541
|
+
*
|
|
542
|
+
* @param {object} menu
|
|
543
|
+
* @returns {string[]}
|
|
544
|
+
*/
|
|
545
|
+
export function validateMenu(menu) {
|
|
546
|
+
const errors = [];
|
|
547
|
+
if (!menu || typeof menu !== 'object') return ['Menu must be an object'];
|
|
548
|
+
if (!validateSlug(menu.slug)) {
|
|
549
|
+
errors.push(`Invalid slug "${menu.slug}" - lowercase alphanumeric + hyphen only`);
|
|
550
|
+
}
|
|
551
|
+
if (!menu.name || typeof menu.name !== 'string') {
|
|
552
|
+
errors.push('name is required');
|
|
553
|
+
}
|
|
554
|
+
if (menu.position != null && !POSITIONS.has(menu.position)) {
|
|
555
|
+
errors.push(`Invalid position "${menu.position}" - expected one of: ${[...POSITIONS].join(', ')}`);
|
|
556
|
+
}
|
|
557
|
+
if (!Array.isArray(menu.items)) {
|
|
558
|
+
errors.push('items must be an array');
|
|
559
|
+
} else {
|
|
560
|
+
walkItems(menu.items, (item, path) => {
|
|
561
|
+
if (!item.text || typeof item.text !== 'string') {
|
|
562
|
+
errors.push(`Item ${path}: text is required`);
|
|
563
|
+
}
|
|
564
|
+
if (!item.url || typeof item.url !== 'string' || !URL_PREFIX_RE.test(item.url)) {
|
|
565
|
+
errors.push(`Item ${path}: url must start with /, #, http(s)://, or mailto:`);
|
|
566
|
+
}
|
|
567
|
+
if (item.items != null && !Array.isArray(item.items)) {
|
|
568
|
+
errors.push(`Item ${path}: items must be an array if present`);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
return errors;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function walkItems(items, fn, parentPath = '') {
|
|
576
|
+
items.forEach((item, idx) => {
|
|
577
|
+
const itemPath = parentPath ? `${parentPath}.${idx}` : String(idx);
|
|
578
|
+
fn(item, itemPath);
|
|
579
|
+
if (Array.isArray(item?.items)) walkItems(item.items, fn, itemPath);
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
- [ ] **Step 3.4: Wire validation into createMenu + updateMenu**
|
|
585
|
+
|
|
586
|
+
In `server/services/menus.js`, locate `createMenu`. After the slug-collision check and before the `now` declaration, add:
|
|
587
|
+
|
|
588
|
+
```js
|
|
589
|
+
const errors = validateMenu({...input, slug: input.slug});
|
|
590
|
+
if (errors.length) {
|
|
591
|
+
throw new Error('Validation failed: ' + errors.join('; '));
|
|
592
|
+
}
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
In `updateMenu`, after the `existing` check and before constructing `merged`, add:
|
|
596
|
+
|
|
597
|
+
```js
|
|
598
|
+
const errors = validateMenu({...input, slug});
|
|
599
|
+
if (errors.length) {
|
|
600
|
+
throw new Error('Validation failed: ' + errors.join('; '));
|
|
601
|
+
}
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
- [ ] **Step 3.5: Verify validation also fires through create/update**
|
|
605
|
+
|
|
606
|
+
Append to `tests/services/menus.test.js`:
|
|
607
|
+
|
|
608
|
+
```js
|
|
609
|
+
test('createMenu rejects invalid items via validation', async () => {
|
|
610
|
+
await assert.rejects(
|
|
611
|
+
() => createMenu({slug: 'test-bad-' + Date.now(), name: 'A', items: [{text: '', url: '/'}]}),
|
|
612
|
+
/Validation failed/
|
|
613
|
+
);
|
|
614
|
+
});
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
- [ ] **Step 3.6: Run all tests**
|
|
618
|
+
|
|
619
|
+
Run: `node --test tests/services/menus.test.js`
|
|
620
|
+
|
|
621
|
+
Expected: every test (Tasks 1-3) passes.
|
|
622
|
+
|
|
623
|
+
---
|
|
624
|
+
|
|
625
|
+
## Task 4: Locations registry + slot map IO
|
|
626
|
+
|
|
627
|
+
**Files:**
|
|
628
|
+
|
|
629
|
+
- Modify: `server/services/menus.js`
|
|
630
|
+
- Modify: `tests/services/menus.test.js`
|
|
631
|
+
|
|
632
|
+
The registry is a module-level Map of slot → definition, seeded by core at module load and extended at runtime by `registerLocation()`. The slot map is a JSON file (`config/menu-locations.json`).
|
|
633
|
+
|
|
634
|
+
- [ ] **Step 4.1: Write the registry + map tests**
|
|
635
|
+
|
|
636
|
+
Append to `tests/services/menus.test.js`:
|
|
637
|
+
|
|
638
|
+
```js
|
|
639
|
+
import {
|
|
640
|
+
registerLocation, getLocationRegistry,
|
|
641
|
+
getLocations, setLocations
|
|
642
|
+
} from '../../server/services/menus.js';
|
|
643
|
+
|
|
644
|
+
test('registry - built-in slots present after import', () => {
|
|
645
|
+
const slots = getLocationRegistry().map(s => s.slot);
|
|
646
|
+
assert.ok(slots.includes('navbar'));
|
|
647
|
+
assert.ok(slots.includes('footer-primary'));
|
|
648
|
+
assert.ok(slots.includes('footer-legal'));
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test('registerLocation - accepts plugin-contributed slot', () => {
|
|
652
|
+
registerLocation('test-slot-' + Date.now(), {
|
|
653
|
+
label: 'Test', description: 'd', maxDepth: 2, source: 'test-plugin'
|
|
654
|
+
});
|
|
655
|
+
const slots = getLocationRegistry().map(s => s.slot);
|
|
656
|
+
assert.ok(slots.some(s => s.startsWith('test-slot-')));
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
test('registerLocation - rejects bad slot name', () => {
|
|
660
|
+
assert.throws(() => registerLocation('Bad Slot', {label: 'x'}), /Invalid slot name/);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test('setLocations + getLocations round-trip', async () => {
|
|
664
|
+
const slug = T();
|
|
665
|
+
await createMenu({slug, name: 'A', items: []});
|
|
666
|
+
try {
|
|
667
|
+
await setLocations({navbar: slug});
|
|
668
|
+
const map = await getLocations();
|
|
669
|
+
assert.equal(map.navbar, slug);
|
|
670
|
+
} finally {
|
|
671
|
+
// Restore to whatever was there before the test (reset to empty for isolation)
|
|
672
|
+
await setLocations({});
|
|
673
|
+
await deleteMenu(slug);
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test('setLocations rejects unregistered slot', async () => {
|
|
678
|
+
const slug = T();
|
|
679
|
+
await createMenu({slug, name: 'A', items: []});
|
|
680
|
+
try {
|
|
681
|
+
await assert.rejects(
|
|
682
|
+
() => setLocations({'unknown-slot-xyz': slug}),
|
|
683
|
+
/not registered/
|
|
684
|
+
);
|
|
685
|
+
} finally {
|
|
686
|
+
await deleteMenu(slug);
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test('setLocations rejects mapping to nonexistent menu', async () => {
|
|
691
|
+
await assert.rejects(
|
|
692
|
+
() => setLocations({navbar: 'nope-' + Date.now()}),
|
|
693
|
+
/not found/
|
|
694
|
+
);
|
|
695
|
+
});
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
- [ ] **Step 4.2: Run to confirm tests fail**
|
|
699
|
+
|
|
700
|
+
Run: `node --test tests/services/menus.test.js`
|
|
701
|
+
|
|
702
|
+
Expected: 6 new tests fail with "is not a function".
|
|
703
|
+
|
|
704
|
+
- [ ] **Step 4.3: Implement the registry + slot-map IO**
|
|
705
|
+
|
|
706
|
+
Append to `server/services/menus.js`:
|
|
707
|
+
|
|
708
|
+
```js
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
// Locations registry - module-level Map, seeded by core, extended by plugins
|
|
711
|
+
// ---------------------------------------------------------------------------
|
|
712
|
+
|
|
713
|
+
const _locationRegistry = new Map(); // slot -> { label, description, maxDepth, source }
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Register a slot where a menu can be rendered. Re-registering an existing
|
|
717
|
+
* slot logs a warning and the second registration wins (last-write).
|
|
718
|
+
*
|
|
719
|
+
* @param {string} slot
|
|
720
|
+
* @param {{label:string, description?:string, maxDepth?:number, source?:string}} def
|
|
721
|
+
*/
|
|
722
|
+
export function registerLocation(slot, def) {
|
|
723
|
+
if (!validateSlug(slot)) throw new Error(`Invalid slot name: ${slot}`);
|
|
724
|
+
if (_locationRegistry.has(slot)) {
|
|
725
|
+
console.warn(`[menus] Slot "${slot}" re-registered - last write wins`);
|
|
726
|
+
}
|
|
727
|
+
_locationRegistry.set(slot, {
|
|
728
|
+
label: def?.label || slot,
|
|
729
|
+
description: def?.description || '',
|
|
730
|
+
maxDepth: Number.isFinite(def?.maxDepth) ? def.maxDepth : null,
|
|
731
|
+
source: def?.source || 'core'
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* @returns {Array<{slot:string, label:string, description:string, maxDepth:number|null, source:string}>}
|
|
737
|
+
*/
|
|
738
|
+
export function getLocationRegistry() {
|
|
739
|
+
return [..._locationRegistry.entries()].map(([slot, def]) => ({slot, ...def}));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Core slots - registered at module load. Plugins register theirs from
|
|
743
|
+
// their plugin.js setup function via the registerMenuLocation hook (Task 7).
|
|
744
|
+
registerLocation('navbar', {label: 'Navbar', description: 'Site navbar', source: 'core'});
|
|
745
|
+
registerLocation('footer-primary', {label: 'Footer primary', description: 'Primary footer column', source: 'core', maxDepth: 1});
|
|
746
|
+
registerLocation('footer-legal', {label: 'Footer legal', description: 'Legal/secondary footer column', source: 'core', maxDepth: 1});
|
|
747
|
+
|
|
748
|
+
// ---------------------------------------------------------------------------
|
|
749
|
+
// Slot → menu-slug map
|
|
750
|
+
// ---------------------------------------------------------------------------
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Read the slot → menu-slug map from disk. Returns an empty object when the
|
|
754
|
+
* file doesn't exist or is malformed (logged as a warning).
|
|
755
|
+
*
|
|
756
|
+
* @returns {Promise<Record<string,string>>}
|
|
757
|
+
*/
|
|
758
|
+
export async function getLocations() {
|
|
759
|
+
try {
|
|
760
|
+
const raw = await fs.readFile(LOCATIONS_FILE, 'utf8');
|
|
761
|
+
return JSON.parse(raw);
|
|
762
|
+
} catch (err) {
|
|
763
|
+
if (err.code === 'ENOENT') return {};
|
|
764
|
+
console.warn(`[menus] menu-locations.json unreadable: ${err.message}`);
|
|
765
|
+
return {};
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Replace the slot map. Validates that every slot is registered and every
|
|
771
|
+
* menu slug resolves to a real menu file.
|
|
772
|
+
*
|
|
773
|
+
* @param {Record<string,string>} map
|
|
774
|
+
*/
|
|
775
|
+
export async function setLocations(map) {
|
|
776
|
+
if (!map || typeof map !== 'object' || Array.isArray(map)) {
|
|
777
|
+
throw new Error('Locations map must be a plain object');
|
|
778
|
+
}
|
|
779
|
+
for (const slot of Object.keys(map)) {
|
|
780
|
+
if (!_locationRegistry.has(slot)) {
|
|
781
|
+
throw new Error(`Slot "${slot}" is not registered`);
|
|
782
|
+
}
|
|
783
|
+
const slug = map[slot];
|
|
784
|
+
if (slug != null && slug !== '' && !(await getMenu(slug))) {
|
|
785
|
+
throw new Error(`Menu "${slug}" not found for slot "${slot}"`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
await fs.writeFile(LOCATIONS_FILE, JSON.stringify(map, null, 2) + '\n', 'utf8');
|
|
789
|
+
}
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
- [ ] **Step 4.4: Run all tests**
|
|
793
|
+
|
|
794
|
+
Run: `node --test tests/services/menus.test.js`
|
|
795
|
+
|
|
796
|
+
Expected: every test (Tasks 1-4) passes.
|
|
797
|
+
|
|
798
|
+
---
|
|
799
|
+
|
|
800
|
+
## Task 5: `resolveLocation` - visibility-filtered menu lookup
|
|
801
|
+
|
|
802
|
+
**Files:**
|
|
803
|
+
|
|
804
|
+
- Modify: `server/services/menus.js`
|
|
805
|
+
- Modify: `tests/services/menus.test.js`
|
|
806
|
+
|
|
807
|
+
`resolveLocation(slot, user)` is the renderer's entry point. It reads the slot map, fetches the menu, and runs items through `checkVisibility()` from `server/middleware/auth.js` (reused - same helper used by page visibility).
|
|
808
|
+
|
|
809
|
+
- [ ] **Step 5.1: Verify the existing checkVisibility helper**
|
|
810
|
+
|
|
811
|
+
Read `server/middleware/auth.js` and confirm `checkVisibility(userRole, visibility)` is exported. (It's already in use for page visibility per `docs/configuration.md`.)
|
|
812
|
+
|
|
813
|
+
If it isn't exported under that exact name, search:
|
|
814
|
+
Run: `grep -nE "^export (function|const) checkVisibility|^export \\{[^}]*checkVisibility" server/middleware/auth.js`
|
|
815
|
+
|
|
816
|
+
Use the actual export name in the next step.
|
|
817
|
+
|
|
818
|
+
- [ ] **Step 5.2: Write resolveLocation tests**
|
|
819
|
+
|
|
820
|
+
Append to `tests/services/menus.test.js`:
|
|
821
|
+
|
|
822
|
+
```js
|
|
823
|
+
import {resolveLocation} from '../../server/services/menus.js';
|
|
824
|
+
|
|
825
|
+
test('resolveLocation - returns null when slot is unmapped', async () => {
|
|
826
|
+
await setLocations({});
|
|
827
|
+
const got = await resolveLocation('navbar', null);
|
|
828
|
+
assert.equal(got, null);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
test('resolveLocation - returns the menu when mapped', async () => {
|
|
832
|
+
const slug = T();
|
|
833
|
+
await createMenu({slug, name: 'A', items: [{text: 'Public', url: '/p'}]});
|
|
834
|
+
try {
|
|
835
|
+
await setLocations({navbar: slug});
|
|
836
|
+
const got = await resolveLocation('navbar', null);
|
|
837
|
+
assert.equal(got.slug, slug);
|
|
838
|
+
assert.equal(got.items[0].text, 'Public');
|
|
839
|
+
} finally {
|
|
840
|
+
await setLocations({});
|
|
841
|
+
await deleteMenu(slug);
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
test('resolveLocation - filters items by visibility for anonymous users', async () => {
|
|
846
|
+
const slug = T();
|
|
847
|
+
await createMenu({
|
|
848
|
+
slug, name: 'A',
|
|
849
|
+
items: [
|
|
850
|
+
{text: 'Public', url: '/p', visibility: 'public'},
|
|
851
|
+
{text: 'Members', url: '/m', visibility: 'subscriber'}
|
|
852
|
+
]
|
|
853
|
+
});
|
|
854
|
+
try {
|
|
855
|
+
await setLocations({navbar: slug});
|
|
856
|
+
const got = await resolveLocation('navbar', null);
|
|
857
|
+
assert.equal(got.items.length, 1);
|
|
858
|
+
assert.equal(got.items[0].text, 'Public');
|
|
859
|
+
} finally {
|
|
860
|
+
await setLocations({});
|
|
861
|
+
await deleteMenu(slug);
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
test('resolveLocation - drops empty parents whose children are all gated', async () => {
|
|
866
|
+
const slug = T();
|
|
867
|
+
await createMenu({
|
|
868
|
+
slug, name: 'A',
|
|
869
|
+
items: [
|
|
870
|
+
{text: 'Top', url: '/t', items: [
|
|
871
|
+
{text: 'Members only', url: '/mo', visibility: 'subscriber'}
|
|
872
|
+
]}
|
|
873
|
+
]
|
|
874
|
+
});
|
|
875
|
+
try {
|
|
876
|
+
await setLocations({navbar: slug});
|
|
877
|
+
const got = await resolveLocation('navbar', null);
|
|
878
|
+
// Top stays (it has its own visible link); its items array is empty.
|
|
879
|
+
assert.equal(got.items.length, 1);
|
|
880
|
+
assert.equal(got.items[0].text, 'Top');
|
|
881
|
+
assert.deepEqual(got.items[0].items, []);
|
|
882
|
+
} finally {
|
|
883
|
+
await setLocations({});
|
|
884
|
+
await deleteMenu(slug);
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
test('resolveLocation - respects hidden flag (always drops)', async () => {
|
|
889
|
+
const slug = T();
|
|
890
|
+
await createMenu({
|
|
891
|
+
slug, name: 'A',
|
|
892
|
+
items: [
|
|
893
|
+
{text: 'Visible', url: '/v'},
|
|
894
|
+
{text: 'Hidden', url: '/h', hidden: true}
|
|
895
|
+
]
|
|
896
|
+
});
|
|
897
|
+
try {
|
|
898
|
+
await setLocations({navbar: slug});
|
|
899
|
+
const got = await resolveLocation('navbar', null);
|
|
900
|
+
assert.equal(got.items.length, 1);
|
|
901
|
+
assert.equal(got.items[0].text, 'Visible');
|
|
902
|
+
} finally {
|
|
903
|
+
await setLocations({});
|
|
904
|
+
await deleteMenu(slug);
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
- [ ] **Step 5.3: Run to confirm tests fail**
|
|
910
|
+
|
|
911
|
+
Run: `node --test tests/services/menus.test.js`
|
|
912
|
+
|
|
913
|
+
Expected: 5 new tests fail with "resolveLocation is not a function".
|
|
914
|
+
|
|
915
|
+
- [ ] **Step 5.4: Implement resolveLocation**
|
|
916
|
+
|
|
917
|
+
Append to `server/services/menus.js`:
|
|
918
|
+
|
|
919
|
+
```js
|
|
920
|
+
import {checkVisibility} from '../middleware/auth.js';
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Resolve the menu mapped to `slot` for `user`, with items filtered by
|
|
924
|
+
* `hidden` (always dropped) and `visibility` (per-user role gating).
|
|
925
|
+
* Returns null if the slot is unmapped or the mapped menu is missing.
|
|
926
|
+
*
|
|
927
|
+
* Note: `user` may be null (anonymous). `user.role` and
|
|
928
|
+
* `user.additionalRoles` are honoured via `checkVisibility`.
|
|
929
|
+
*
|
|
930
|
+
* @param {string} slot
|
|
931
|
+
* @param {object|null} user
|
|
932
|
+
* @returns {Promise<object|null>}
|
|
933
|
+
*/
|
|
934
|
+
export async function resolveLocation(slot, user) {
|
|
935
|
+
const map = await getLocations();
|
|
936
|
+
const slug = map[slot];
|
|
937
|
+
if (!slug) return null;
|
|
938
|
+
const menu = await getMenu(slug);
|
|
939
|
+
if (!menu) {
|
|
940
|
+
console.warn(`[menus] Slot "${slot}" maps to "${slug}" which doesn't exist`);
|
|
941
|
+
return null;
|
|
942
|
+
}
|
|
943
|
+
return {
|
|
944
|
+
...menu,
|
|
945
|
+
items: filterItemsForUser(menu.items || [], user)
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function filterItemsForUser(items, user) {
|
|
950
|
+
const out = [];
|
|
951
|
+
for (const item of items) {
|
|
952
|
+
if (item.hidden) continue;
|
|
953
|
+
if (item.visibility != null) {
|
|
954
|
+
const role = user?.role || null;
|
|
955
|
+
if (!checkVisibility(role, item.visibility)) continue;
|
|
956
|
+
}
|
|
957
|
+
const children = Array.isArray(item.items)
|
|
958
|
+
? filterItemsForUser(item.items, user)
|
|
959
|
+
: [];
|
|
960
|
+
out.push({...item, items: children});
|
|
961
|
+
}
|
|
962
|
+
return out;
|
|
963
|
+
}
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
Move the `import {checkVisibility}` to the top of the file with the other imports.
|
|
967
|
+
|
|
968
|
+
- [ ] **Step 5.5: Run all tests**
|
|
969
|
+
|
|
970
|
+
Run: `node --test tests/services/menus.test.js`
|
|
971
|
+
|
|
972
|
+
Expected: all tests pass.
|
|
973
|
+
|
|
974
|
+
---
|
|
975
|
+
|
|
976
|
+
## Task 6: Permission family - `menus.*`
|
|
977
|
+
|
|
978
|
+
**Files:**
|
|
979
|
+
|
|
980
|
+
- Modify: `server/services/permissionRegistry.js`
|
|
981
|
+
|
|
982
|
+
- [ ] **Step 6.1: Find the navigation entry to anchor the insertion**
|
|
983
|
+
|
|
984
|
+
Run: `grep -n "key: 'navigation'" server/services/permissionRegistry.js`
|
|
985
|
+
|
|
986
|
+
Expected: one line number - note it.
|
|
987
|
+
|
|
988
|
+
- [ ] **Step 6.2: Insert the menus family after the navigation entry**
|
|
989
|
+
|
|
990
|
+
Read the lines around the `navigation` entry. After its closing `},`, insert the menus block:
|
|
991
|
+
|
|
992
|
+
```js
|
|
993
|
+
{
|
|
994
|
+
key: 'menus',
|
|
995
|
+
label: 'Menus',
|
|
996
|
+
description: 'Manage menus and the slots they render into.',
|
|
997
|
+
icon: 'menu',
|
|
998
|
+
group: 'Structure',
|
|
999
|
+
actions: [
|
|
1000
|
+
{key: 'read', label: 'View', description: 'View menus and locations'},
|
|
1001
|
+
{key: 'create', label: 'Create', description: 'Create new menus'},
|
|
1002
|
+
{key: 'update', label: 'Edit', description: 'Edit menus and the slot map'},
|
|
1003
|
+
{key: 'delete', label: 'Delete', description: 'Delete menus'}
|
|
1004
|
+
]
|
|
1005
|
+
},
|
|
1006
|
+
```
|
|
1007
|
+
|
|
1008
|
+
- [ ] **Step 6.3: Smoke-test the import surface**
|
|
1009
|
+
|
|
1010
|
+
Run: `node -e "import('./server/services/permissionRegistry.js').then(m => console.log(m.REGISTRY.find(r => r.key === 'menus')))"`
|
|
1011
|
+
|
|
1012
|
+
Expected: the menus object is printed, including all four actions.
|
|
1013
|
+
|
|
1014
|
+
---
|
|
1015
|
+
|
|
1016
|
+
## Task 7: `registerMenuLocation` plugin hook
|
|
1017
|
+
|
|
1018
|
+
**Files:**
|
|
1019
|
+
|
|
1020
|
+
- Modify: `server/services/hooks.js`
|
|
1021
|
+
|
|
1022
|
+
- [ ] **Step 7.1: Add the hook export**
|
|
1023
|
+
|
|
1024
|
+
In `server/services/hooks.js`, after the shortcode-registry block (the `getShortcodeProcessors` export, before the sanitize-rules block), add:
|
|
1025
|
+
|
|
1026
|
+
```js
|
|
1027
|
+
// ---------------------------------------------------------------------------
|
|
1028
|
+
// Menu-location registry - delegated to server/services/menus.js
|
|
1029
|
+
// ---------------------------------------------------------------------------
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Register a menu-location slot. Plugins call this from their setupPlugin to
|
|
1033
|
+
* declare slots where a curated menu can be rendered (e.g. a sidebar widget).
|
|
1034
|
+
* The slot then appears in the admin Locations editor and can be filled with
|
|
1035
|
+
* any menu the admin chooses.
|
|
1036
|
+
*
|
|
1037
|
+
* @param {string} slot - lower-snake-case slot name (e.g. 'sidebar')
|
|
1038
|
+
* @param {{label?:string, description?:string, maxDepth?:number}} def
|
|
1039
|
+
*/
|
|
1040
|
+
export async function registerMenuLocation(slot, def) {
|
|
1041
|
+
const {registerLocation} = await import('./menus.js');
|
|
1042
|
+
registerLocation(slot, {...def, source: def?.source || 'plugin'});
|
|
1043
|
+
}
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
- [ ] **Step 7.2: Expose it to plugins via the plugin loader**
|
|
1047
|
+
|
|
1048
|
+
Open `server/services/plugins.js`. Find the `hooks:` block passed to plugins (around line 207):
|
|
1049
|
+
|
|
1050
|
+
```js
|
|
1051
|
+
hooks: {registerShortcode, registerSanitizeRules, registerTransform, on: hooks.on.bind(hooks)},
|
|
1052
|
+
```
|
|
1053
|
+
|
|
1054
|
+
Replace with:
|
|
1055
|
+
|
|
1056
|
+
```js
|
|
1057
|
+
hooks: {registerShortcode, registerSanitizeRules, registerTransform, registerMenuLocation, on: hooks.on.bind(hooks)},
|
|
1058
|
+
```
|
|
1059
|
+
|
|
1060
|
+
Update the `import {hooks, ...}` line at the top to include `registerMenuLocation`.
|
|
1061
|
+
|
|
1062
|
+
- [ ] **Step 7.3: Smoke-test from a Node one-liner**
|
|
1063
|
+
|
|
1064
|
+
Run:
|
|
1065
|
+
```bash
|
|
1066
|
+
node -e "import('./server/services/hooks.js').then(async h => { await h.registerMenuLocation('plugin-test-sidebar', {label: 'Sidebar', description: 'd', source: 'test'}); const m = await import('./server/services/menus.js'); console.log(m.getLocationRegistry().find(s => s.slot === 'plugin-test-sidebar')); })"
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
Expected: the registered slot is printed with `source: 'test'`.
|
|
1070
|
+
|
|
1071
|
+
---
|
|
1072
|
+
|
|
1073
|
+
## Task 8: API routes - `/api/menus/*`
|
|
1074
|
+
|
|
1075
|
+
**Files:**
|
|
1076
|
+
|
|
1077
|
+
- Create: `server/routes/api/menus.js`
|
|
1078
|
+
- Create: `tests/routes/menus.test.js`
|
|
1079
|
+
|
|
1080
|
+
- [ ] **Step 8.1: Write the route tests using Fastify .inject**
|
|
1081
|
+
|
|
1082
|
+
Look at how an existing route test calls Fastify (e.g. `tests/routes/dashboard-summary.test.js`) to copy the bootstrapping pattern. The tests should hit the routes through a real Fastify instance with auth disabled or mocked - match whatever the existing route tests do.
|
|
1083
|
+
|
|
1084
|
+
Create `tests/routes/menus.test.js`:
|
|
1085
|
+
|
|
1086
|
+
```js
|
|
1087
|
+
/**
|
|
1088
|
+
* Smoke tests for /api/menus/* - verifies route shape, auth gates, and
|
|
1089
|
+
* successful round-trip CRUD. Heavy validation is tested in the service
|
|
1090
|
+
* tests; this file checks the wiring.
|
|
1091
|
+
*/
|
|
1092
|
+
import {test, after, before} from 'node:test';
|
|
1093
|
+
import assert from 'node:assert/strict';
|
|
1094
|
+
import Fastify from 'fastify';
|
|
1095
|
+
import fastifyJwt from '@fastify/jwt';
|
|
1096
|
+
import {menusRoutes} from '../../server/routes/api/menus.js';
|
|
1097
|
+
import {deleteMenu} from '../../server/services/menus.js';
|
|
1098
|
+
|
|
1099
|
+
// Token-less mode for tests: we replace authenticate + requirePermission with
|
|
1100
|
+
// pass-throughs by exposing the route module's preHandler hooks via fake
|
|
1101
|
+
// implementations. The simplest approach is to register a small auth shim
|
|
1102
|
+
// BEFORE menusRoutes that decorates request.user.
|
|
1103
|
+
async function build() {
|
|
1104
|
+
const app = Fastify();
|
|
1105
|
+
app.register(fastifyJwt, {secret: 'test-secret-only-for-tests-32chars'});
|
|
1106
|
+
app.addHook('preHandler', async (req) => {
|
|
1107
|
+
// Pretend every request is an admin
|
|
1108
|
+
req.user = {id: 'test-admin', role: 'admin'};
|
|
1109
|
+
});
|
|
1110
|
+
await app.register(async (inst) => menusRoutes(inst), {prefix: '/api'});
|
|
1111
|
+
return app;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const T = () => 'rt-menus-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
|
|
1115
|
+
|
|
1116
|
+
test('POST /api/menus → 201 + body', async () => {
|
|
1117
|
+
const app = await build();
|
|
1118
|
+
const slug = T();
|
|
1119
|
+
try {
|
|
1120
|
+
const res = await app.inject({
|
|
1121
|
+
method: 'POST',
|
|
1122
|
+
url: '/api/menus',
|
|
1123
|
+
payload: {slug, name: 'Routed', items: []}
|
|
1124
|
+
});
|
|
1125
|
+
assert.equal(res.statusCode, 201);
|
|
1126
|
+
const body = res.json();
|
|
1127
|
+
assert.equal(body.slug, slug);
|
|
1128
|
+
} finally {
|
|
1129
|
+
await deleteMenu(slug);
|
|
1130
|
+
await app.close();
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
test('GET /api/menus → array including the created menu', async () => {
|
|
1135
|
+
const app = await build();
|
|
1136
|
+
const slug = T();
|
|
1137
|
+
try {
|
|
1138
|
+
await app.inject({method: 'POST', url: '/api/menus', payload: {slug, name: 'A', items: []}});
|
|
1139
|
+
const res = await app.inject({method: 'GET', url: '/api/menus'});
|
|
1140
|
+
assert.equal(res.statusCode, 200);
|
|
1141
|
+
assert.ok(res.json().some(m => m.slug === slug));
|
|
1142
|
+
} finally {
|
|
1143
|
+
await deleteMenu(slug);
|
|
1144
|
+
await app.close();
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
test('GET /api/menus/:slug → full menu', async () => {
|
|
1149
|
+
const app = await build();
|
|
1150
|
+
const slug = T();
|
|
1151
|
+
try {
|
|
1152
|
+
await app.inject({method: 'POST', url: '/api/menus', payload: {slug, name: 'A', items: [{text: 'H', url: '/'}]}});
|
|
1153
|
+
const res = await app.inject({method: 'GET', url: `/api/menus/${slug}`});
|
|
1154
|
+
assert.equal(res.statusCode, 200);
|
|
1155
|
+
assert.equal(res.json().items[0].text, 'H');
|
|
1156
|
+
} finally {
|
|
1157
|
+
await deleteMenu(slug);
|
|
1158
|
+
await app.close();
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
test('GET /api/menus/:slug → 404 for unknown', async () => {
|
|
1163
|
+
const app = await build();
|
|
1164
|
+
const res = await app.inject({method: 'GET', url: '/api/menus/does-not-exist-xyz'});
|
|
1165
|
+
assert.equal(res.statusCode, 404);
|
|
1166
|
+
await app.close();
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
test('DELETE /api/menus/:slug → 200 when not mapped', async () => {
|
|
1170
|
+
const app = await build();
|
|
1171
|
+
const slug = T();
|
|
1172
|
+
await app.inject({method: 'POST', url: '/api/menus', payload: {slug, name: 'A', items: []}});
|
|
1173
|
+
const res = await app.inject({method: 'DELETE', url: `/api/menus/${slug}`});
|
|
1174
|
+
assert.equal(res.statusCode, 200);
|
|
1175
|
+
await app.close();
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
test('POST /api/menus/:slug/duplicate → 201 with -copy suffix', async () => {
|
|
1179
|
+
const app = await build();
|
|
1180
|
+
const slug = T();
|
|
1181
|
+
try {
|
|
1182
|
+
await app.inject({method: 'POST', url: '/api/menus', payload: {slug, name: 'A', items: []}});
|
|
1183
|
+
const res = await app.inject({method: 'POST', url: `/api/menus/${slug}/duplicate`});
|
|
1184
|
+
assert.equal(res.statusCode, 201);
|
|
1185
|
+
const body = res.json();
|
|
1186
|
+
assert.equal(body.slug, slug + '-copy');
|
|
1187
|
+
await deleteMenu(body.slug);
|
|
1188
|
+
} finally {
|
|
1189
|
+
await deleteMenu(slug);
|
|
1190
|
+
await app.close();
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
```
|
|
1194
|
+
|
|
1195
|
+
- [ ] **Step 8.2: Run to confirm tests fail**
|
|
1196
|
+
|
|
1197
|
+
Run: `node --test tests/routes/menus.test.js`
|
|
1198
|
+
|
|
1199
|
+
Expected: import failures referencing the missing routes module.
|
|
1200
|
+
|
|
1201
|
+
- [ ] **Step 8.3: Implement the routes**
|
|
1202
|
+
|
|
1203
|
+
Create `server/routes/api/menus.js`:
|
|
1204
|
+
|
|
1205
|
+
```js
|
|
1206
|
+
/**
|
|
1207
|
+
* Menus API
|
|
1208
|
+
*
|
|
1209
|
+
* GET /api/menus - list (metadata only)
|
|
1210
|
+
* GET /api/menus/:slug - full menu
|
|
1211
|
+
* POST /api/menus - create
|
|
1212
|
+
* PUT /api/menus/:slug - replace
|
|
1213
|
+
* DELETE /api/menus/:slug - delete (409 if mapped to a slot)
|
|
1214
|
+
* POST /api/menus/:slug/duplicate - copy as <slug>-copy
|
|
1215
|
+
*/
|
|
1216
|
+
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
1217
|
+
import {
|
|
1218
|
+
listMenus, getMenu, createMenu, updateMenu, deleteMenu,
|
|
1219
|
+
duplicateMenu, getLocations
|
|
1220
|
+
} from '../../services/menus.js';
|
|
1221
|
+
import * as cache from '../../services/cache/index.js';
|
|
1222
|
+
|
|
1223
|
+
export async function menusRoutes(fastify) {
|
|
1224
|
+
const canRead = {preHandler: [authenticate, requirePermission('menus', 'read')]};
|
|
1225
|
+
const canCreate = {preHandler: [authenticate, requirePermission('menus', 'create')]};
|
|
1226
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('menus', 'update')]};
|
|
1227
|
+
const canDelete = {preHandler: [authenticate, requirePermission('menus', 'delete')]};
|
|
1228
|
+
|
|
1229
|
+
fastify.get('/menus', canRead, async () => listMenus());
|
|
1230
|
+
|
|
1231
|
+
fastify.get('/menus/:slug', canRead, async (request, reply) => {
|
|
1232
|
+
const menu = await getMenu(request.params.slug);
|
|
1233
|
+
if (!menu) return reply.status(404).send({error: 'Menu not found'});
|
|
1234
|
+
return menu;
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
fastify.post('/menus', canCreate, async (request, reply) => {
|
|
1238
|
+
try {
|
|
1239
|
+
const menu = await createMenu(request.body || {});
|
|
1240
|
+
await cache.invalidateTags([`menu:${menu.slug}`, 'nav']);
|
|
1241
|
+
return reply.status(201).send(menu);
|
|
1242
|
+
} catch (err) {
|
|
1243
|
+
const status = /already exists/.test(err.message) ? 409 : 400;
|
|
1244
|
+
return reply.status(status).send({error: err.message});
|
|
1245
|
+
}
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
fastify.put('/menus/:slug', canUpdate, async (request, reply) => {
|
|
1249
|
+
try {
|
|
1250
|
+
const menu = await updateMenu(request.params.slug, request.body || {});
|
|
1251
|
+
await cache.invalidateTags([`menu:${menu.slug}`, 'nav']);
|
|
1252
|
+
return menu;
|
|
1253
|
+
} catch (err) {
|
|
1254
|
+
const status = /not found/.test(err.message) ? 404 : 400;
|
|
1255
|
+
return reply.status(status).send({error: err.message});
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
fastify.delete('/menus/:slug', canDelete, async (request, reply) => {
|
|
1260
|
+
const map = await getLocations();
|
|
1261
|
+
const slot = Object.entries(map).find(([, slug]) => slug === request.params.slug)?.[0];
|
|
1262
|
+
if (slot) {
|
|
1263
|
+
return reply.status(409).send({
|
|
1264
|
+
error: `Cannot delete - menu is mapped to slot "${slot}". Unmap it first.`
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
const ok = await deleteMenu(request.params.slug);
|
|
1268
|
+
if (!ok) return reply.status(404).send({error: 'Menu not found'});
|
|
1269
|
+
await cache.invalidateTags([`menu:${request.params.slug}`, 'nav']);
|
|
1270
|
+
return {success: true};
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
fastify.post('/menus/:slug/duplicate', canCreate, async (request, reply) => {
|
|
1274
|
+
try {
|
|
1275
|
+
const copy = await duplicateMenu(request.params.slug);
|
|
1276
|
+
await cache.invalidateTags([`menu:${copy.slug}`]);
|
|
1277
|
+
return reply.status(201).send(copy);
|
|
1278
|
+
} catch (err) {
|
|
1279
|
+
const status = /not found/.test(err.message) ? 404 : 400;
|
|
1280
|
+
return reply.status(status).send({error: err.message});
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
```
|
|
1285
|
+
|
|
1286
|
+
- [ ] **Step 8.4: Run the route tests**
|
|
1287
|
+
|
|
1288
|
+
Run: `node --test tests/routes/menus.test.js`
|
|
1289
|
+
|
|
1290
|
+
Expected: all pass.
|
|
1291
|
+
|
|
1292
|
+
---
|
|
1293
|
+
|
|
1294
|
+
## Task 9: API routes - `/api/menu-locations/*`
|
|
1295
|
+
|
|
1296
|
+
**Files:**
|
|
1297
|
+
|
|
1298
|
+
- Create: `server/routes/api/menu-locations.js`
|
|
1299
|
+
- Modify: `tests/routes/menus.test.js`
|
|
1300
|
+
|
|
1301
|
+
- [ ] **Step 9.1: Append location-route tests**
|
|
1302
|
+
|
|
1303
|
+
Append to `tests/routes/menus.test.js`:
|
|
1304
|
+
|
|
1305
|
+
```js
|
|
1306
|
+
import {menuLocationsRoutes} from '../../server/routes/api/menu-locations.js';
|
|
1307
|
+
|
|
1308
|
+
async function buildWithLocations() {
|
|
1309
|
+
const app = Fastify();
|
|
1310
|
+
app.register(fastifyJwt, {secret: 'test-secret-only-for-tests-32chars'});
|
|
1311
|
+
app.addHook('preHandler', async (req) => {
|
|
1312
|
+
req.user = {id: 'test-admin', role: 'admin'};
|
|
1313
|
+
});
|
|
1314
|
+
await app.register(async (inst) => {
|
|
1315
|
+
await menusRoutes(inst);
|
|
1316
|
+
await menuLocationsRoutes(inst);
|
|
1317
|
+
}, {prefix: '/api'});
|
|
1318
|
+
return app;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
test('GET /api/menu-locations/registry - includes core slots', async () => {
|
|
1322
|
+
const app = await buildWithLocations();
|
|
1323
|
+
const res = await app.inject({method: 'GET', url: '/api/menu-locations/registry'});
|
|
1324
|
+
assert.equal(res.statusCode, 200);
|
|
1325
|
+
const slots = res.json().map(s => s.slot);
|
|
1326
|
+
assert.ok(slots.includes('navbar'));
|
|
1327
|
+
assert.ok(slots.includes('footer-primary'));
|
|
1328
|
+
await app.close();
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
test('PUT /api/menu-locations - saves valid slot map', async () => {
|
|
1332
|
+
const app = await buildWithLocations();
|
|
1333
|
+
const slug = T();
|
|
1334
|
+
try {
|
|
1335
|
+
await app.inject({method: 'POST', url: '/api/menus', payload: {slug, name: 'A', items: []}});
|
|
1336
|
+
const res = await app.inject({
|
|
1337
|
+
method: 'PUT', url: '/api/menu-locations',
|
|
1338
|
+
payload: {navbar: slug}
|
|
1339
|
+
});
|
|
1340
|
+
assert.equal(res.statusCode, 200);
|
|
1341
|
+
const get = await app.inject({method: 'GET', url: '/api/menu-locations'});
|
|
1342
|
+
assert.equal(get.json().navbar, slug);
|
|
1343
|
+
} finally {
|
|
1344
|
+
await app.inject({method: 'PUT', url: '/api/menu-locations', payload: {}});
|
|
1345
|
+
await deleteMenu(slug);
|
|
1346
|
+
await app.close();
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
test('PUT /api/menu-locations - rejects unknown slot', async () => {
|
|
1351
|
+
const app = await buildWithLocations();
|
|
1352
|
+
const slug = T();
|
|
1353
|
+
try {
|
|
1354
|
+
await app.inject({method: 'POST', url: '/api/menus', payload: {slug, name: 'A', items: []}});
|
|
1355
|
+
const res = await app.inject({
|
|
1356
|
+
method: 'PUT', url: '/api/menu-locations',
|
|
1357
|
+
payload: {'nonexistent-slot': slug}
|
|
1358
|
+
});
|
|
1359
|
+
assert.equal(res.statusCode, 400);
|
|
1360
|
+
} finally {
|
|
1361
|
+
await deleteMenu(slug);
|
|
1362
|
+
await app.close();
|
|
1363
|
+
}
|
|
1364
|
+
});
|
|
1365
|
+
```
|
|
1366
|
+
|
|
1367
|
+
- [ ] **Step 9.2: Run to confirm tests fail**
|
|
1368
|
+
|
|
1369
|
+
Run: `node --test tests/routes/menus.test.js`
|
|
1370
|
+
|
|
1371
|
+
Expected: import failures for `menu-locations.js`.
|
|
1372
|
+
|
|
1373
|
+
- [ ] **Step 9.3: Implement the locations route**
|
|
1374
|
+
|
|
1375
|
+
Create `server/routes/api/menu-locations.js`:
|
|
1376
|
+
|
|
1377
|
+
```js
|
|
1378
|
+
/**
|
|
1379
|
+
* Menu locations API
|
|
1380
|
+
*
|
|
1381
|
+
* GET /api/menu-locations - current slot → menu-slug map
|
|
1382
|
+
* PUT /api/menu-locations - replace the map (validates against registry + menus)
|
|
1383
|
+
* GET /api/menu-locations/registry - list registered slots (core + plugin-contributed)
|
|
1384
|
+
*/
|
|
1385
|
+
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
1386
|
+
import {getLocations, setLocations, getLocationRegistry} from '../../services/menus.js';
|
|
1387
|
+
import * as cache from '../../services/cache/index.js';
|
|
1388
|
+
|
|
1389
|
+
export async function menuLocationsRoutes(fastify) {
|
|
1390
|
+
const canRead = {preHandler: [authenticate, requirePermission('menus', 'read')]};
|
|
1391
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('menus', 'update')]};
|
|
1392
|
+
|
|
1393
|
+
fastify.get('/menu-locations', canRead, async () => getLocations());
|
|
1394
|
+
fastify.get('/menu-locations/registry', canRead, async () => getLocationRegistry());
|
|
1395
|
+
|
|
1396
|
+
fastify.put('/menu-locations', canUpdate, async (request, reply) => {
|
|
1397
|
+
try {
|
|
1398
|
+
await setLocations(request.body || {});
|
|
1399
|
+
await cache.invalidateTags(['menu-locations', 'nav']);
|
|
1400
|
+
return {success: true};
|
|
1401
|
+
} catch (err) {
|
|
1402
|
+
return reply.status(400).send({error: err.message});
|
|
1403
|
+
}
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
```
|
|
1407
|
+
|
|
1408
|
+
- [ ] **Step 9.4: Run all route tests**
|
|
1409
|
+
|
|
1410
|
+
Run: `node --test tests/routes/menus.test.js`
|
|
1411
|
+
|
|
1412
|
+
Expected: all pass.
|
|
1413
|
+
|
|
1414
|
+
---
|
|
1415
|
+
|
|
1416
|
+
## Task 10: Wire routes into `server/server.js`
|
|
1417
|
+
|
|
1418
|
+
**Files:**
|
|
1419
|
+
|
|
1420
|
+
- Modify: `server/server.js`
|
|
1421
|
+
|
|
1422
|
+
- [ ] **Step 10.1: Find where navigation routes are registered**
|
|
1423
|
+
|
|
1424
|
+
Run: `grep -n "navigationRoutes\|navigation" server/server.js`
|
|
1425
|
+
|
|
1426
|
+
Expected: a `register(navigationRoutes, ...)` line and an import.
|
|
1427
|
+
|
|
1428
|
+
- [ ] **Step 10.2: Import + register the new routes**
|
|
1429
|
+
|
|
1430
|
+
In `server/server.js`, add the imports next to the existing route imports:
|
|
1431
|
+
|
|
1432
|
+
```js
|
|
1433
|
+
import {menusRoutes} from './routes/api/menus.js';
|
|
1434
|
+
import {menuLocationsRoutes} from './routes/api/menu-locations.js';
|
|
1435
|
+
```
|
|
1436
|
+
|
|
1437
|
+
Find the navigation route registration block. Immediately after it, add:
|
|
1438
|
+
|
|
1439
|
+
```js
|
|
1440
|
+
await fastify.register(menusRoutes, {prefix: '/api'});
|
|
1441
|
+
await fastify.register(menuLocationsRoutes, {prefix: '/api'});
|
|
1442
|
+
```
|
|
1443
|
+
|
|
1444
|
+
- [ ] **Step 10.3: Verify with curl that the routes are live**
|
|
1445
|
+
|
|
1446
|
+
The server is always running on port 4096. Use a real token from `S.get('auth_token')` (the user can grab one from the browser) or hit the routes with no auth to see a 401 (proves they're routed, not 404).
|
|
1447
|
+
|
|
1448
|
+
Run:
|
|
1449
|
+
```bash
|
|
1450
|
+
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4096/api/menu-locations/registry
|
|
1451
|
+
```
|
|
1452
|
+
|
|
1453
|
+
Expected: `401` (auth required) - confirms the route is registered. If you get `404` the registration didn't take.
|
|
1454
|
+
|
|
1455
|
+
Notify the user that route registration is complete and request they refresh / re-test in the browser if they want a live check.
|
|
1456
|
+
|
|
1457
|
+
---
|
|
1458
|
+
|
|
1459
|
+
## Task 11: Migration service - convert legacy nav + footer
|
|
1460
|
+
|
|
1461
|
+
**Files:**
|
|
1462
|
+
|
|
1463
|
+
- Create: `server/services/menus-migration.js`
|
|
1464
|
+
- Create: `tests/services/menus-migration.test.js`
|
|
1465
|
+
|
|
1466
|
+
The migration is one-shot. Idempotency guard: if `config/menu-locations.json` already exists, it's skipped.
|
|
1467
|
+
|
|
1468
|
+
- [ ] **Step 11.1: Write the migration tests with a temp config directory**
|
|
1469
|
+
|
|
1470
|
+
Create `tests/services/menus-migration.test.js`:
|
|
1471
|
+
|
|
1472
|
+
```js
|
|
1473
|
+
/**
|
|
1474
|
+
* Menus migration - exercises the convert-once flow on a temporary config dir
|
|
1475
|
+
* so the real project config is untouched. Each test points the migration at
|
|
1476
|
+
* its own scratch directory.
|
|
1477
|
+
*/
|
|
1478
|
+
import {test} from 'node:test';
|
|
1479
|
+
import assert from 'node:assert/strict';
|
|
1480
|
+
import fs from 'fs/promises';
|
|
1481
|
+
import path from 'path';
|
|
1482
|
+
import os from 'os';
|
|
1483
|
+
import {runMigration} from '../../server/services/menus-migration.js';
|
|
1484
|
+
|
|
1485
|
+
async function scratch() {
|
|
1486
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'menus-migration-'));
|
|
1487
|
+
return dir;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
test('no-op when menu-locations.json already exists', async () => {
|
|
1491
|
+
const dir = await scratch();
|
|
1492
|
+
await fs.writeFile(path.join(dir, 'menu-locations.json'), '{"navbar":"main"}', 'utf8');
|
|
1493
|
+
const result = await runMigration({configDir: dir});
|
|
1494
|
+
assert.equal(result.migrated, false);
|
|
1495
|
+
assert.equal(result.reason, 'menu-locations.json already present');
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
test('migrates navigation.json + footer.links + writes locations + brand', async () => {
|
|
1499
|
+
const dir = await scratch();
|
|
1500
|
+
await fs.writeFile(path.join(dir, 'navigation.json'), JSON.stringify({
|
|
1501
|
+
brand: {text: 'My Site', url: '/', icon: 'home'},
|
|
1502
|
+
items: [{text: 'Home', url: '/'}, {text: 'About', url: '/about'}],
|
|
1503
|
+
variant: 'transparent', position: 'sticky'
|
|
1504
|
+
}), 'utf8');
|
|
1505
|
+
await fs.writeFile(path.join(dir, 'site.json'), JSON.stringify({
|
|
1506
|
+
title: 'My Site',
|
|
1507
|
+
footer: {
|
|
1508
|
+
copyright: '© 2026',
|
|
1509
|
+
links: [{text: 'Privacy', url: '/privacy'}]
|
|
1510
|
+
}
|
|
1511
|
+
}), 'utf8');
|
|
1512
|
+
|
|
1513
|
+
const result = await runMigration({configDir: dir});
|
|
1514
|
+
assert.equal(result.migrated, true);
|
|
1515
|
+
|
|
1516
|
+
// menus/main.json
|
|
1517
|
+
const main = JSON.parse(await fs.readFile(path.join(dir, 'menus', 'main.json'), 'utf8'));
|
|
1518
|
+
assert.equal(main.slug, 'main');
|
|
1519
|
+
assert.equal(main.items[0].text, 'Home');
|
|
1520
|
+
assert.equal(main.variant, 'transparent');
|
|
1521
|
+
|
|
1522
|
+
// menus/footer.json
|
|
1523
|
+
const footer = JSON.parse(await fs.readFile(path.join(dir, 'menus', 'footer.json'), 'utf8'));
|
|
1524
|
+
assert.equal(footer.slug, 'footer');
|
|
1525
|
+
assert.equal(footer.items[0].text, 'Privacy');
|
|
1526
|
+
|
|
1527
|
+
// brand moved into site.json
|
|
1528
|
+
const site = JSON.parse(await fs.readFile(path.join(dir, 'site.json'), 'utf8'));
|
|
1529
|
+
assert.equal(site.brand.text, 'My Site');
|
|
1530
|
+
assert.deepEqual(site.footer.links, []);
|
|
1531
|
+
assert.equal(site.footer.copyright, '© 2026');
|
|
1532
|
+
|
|
1533
|
+
// menu-locations
|
|
1534
|
+
const loc = JSON.parse(await fs.readFile(path.join(dir, 'menu-locations.json'), 'utf8'));
|
|
1535
|
+
assert.equal(loc.navbar, 'main');
|
|
1536
|
+
assert.equal(loc['footer-primary'], 'footer');
|
|
1537
|
+
|
|
1538
|
+
// .bak preserved
|
|
1539
|
+
const bakExists = await fs.access(path.join(dir, 'navigation.json.bak')).then(() => true).catch(() => false);
|
|
1540
|
+
assert.equal(bakExists, true);
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
test('handles missing footer.links gracefully', async () => {
|
|
1544
|
+
const dir = await scratch();
|
|
1545
|
+
await fs.writeFile(path.join(dir, 'navigation.json'), JSON.stringify({
|
|
1546
|
+
brand: {text: 'X'}, items: [{text: 'Home', url: '/'}]
|
|
1547
|
+
}), 'utf8');
|
|
1548
|
+
await fs.writeFile(path.join(dir, 'site.json'), JSON.stringify({title: 'X'}), 'utf8');
|
|
1549
|
+
|
|
1550
|
+
const result = await runMigration({configDir: dir});
|
|
1551
|
+
assert.equal(result.migrated, true);
|
|
1552
|
+
|
|
1553
|
+
const loc = JSON.parse(await fs.readFile(path.join(dir, 'menu-locations.json'), 'utf8'));
|
|
1554
|
+
assert.equal(loc.navbar, 'main');
|
|
1555
|
+
assert.equal(loc['footer-primary'], undefined);
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
test('handles missing navigation.json gracefully', async () => {
|
|
1559
|
+
const dir = await scratch();
|
|
1560
|
+
await fs.writeFile(path.join(dir, 'site.json'), JSON.stringify({
|
|
1561
|
+
title: 'X',
|
|
1562
|
+
footer: {links: [{text: 'Privacy', url: '/p'}]}
|
|
1563
|
+
}), 'utf8');
|
|
1564
|
+
|
|
1565
|
+
const result = await runMigration({configDir: dir});
|
|
1566
|
+
assert.equal(result.migrated, true);
|
|
1567
|
+
|
|
1568
|
+
const loc = JSON.parse(await fs.readFile(path.join(dir, 'menu-locations.json'), 'utf8'));
|
|
1569
|
+
assert.equal(loc.navbar, undefined);
|
|
1570
|
+
assert.equal(loc['footer-primary'], 'footer');
|
|
1571
|
+
});
|
|
1572
|
+
```
|
|
1573
|
+
|
|
1574
|
+
- [ ] **Step 11.2: Run to confirm tests fail**
|
|
1575
|
+
|
|
1576
|
+
Run: `node --test tests/services/menus-migration.test.js`
|
|
1577
|
+
|
|
1578
|
+
Expected: import error on `menus-migration.js`.
|
|
1579
|
+
|
|
1580
|
+
- [ ] **Step 11.3: Implement the migration**
|
|
1581
|
+
|
|
1582
|
+
Create `server/services/menus-migration.js`:
|
|
1583
|
+
|
|
1584
|
+
```js
|
|
1585
|
+
/**
|
|
1586
|
+
* Menus auto-migration.
|
|
1587
|
+
*
|
|
1588
|
+
* Runs once on server boot. Idempotent - guarded by the existence of
|
|
1589
|
+
* config/menu-locations.json. Converts:
|
|
1590
|
+
* - config/navigation.json → config/menus/main.json + locations.navbar
|
|
1591
|
+
* (brand moves into site.json.brand)
|
|
1592
|
+
* - site.json.footer.links → config/menus/footer.json + locations.footer-primary
|
|
1593
|
+
* (footer.copyright preserved)
|
|
1594
|
+
* The originals are renamed to .bak for one release of read-fallback safety.
|
|
1595
|
+
*/
|
|
1596
|
+
import fs from 'fs/promises';
|
|
1597
|
+
import path from 'path';
|
|
1598
|
+
import {fileURLToPath} from 'url';
|
|
1599
|
+
|
|
1600
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
1601
|
+
const DEFAULT_CONFIG_DIR = path.resolve(path.dirname(__filename), '..', '..', 'config');
|
|
1602
|
+
|
|
1603
|
+
async function exists(p) {
|
|
1604
|
+
try { await fs.access(p); return true; } catch { return false; }
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
async function readJson(p) {
|
|
1608
|
+
const raw = await fs.readFile(p, 'utf8');
|
|
1609
|
+
return JSON.parse(raw);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
async function writeJson(p, data) {
|
|
1613
|
+
await fs.writeFile(p, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/**
|
|
1617
|
+
* Run the migration. Returns `{migrated: boolean, reason?: string}`.
|
|
1618
|
+
*
|
|
1619
|
+
* @param {{configDir?: string}} [opts]
|
|
1620
|
+
*/
|
|
1621
|
+
export async function runMigration(opts = {}) {
|
|
1622
|
+
const configDir = opts.configDir || DEFAULT_CONFIG_DIR;
|
|
1623
|
+
|
|
1624
|
+
// 1. Guard
|
|
1625
|
+
if (await exists(path.join(configDir, 'menu-locations.json'))) {
|
|
1626
|
+
return {migrated: false, reason: 'menu-locations.json already present'};
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
const menusDir = path.join(configDir, 'menus');
|
|
1630
|
+
await fs.mkdir(menusDir, {recursive: true});
|
|
1631
|
+
const locations = {};
|
|
1632
|
+
const now = new Date().toISOString();
|
|
1633
|
+
|
|
1634
|
+
// 2. navigation.json
|
|
1635
|
+
const navPath = path.join(configDir, 'navigation.json');
|
|
1636
|
+
if (await exists(navPath)) {
|
|
1637
|
+
const nav = await readJson(navPath);
|
|
1638
|
+
const mainMenu = {
|
|
1639
|
+
slug: 'main',
|
|
1640
|
+
name: 'Main navigation',
|
|
1641
|
+
description: 'Migrated from config/navigation.json',
|
|
1642
|
+
...(nav.variant != null && {variant: nav.variant}),
|
|
1643
|
+
...(nav.position != null && {position: nav.position}),
|
|
1644
|
+
...(nav.style != null && {style: nav.style}),
|
|
1645
|
+
items: Array.isArray(nav.items) ? nav.items : [],
|
|
1646
|
+
meta: {createdAt: now, updatedAt: now, bundled: false, presetOwner: null}
|
|
1647
|
+
};
|
|
1648
|
+
await writeJson(path.join(menusDir, 'main.json'), mainMenu);
|
|
1649
|
+
locations.navbar = 'main';
|
|
1650
|
+
|
|
1651
|
+
if (nav.brand) {
|
|
1652
|
+
const sitePath = path.join(configDir, 'site.json');
|
|
1653
|
+
const site = (await exists(sitePath)) ? await readJson(sitePath) : {};
|
|
1654
|
+
site.brand = nav.brand;
|
|
1655
|
+
await writeJson(sitePath, site);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
await fs.rename(navPath, navPath + '.bak');
|
|
1659
|
+
console.log(`[menus] Migrated navigation.json → menus/main.json + locations.navbar`);
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// 3. site.json footer.links
|
|
1663
|
+
const sitePath = path.join(configDir, 'site.json');
|
|
1664
|
+
if (await exists(sitePath)) {
|
|
1665
|
+
const site = await readJson(sitePath);
|
|
1666
|
+
const links = site.footer?.links;
|
|
1667
|
+
if (Array.isArray(links) && links.length) {
|
|
1668
|
+
const footerMenu = {
|
|
1669
|
+
slug: 'footer',
|
|
1670
|
+
name: 'Footer',
|
|
1671
|
+
description: 'Migrated from site.json footer.links',
|
|
1672
|
+
items: links.map(l => ({
|
|
1673
|
+
text: l.text || '',
|
|
1674
|
+
url: l.url || '/',
|
|
1675
|
+
...(l.hidden && {hidden: true})
|
|
1676
|
+
})),
|
|
1677
|
+
meta: {createdAt: now, updatedAt: now, bundled: false, presetOwner: null}
|
|
1678
|
+
};
|
|
1679
|
+
await writeJson(path.join(menusDir, 'footer.json'), footerMenu);
|
|
1680
|
+
locations['footer-primary'] = 'footer';
|
|
1681
|
+
site.footer = {...site.footer, links: []};
|
|
1682
|
+
await writeJson(sitePath, site);
|
|
1683
|
+
console.log(`[menus] Migrated footer.links → menus/footer.json + locations.footer-primary`);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// 4. Write the locations map (even if empty - its existence is the migration guard)
|
|
1688
|
+
await writeJson(path.join(configDir, 'menu-locations.json'), locations);
|
|
1689
|
+
|
|
1690
|
+
return {migrated: true};
|
|
1691
|
+
}
|
|
1692
|
+
```
|
|
1693
|
+
|
|
1694
|
+
- [ ] **Step 11.4: Run the migration tests**
|
|
1695
|
+
|
|
1696
|
+
Run: `node --test tests/services/menus-migration.test.js`
|
|
1697
|
+
|
|
1698
|
+
Expected: all four tests pass.
|
|
1699
|
+
|
|
1700
|
+
---
|
|
1701
|
+
|
|
1702
|
+
## Task 12: Wire migration into server startup
|
|
1703
|
+
|
|
1704
|
+
**Files:**
|
|
1705
|
+
|
|
1706
|
+
- Modify: `server/server.js`
|
|
1707
|
+
|
|
1708
|
+
- [ ] **Step 12.1: Find a stable insertion point**
|
|
1709
|
+
|
|
1710
|
+
Run: `grep -n "loadConfig\|fastify.register\|getConfig\|await loadRoles" server/server.js | head -20`
|
|
1711
|
+
|
|
1712
|
+
The migration must run after config-load and before route registration. Common placement is right after the first `loadConfig()` call or alongside `await loadRoles()` (whichever runs at startup).
|
|
1713
|
+
|
|
1714
|
+
- [ ] **Step 12.2: Add the import and call**
|
|
1715
|
+
|
|
1716
|
+
At the top of `server/server.js`, add:
|
|
1717
|
+
|
|
1718
|
+
```js
|
|
1719
|
+
import {runMigration as runMenusMigration} from './services/menus-migration.js';
|
|
1720
|
+
```
|
|
1721
|
+
|
|
1722
|
+
After the existing config / role load calls (and before the menus + menu-locations route registrations from Task 10), add:
|
|
1723
|
+
|
|
1724
|
+
```js
|
|
1725
|
+
try {
|
|
1726
|
+
await runMenusMigration();
|
|
1727
|
+
} catch (err) {
|
|
1728
|
+
fastify.log.warn(`[menus] Migration skipped: ${err.message}`);
|
|
1729
|
+
}
|
|
1730
|
+
```
|
|
1731
|
+
|
|
1732
|
+
If `fastify` isn't in scope at that point, swap to `console.warn`.
|
|
1733
|
+
|
|
1734
|
+
- [ ] **Step 12.3: Verify on a scratch project**
|
|
1735
|
+
|
|
1736
|
+
Don't run the migration against the live config. Instead, on a copy:
|
|
1737
|
+
|
|
1738
|
+
```bash
|
|
1739
|
+
cp -r config /tmp/menus-mig-test
|
|
1740
|
+
node -e "import('./server/services/menus-migration.js').then(m => m.runMigration({configDir: '/tmp/menus-mig-test'}).then(console.log))"
|
|
1741
|
+
ls /tmp/menus-mig-test/menus /tmp/menus-mig-test/menu-locations.json
|
|
1742
|
+
```
|
|
1743
|
+
|
|
1744
|
+
Expected: `menus/main.json`, `menus/footer.json`, `menu-locations.json`, plus `navigation.json.bak`.
|
|
1745
|
+
|
|
1746
|
+
Notify the user - the live config will migrate on next server restart. The server is currently running; restart timing is the user's call.
|
|
1747
|
+
|
|
1748
|
+
---
|
|
1749
|
+
|
|
1750
|
+
## Task 13: Renderer integration - navbar + footer globals
|
|
1751
|
+
|
|
1752
|
+
**Files:**
|
|
1753
|
+
|
|
1754
|
+
- Modify: `server/services/renderer.js`
|
|
1755
|
+
|
|
1756
|
+
- [ ] **Step 13.1: Locate the existing navigation injection**
|
|
1757
|
+
|
|
1758
|
+
Run: `grep -n "getConfig('navigation')\|__CMS_NAV__\|navJson" server/services/renderer.js`
|
|
1759
|
+
|
|
1760
|
+
Expected: a line that reads navigation from config and a line that builds `navJson` from it. Note the line numbers.
|
|
1761
|
+
|
|
1762
|
+
- [ ] **Step 13.2: Replace the navigation-config read with location resolution**
|
|
1763
|
+
|
|
1764
|
+
In `server/services/renderer.js`:
|
|
1765
|
+
|
|
1766
|
+
- Add imports at the top:
|
|
1767
|
+
|
|
1768
|
+
```js
|
|
1769
|
+
import {resolveLocation, getLocations} from './menus.js';
|
|
1770
|
+
```
|
|
1771
|
+
|
|
1772
|
+
- Find the existing line that fetches `navigation` from `getConfig('navigation')` and the `navJson` line. Replace the navigation fetch with:
|
|
1773
|
+
|
|
1774
|
+
```js
|
|
1775
|
+
// Resolve the menu mapped to the navbar slot; fall back to navigation.json.bak
|
|
1776
|
+
// for one release if no menu is mapped (see migration spec).
|
|
1777
|
+
const navMenu = await resolveLocation('navbar', user);
|
|
1778
|
+
const site = getConfig('site') || {};
|
|
1779
|
+
const brand = site.brand || {};
|
|
1780
|
+
const navigation = navMenu
|
|
1781
|
+
? {
|
|
1782
|
+
brand,
|
|
1783
|
+
items: filterHiddenNavItems({items: navMenu.items}).items,
|
|
1784
|
+
variant: navMenu.variant || 'default',
|
|
1785
|
+
position: navMenu.position || 'sticky',
|
|
1786
|
+
...(navMenu.style && {style: navMenu.style})
|
|
1787
|
+
}
|
|
1788
|
+
: await readLegacyNavBackup() || {brand, items: []};
|
|
1789
|
+
```
|
|
1790
|
+
|
|
1791
|
+
- At the bottom of the file (before the final `}` if any), add:
|
|
1792
|
+
|
|
1793
|
+
```js
|
|
1794
|
+
/**
|
|
1795
|
+
* Read config/navigation.json.bak as a one-release fallback when no menu is
|
|
1796
|
+
* mapped to the navbar slot. Returns null when the .bak is missing.
|
|
1797
|
+
*/
|
|
1798
|
+
async function readLegacyNavBackup() {
|
|
1799
|
+
try {
|
|
1800
|
+
const fs = await import('fs/promises');
|
|
1801
|
+
const path = await import('path');
|
|
1802
|
+
const {fileURLToPath} = await import('url');
|
|
1803
|
+
const here = fileURLToPath(import.meta.url);
|
|
1804
|
+
const bak = path.resolve(path.dirname(here), '..', '..', 'config', 'navigation.json.bak');
|
|
1805
|
+
const raw = await fs.readFile(bak, 'utf8');
|
|
1806
|
+
console.warn('[menus] navbar location unmapped - falling back to navigation.json.bak (deprecated)');
|
|
1807
|
+
return JSON.parse(raw);
|
|
1808
|
+
} catch {
|
|
1809
|
+
return null;
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
```
|
|
1813
|
+
|
|
1814
|
+
- [ ] **Step 13.3: Add the footer global injection**
|
|
1815
|
+
|
|
1816
|
+
In `server/services/renderer.js`, find where the existing site config is serialised into the page (look for `__CMS_SITE__` or `siteJson`). Just before that block, build the footer payload:
|
|
1817
|
+
|
|
1818
|
+
```js
|
|
1819
|
+
// Compose window.__CMS_FOOTER__ from the menus mapped to footer-primary / footer-legal.
|
|
1820
|
+
const footerPrimary = await resolveLocation('footer-primary', user);
|
|
1821
|
+
const footerLegal = await resolveLocation('footer-legal', user);
|
|
1822
|
+
const footer = {
|
|
1823
|
+
primary: footerPrimary ? footerPrimary.items : [],
|
|
1824
|
+
legal: footerLegal ? footerLegal.items : [],
|
|
1825
|
+
copyright: site.footer?.copyright || ''
|
|
1826
|
+
};
|
|
1827
|
+
const footerJson = JSON.stringify(footer).replace(/<\/script>/gi, '<\\/script>');
|
|
1828
|
+
```
|
|
1829
|
+
|
|
1830
|
+
Inject the global by adding to the existing template placeholder substitution (find where `__CMS_NAV__` is injected - typically `templates/page.html` has a `{{footerScript}}` slot or similar). If the renderer currently uses string substitution, add:
|
|
1831
|
+
|
|
1832
|
+
```js
|
|
1833
|
+
html = html.replace('{{footerScript}}', `<script>window.__CMS_FOOTER__ = ${footerJson};</script>`);
|
|
1834
|
+
```
|
|
1835
|
+
|
|
1836
|
+
If `{{footerScript}}` does not yet exist in the page template, jump to Step 13.4 first.
|
|
1837
|
+
|
|
1838
|
+
- [ ] **Step 13.4: Add the placeholder to the page shell template**
|
|
1839
|
+
|
|
1840
|
+
Open `server/templates/page.html`. Find the line that has `{{navScript}}` or `window.__CMS_NAV__`. Immediately after it, add:
|
|
1841
|
+
|
|
1842
|
+
```html
|
|
1843
|
+
{{footerScript}}
|
|
1844
|
+
```
|
|
1845
|
+
|
|
1846
|
+
- [ ] **Step 13.5: Smoke-test in the browser**
|
|
1847
|
+
|
|
1848
|
+
The server is always running. Open the public homepage in the user's browser (`http://192.168.1.4:4096/`) and:
|
|
1849
|
+
|
|
1850
|
+
- **Hard-refresh** so the new page shell is fetched.
|
|
1851
|
+
- Open DevTools console → type `window.__CMS_FOOTER__`. Should print `{primary: [...], legal: [...], copyright: "…"}`.
|
|
1852
|
+
- Type `window.__CMS_NAV__`. Should print the composed navbar with the migrated `main` menu items (assuming migration has run) and `brand` from site.json.
|
|
1853
|
+
|
|
1854
|
+
Notify the user with explicit limits - note that this is a real browser load. If you cannot reach the browser yourself, state that clearly and ask the user to do the check.
|
|
1855
|
+
|
|
1856
|
+
---
|
|
1857
|
+
|
|
1858
|
+
## Task 14: `[menu]` shortcode
|
|
1859
|
+
|
|
1860
|
+
**Files:**
|
|
1861
|
+
|
|
1862
|
+
- Modify: `server/services/markdown.js`
|
|
1863
|
+
- Create: `tests/services/menus-shortcode.test.js`
|
|
1864
|
+
|
|
1865
|
+
The shortcode is wired through the same `registerShortcode` registry as `[view]`, `[block]`, `[collection]`. It's self-closing (`[menu slug="…" /]`) and resolves visibility from the request's user (passed in via `context`).
|
|
1866
|
+
|
|
1867
|
+
- [ ] **Step 14.1: Find the existing shortcode registration pattern**
|
|
1868
|
+
|
|
1869
|
+
Run: `grep -n "registerShortcode\|getShortcodeProcessors\|context\.user\|context\.parseShortcodeAttrs" server/services/markdown.js | head -20`
|
|
1870
|
+
|
|
1871
|
+
Look at how `[view]` or `[block]` is registered - copy that shape exactly. If they're inlined in `markdown.js` (not through `registerShortcode`), follow the inline pattern.
|
|
1872
|
+
|
|
1873
|
+
- [ ] **Step 14.2: Write the shortcode tests**
|
|
1874
|
+
|
|
1875
|
+
Create `tests/services/menus-shortcode.test.js`:
|
|
1876
|
+
|
|
1877
|
+
```js
|
|
1878
|
+
/**
|
|
1879
|
+
* [menu] shortcode - renders by slug or location, respects visibility,
|
|
1880
|
+
* outputs nested <ul> markup.
|
|
1881
|
+
*/
|
|
1882
|
+
import {test} from 'node:test';
|
|
1883
|
+
import assert from 'node:assert/strict';
|
|
1884
|
+
import {parseMarkdown} from '../../server/services/markdown.js';
|
|
1885
|
+
import {
|
|
1886
|
+
createMenu, deleteMenu, setLocations, getLocations
|
|
1887
|
+
} from '../../server/services/menus.js';
|
|
1888
|
+
|
|
1889
|
+
const T = () => 'mdsc-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
|
|
1890
|
+
|
|
1891
|
+
test('[menu slug=...] renders the menu items as <ul>', async () => {
|
|
1892
|
+
const slug = T();
|
|
1893
|
+
await createMenu({
|
|
1894
|
+
slug, name: 'A',
|
|
1895
|
+
items: [
|
|
1896
|
+
{text: 'Home', url: '/'},
|
|
1897
|
+
{text: 'About', url: '/about'}
|
|
1898
|
+
]
|
|
1899
|
+
});
|
|
1900
|
+
try {
|
|
1901
|
+
const md = `Hello\n\n[menu slug="${slug}" /]`;
|
|
1902
|
+
const html = await parseMarkdown(md, {});
|
|
1903
|
+
assert.match(html, /class="dm-menu/);
|
|
1904
|
+
assert.match(html, /href="\/about"/);
|
|
1905
|
+
assert.match(html, /<ul/);
|
|
1906
|
+
} finally {
|
|
1907
|
+
await deleteMenu(slug);
|
|
1908
|
+
}
|
|
1909
|
+
});
|
|
1910
|
+
|
|
1911
|
+
test('[menu location=...] renders the menu mapped to that slot', async () => {
|
|
1912
|
+
const slug = T();
|
|
1913
|
+
await createMenu({slug, name: 'A', items: [{text: 'Top', url: '/t'}]});
|
|
1914
|
+
try {
|
|
1915
|
+
await setLocations({navbar: slug});
|
|
1916
|
+
const html = await parseMarkdown(`[menu location="navbar" /]`, {});
|
|
1917
|
+
assert.match(html, /href="\/t"/);
|
|
1918
|
+
} finally {
|
|
1919
|
+
await setLocations({});
|
|
1920
|
+
await deleteMenu(slug);
|
|
1921
|
+
}
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
test('[menu] omits hidden items', async () => {
|
|
1925
|
+
const slug = T();
|
|
1926
|
+
await createMenu({
|
|
1927
|
+
slug, name: 'A',
|
|
1928
|
+
items: [{text: 'Visible', url: '/v'}, {text: 'Hidden', url: '/h', hidden: true}]
|
|
1929
|
+
});
|
|
1930
|
+
try {
|
|
1931
|
+
const html = await parseMarkdown(`[menu slug="${slug}" /]`, {});
|
|
1932
|
+
assert.match(html, /Visible/);
|
|
1933
|
+
assert.doesNotMatch(html, /Hidden/);
|
|
1934
|
+
} finally {
|
|
1935
|
+
await deleteMenu(slug);
|
|
1936
|
+
}
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
test('[menu] depth attr caps render depth', async () => {
|
|
1940
|
+
const slug = T();
|
|
1941
|
+
await createMenu({
|
|
1942
|
+
slug, name: 'A',
|
|
1943
|
+
items: [{text: 'Top', url: '/t', items: [{text: 'Mid', url: '/m', items: [{text: 'Deep', url: '/d'}]}]}]
|
|
1944
|
+
});
|
|
1945
|
+
try {
|
|
1946
|
+
const html = await parseMarkdown(`[menu slug="${slug}" depth="2" /]`, {});
|
|
1947
|
+
assert.match(html, /Top/);
|
|
1948
|
+
assert.match(html, /Mid/);
|
|
1949
|
+
assert.doesNotMatch(html, /Deep/);
|
|
1950
|
+
} finally {
|
|
1951
|
+
await deleteMenu(slug);
|
|
1952
|
+
}
|
|
1953
|
+
});
|
|
1954
|
+
|
|
1955
|
+
test('[menu] missing slug + location → empty render with no error', async () => {
|
|
1956
|
+
const html = await parseMarkdown(`[menu /]`, {});
|
|
1957
|
+
assert.doesNotMatch(html, /<ul/);
|
|
1958
|
+
});
|
|
1959
|
+
```
|
|
1960
|
+
|
|
1961
|
+
If `parseMarkdown` isn't the actual export name, run `grep -n "export.*parse\|export.*render" server/services/markdown.js` to find the right one - adjust the import.
|
|
1962
|
+
|
|
1963
|
+
- [ ] **Step 14.3: Run to confirm tests fail**
|
|
1964
|
+
|
|
1965
|
+
Run: `node --test tests/services/menus-shortcode.test.js`
|
|
1966
|
+
|
|
1967
|
+
Expected: failures - the `[menu]` shortcode renders the literal text or empty, not the `<ul>`.
|
|
1968
|
+
|
|
1969
|
+
- [ ] **Step 14.4: Implement the shortcode**
|
|
1970
|
+
|
|
1971
|
+
In `server/services/markdown.js`, find the section where other self-closing shortcodes like `[view]` or `[block]` are processed. Add a sibling processor for `[menu]`:
|
|
1972
|
+
|
|
1973
|
+
```js
|
|
1974
|
+
/**
|
|
1975
|
+
* Replace [menu slug="..." /] / [menu location="..." /] with rendered HTML.
|
|
1976
|
+
* Self-closing only. Visibility filtering uses the request user from `context.user`.
|
|
1977
|
+
*/
|
|
1978
|
+
async function processMenuShortcode(markdown, context) {
|
|
1979
|
+
const re = /\[menu(\s+[^\]]*?)\s*\/\]/gi;
|
|
1980
|
+
const matches = [...markdown.matchAll(re)];
|
|
1981
|
+
if (!matches.length) return markdown;
|
|
1982
|
+
|
|
1983
|
+
const {resolveLocation, getMenu} = await import('./menus.js');
|
|
1984
|
+
let out = markdown;
|
|
1985
|
+
for (const m of matches) {
|
|
1986
|
+
const attrs = context.parseShortcodeAttrs(m[1] || '');
|
|
1987
|
+
let menu = null;
|
|
1988
|
+
if (attrs.slug) menu = await getMenu(attrs.slug);
|
|
1989
|
+
else if (attrs.location) menu = await resolveLocation(attrs.location, context.user || null);
|
|
1990
|
+
if (!menu) { out = out.replace(m[0], ''); continue; }
|
|
1991
|
+
|
|
1992
|
+
// For [menu slug=...] we still need to filter by user - resolveLocation
|
|
1993
|
+
// does that for us, but the slug path does its own filtering here.
|
|
1994
|
+
const items = attrs.slug
|
|
1995
|
+
? filterMenuForUser(menu.items || [], context.user || null)
|
|
1996
|
+
: menu.items;
|
|
1997
|
+
|
|
1998
|
+
const depth = Number.parseInt(attrs.depth, 10);
|
|
1999
|
+
const capped = Number.isFinite(depth) && depth > 0 ? capDepth(items, depth) : items;
|
|
2000
|
+
const klass = attrs.class ? ` ${context.escapeAttr(attrs.class)}` : '';
|
|
2001
|
+
const variantClass = attrs.variant ? ` dm-menu--variant-${context.escapeAttr(attrs.variant)}` : '';
|
|
2002
|
+
const html = `<nav class="dm-menu dm-menu--${context.escapeAttr(menu.slug)}${variantClass}${klass}" data-menu="${context.escapeAttr(menu.slug)}">${renderItemsAsUl(capped, context)}</nav>`;
|
|
2003
|
+
out = out.replace(m[0], html);
|
|
2004
|
+
}
|
|
2005
|
+
return out;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
function renderItemsAsUl(items, context) {
|
|
2009
|
+
if (!items.length) return '';
|
|
2010
|
+
const lis = items.map(it => {
|
|
2011
|
+
const href = context.escapeAttr(it.url || '#');
|
|
2012
|
+
const text = context.escapeAttr(it.text || '');
|
|
2013
|
+
const child = Array.isArray(it.items) && it.items.length ? renderItemsAsUl(it.items, context) : '';
|
|
2014
|
+
return `<li><a href="${href}">${text}</a>${child}</li>`;
|
|
2015
|
+
}).join('');
|
|
2016
|
+
return `<ul>${lis}</ul>`;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
function capDepth(items, max, depth = 1) {
|
|
2020
|
+
return items.map(it => ({
|
|
2021
|
+
...it,
|
|
2022
|
+
items: depth < max && Array.isArray(it.items) ? capDepth(it.items, max, depth + 1) : []
|
|
2023
|
+
}));
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
function filterMenuForUser(items, user) {
|
|
2027
|
+
// Local mirror of resolveLocation's filter - used when looking up by slug
|
|
2028
|
+
// directly (where we already have the menu but still need to gate).
|
|
2029
|
+
const out = [];
|
|
2030
|
+
for (const item of items) {
|
|
2031
|
+
if (item.hidden) continue;
|
|
2032
|
+
if (item.visibility != null) {
|
|
2033
|
+
const role = user?.role || null;
|
|
2034
|
+
if (!_checkVisibility(role, item.visibility)) continue;
|
|
2035
|
+
}
|
|
2036
|
+
out.push({...item, items: Array.isArray(item.items) ? filterMenuForUser(item.items, user) : []});
|
|
2037
|
+
}
|
|
2038
|
+
return out;
|
|
2039
|
+
}
|
|
2040
|
+
```
|
|
2041
|
+
|
|
2042
|
+
At the top of `server/services/markdown.js`, add the visibility import:
|
|
2043
|
+
|
|
2044
|
+
```js
|
|
2045
|
+
import {checkVisibility as _checkVisibility} from '../middleware/auth.js';
|
|
2046
|
+
```
|
|
2047
|
+
|
|
2048
|
+
- [ ] **Step 14.5: Wire processMenuShortcode into the main pipeline**
|
|
2049
|
+
|
|
2050
|
+
Find the main `parseMarkdown` (or equivalent) function that orchestrates the shortcode processors. Add a call to `processMenuShortcode` alongside the other self-closing shortcode calls (after `[block]` / `[view]` / `[collection]` per the docs' "Shortcode Nesting Rules"):
|
|
2051
|
+
|
|
2052
|
+
```js
|
|
2053
|
+
markdown = await processMenuShortcode(markdown, context);
|
|
2054
|
+
```
|
|
2055
|
+
|
|
2056
|
+
Make sure `context` includes `parseShortcodeAttrs`, `escapeAttr`, and the request `user` - the existing shortcode pipeline already populates these (see how `[view]` accesses them).
|
|
2057
|
+
|
|
2058
|
+
- [ ] **Step 14.6: Run the shortcode tests**
|
|
2059
|
+
|
|
2060
|
+
Run: `node --test tests/services/menus-shortcode.test.js`
|
|
2061
|
+
|
|
2062
|
+
Expected: all five tests pass.
|
|
2063
|
+
|
|
2064
|
+
---
|
|
2065
|
+
|
|
2066
|
+
## Task 15: Public site footer rendering
|
|
2067
|
+
|
|
2068
|
+
**Files:**
|
|
2069
|
+
|
|
2070
|
+
- Modify: `public/js/site.js`
|
|
2071
|
+
|
|
2072
|
+
- [ ] **Step 15.1: Locate the current footer init**
|
|
2073
|
+
|
|
2074
|
+
Run: `grep -n "footer\|__CMS_FOOTER__\|__CMS_SITE__" public/js/site.js | head -20`
|
|
2075
|
+
|
|
2076
|
+
Note where the site reads existing footer links from `__CMS_SITE__.footer.links`.
|
|
2077
|
+
|
|
2078
|
+
- [ ] **Step 15.2: Read the new footer global and render two columns**
|
|
2079
|
+
|
|
2080
|
+
Replace the current footer init logic with:
|
|
2081
|
+
|
|
2082
|
+
```js
|
|
2083
|
+
// Footer: read window.__CMS_FOOTER__ for primary + legal columns and
|
|
2084
|
+
// window.__CMS_SITE__.footer.copyright for the copyright line.
|
|
2085
|
+
(function initFooter() {
|
|
2086
|
+
const footerEl = $('#site-footer').get(0);
|
|
2087
|
+
if (!footerEl) return;
|
|
2088
|
+
|
|
2089
|
+
const data = window.__CMS_FOOTER__ || {primary: [], legal: [], copyright: ''};
|
|
2090
|
+
|
|
2091
|
+
function itemHtml(it) {
|
|
2092
|
+
const children = (it.items || []).map(c =>
|
|
2093
|
+
`<li><a href="${c.url || '#'}">${c.text || ''}</a></li>`).join('');
|
|
2094
|
+
return `<li><a href="${it.url || '#'}">${it.text || ''}</a>${children ? `<ul>${children}</ul>` : ''}</li>`;
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
const primaryHtml = data.primary.length
|
|
2098
|
+
? `<nav class="footer-nav footer-nav--primary"><ul>${data.primary.map(itemHtml).join('')}</ul></nav>`
|
|
2099
|
+
: '';
|
|
2100
|
+
const legalHtml = data.legal.length
|
|
2101
|
+
? `<nav class="footer-nav footer-nav--legal"><ul>${data.legal.map(itemHtml).join('')}</ul></nav>`
|
|
2102
|
+
: '';
|
|
2103
|
+
const copyrightHtml = data.copyright
|
|
2104
|
+
? `<div class="footer-copyright">${data.copyright}</div>`
|
|
2105
|
+
: '';
|
|
2106
|
+
|
|
2107
|
+
$(footerEl).html(`${primaryHtml}${legalHtml}${copyrightHtml}`);
|
|
2108
|
+
Domma.icons.scan(footerEl);
|
|
2109
|
+
})();
|
|
2110
|
+
```
|
|
2111
|
+
|
|
2112
|
+
If the existing markup wraps the footer in different containers, preserve that structure - just point the nav HTML at `data.primary` and `data.legal`.
|
|
2113
|
+
|
|
2114
|
+
- [ ] **Step 15.3: Browser smoke-test**
|
|
2115
|
+
|
|
2116
|
+
Hard-refresh the homepage in the browser. Confirm:
|
|
2117
|
+
|
|
2118
|
+
- The footer renders the migrated `footer` menu's items in the primary column.
|
|
2119
|
+
- If no `footer-legal` mapping, the legal column is absent.
|
|
2120
|
+
- Copyright line still renders from `site.json.footer.copyright`.
|
|
2121
|
+
|
|
2122
|
+
This is the second UI checkpoint - call out browser limitations explicitly if you can't reach it yourself.
|
|
2123
|
+
|
|
2124
|
+
---
|
|
2125
|
+
|
|
2126
|
+
## Task 16: Scaffolder integration - `menus` recipe branch
|
|
2127
|
+
|
|
2128
|
+
**Files:**
|
|
2129
|
+
|
|
2130
|
+
- Modify: `server/services/scaffolder.js`
|
|
2131
|
+
- Modify: `tests/services/scaffolder.test.js`
|
|
2132
|
+
|
|
2133
|
+
- [ ] **Step 16.1: Write the menus-recipe test**
|
|
2134
|
+
|
|
2135
|
+
Append to `tests/services/scaffolder.test.js`:
|
|
2136
|
+
|
|
2137
|
+
```js
|
|
2138
|
+
test('applyRecipe - menus branch creates menus and updates locations', async () => {
|
|
2139
|
+
const menuSlug = 'test-recipe-menu-' + Date.now();
|
|
2140
|
+
// Stash a minimal one-off recipe by writing to RECIPES_DIR temporarily.
|
|
2141
|
+
const tempRecipe = {
|
|
2142
|
+
slug: 'test-menu-recipe',
|
|
2143
|
+
name: 'Test menu recipe',
|
|
2144
|
+
description: 'Scaffolder menu integration test',
|
|
2145
|
+
icon: 'menu',
|
|
2146
|
+
options: [{name: 'menuSlug', label: 'Menu slug', default: menuSlug}],
|
|
2147
|
+
menus: [
|
|
2148
|
+
{
|
|
2149
|
+
slug: '{{menuSlug}}',
|
|
2150
|
+
name: 'Recipe-created',
|
|
2151
|
+
items: [{text: 'Home', url: '/'}]
|
|
2152
|
+
}
|
|
2153
|
+
]
|
|
2154
|
+
};
|
|
2155
|
+
const tempPath = path.join(RECIPES_DIR, 'test-menu-recipe.json');
|
|
2156
|
+
await fs.writeFile(tempPath, JSON.stringify(tempRecipe), 'utf8');
|
|
2157
|
+
try {
|
|
2158
|
+
const result = await applyRecipe('test-menu-recipe', {options: {menuSlug}});
|
|
2159
|
+
assert.ok(result.created.menus.includes(menuSlug));
|
|
2160
|
+
const {getMenu, deleteMenu} = await import('../../server/services/menus.js');
|
|
2161
|
+
const menu = await getMenu(menuSlug);
|
|
2162
|
+
assert.ok(menu);
|
|
2163
|
+
assert.equal(menu.name, 'Recipe-created');
|
|
2164
|
+
await deleteMenu(menuSlug);
|
|
2165
|
+
} finally {
|
|
2166
|
+
await fs.unlink(tempPath).catch(() => {});
|
|
2167
|
+
}
|
|
2168
|
+
});
|
|
2169
|
+
```
|
|
2170
|
+
|
|
2171
|
+
- [ ] **Step 16.2: Run to confirm the test fails**
|
|
2172
|
+
|
|
2173
|
+
Run: `node --test tests/services/scaffolder.test.js`
|
|
2174
|
+
|
|
2175
|
+
Expected: failure - `result.created.menus` is undefined.
|
|
2176
|
+
|
|
2177
|
+
- [ ] **Step 16.3: Implement the menus branch**
|
|
2178
|
+
|
|
2179
|
+
In `server/services/scaffolder.js`:
|
|
2180
|
+
|
|
2181
|
+
- Add the import near the existing imports:
|
|
2182
|
+
|
|
2183
|
+
```js
|
|
2184
|
+
import {createMenu as createMenuFile, getMenu as getMenuFile, getLocations as getMenuLocations, setLocations as setMenuLocations} from './menus.js';
|
|
2185
|
+
```
|
|
2186
|
+
|
|
2187
|
+
- In `preflight()`, after the actions loop and before the final `return conflicts;`, add:
|
|
2188
|
+
|
|
2189
|
+
```js
|
|
2190
|
+
for (const menu of (resolvedRecipe.menus || [])) {
|
|
2191
|
+
if (!menu.slug) continue;
|
|
2192
|
+
if (await getMenuFile(menu.slug)) {
|
|
2193
|
+
conflicts.push(`Menu "${menu.slug}" already exists`);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
```
|
|
2197
|
+
|
|
2198
|
+
- In `applyRecipe()`, initialise `created.menus = []` next to the existing `roles: []` / `users: []` array fields.
|
|
2199
|
+
|
|
2200
|
+
- Add the menus apply step between the **Roles** step (block `// 0. Roles`) and the **Collection** step (block `// 1. Collection`):
|
|
2201
|
+
|
|
2202
|
+
```js
|
|
2203
|
+
// 0b. Menus - write each declared menu and, if requested, map it to a slot.
|
|
2204
|
+
for (const menu of (resolved.menus || [])) {
|
|
2205
|
+
if (!menu.slug) continue;
|
|
2206
|
+
try {
|
|
2207
|
+
await createMenuFile({
|
|
2208
|
+
slug: menu.slug,
|
|
2209
|
+
name: menu.name || menu.slug,
|
|
2210
|
+
description: menu.description || '',
|
|
2211
|
+
...(menu.variant != null && {variant: menu.variant}),
|
|
2212
|
+
...(menu.position != null && {position: menu.position}),
|
|
2213
|
+
...(menu.style != null && {style: menu.style}),
|
|
2214
|
+
items: Array.isArray(menu.items) ? menu.items : [],
|
|
2215
|
+
meta: {bundled: true, presetOwner: recipeSlug}
|
|
2216
|
+
});
|
|
2217
|
+
created.menus.push(menu.slug);
|
|
2218
|
+
|
|
2219
|
+
if (Array.isArray(menu.locations) && menu.locations.length) {
|
|
2220
|
+
const map = await getMenuLocations();
|
|
2221
|
+
let touched = false;
|
|
2222
|
+
for (const slot of menu.locations) {
|
|
2223
|
+
if (map[slot] && map[slot] !== menu.slug && !menu.force) {
|
|
2224
|
+
warnings.push(`Slot "${slot}" already mapped to "${map[slot]}" - left unchanged (set force:true to overwrite)`);
|
|
2225
|
+
continue;
|
|
2226
|
+
}
|
|
2227
|
+
map[slot] = menu.slug;
|
|
2228
|
+
touched = true;
|
|
2229
|
+
}
|
|
2230
|
+
if (touched) {
|
|
2231
|
+
try { await setMenuLocations(map); } catch (err) {
|
|
2232
|
+
warnings.push(`Locations update failed: ${err.message}`);
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
} catch (err) {
|
|
2237
|
+
warnings.push(`Menu "${menu.slug}" failed: ${err.message}`);
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
```
|
|
2241
|
+
|
|
2242
|
+
- [ ] **Step 16.4: Run the scaffolder tests**
|
|
2243
|
+
|
|
2244
|
+
Run: `node --test tests/services/scaffolder.test.js`
|
|
2245
|
+
|
|
2246
|
+
Expected: every test (including the new menus one) passes.
|
|
2247
|
+
|
|
2248
|
+
---
|
|
2249
|
+
|
|
2250
|
+
## Task 17: Admin api.js client
|
|
2251
|
+
|
|
2252
|
+
**Files:**
|
|
2253
|
+
|
|
2254
|
+
- Modify: `admin/js/api.js`
|
|
2255
|
+
|
|
2256
|
+
- [ ] **Step 17.1: Find the existing client pattern**
|
|
2257
|
+
|
|
2258
|
+
Run: `grep -n "navigation:\|settings:\|api\\.navigation\|export const api" admin/js/api.js | head -10`
|
|
2259
|
+
|
|
2260
|
+
Note the export pattern - the existing `api.navigation = {get, save}` shape is what we mirror.
|
|
2261
|
+
|
|
2262
|
+
- [ ] **Step 17.2: Add menus + menuLocations clients**
|
|
2263
|
+
|
|
2264
|
+
In `admin/js/api.js`, alongside the existing resource clients, add:
|
|
2265
|
+
|
|
2266
|
+
```js
|
|
2267
|
+
// Menus + menu locations (replaces the older single `navigation` client; the
|
|
2268
|
+
// navigation client is kept temporarily so any cached admin code still works
|
|
2269
|
+
// during a release cycle).
|
|
2270
|
+
api.menus = {
|
|
2271
|
+
list: () => H.get('/api/menus'),
|
|
2272
|
+
get: (slug) => H.get('/api/menus/' + encodeURIComponent(slug)),
|
|
2273
|
+
create: (menu) => H.post('/api/menus', menu),
|
|
2274
|
+
update: (slug, menu) => H.put('/api/menus/' + encodeURIComponent(slug), menu),
|
|
2275
|
+
remove: (slug) => H.delete('/api/menus/' + encodeURIComponent(slug)),
|
|
2276
|
+
duplicate: (slug) => H.post('/api/menus/' + encodeURIComponent(slug) + '/duplicate')
|
|
2277
|
+
};
|
|
2278
|
+
|
|
2279
|
+
api.menuLocations = {
|
|
2280
|
+
get: () => H.get('/api/menu-locations'),
|
|
2281
|
+
save: (map) => H.put('/api/menu-locations', map),
|
|
2282
|
+
registry: () => H.get('/api/menu-locations/registry')
|
|
2283
|
+
};
|
|
2284
|
+
```
|
|
2285
|
+
|
|
2286
|
+
If the file uses a single `export const api = {...}` literal rather than mutation, insert the two blocks as sibling properties.
|
|
2287
|
+
|
|
2288
|
+
- [ ] **Step 17.3: Smoke-test the client**
|
|
2289
|
+
|
|
2290
|
+
Add a temporary test page in the browser dev console:
|
|
2291
|
+
|
|
2292
|
+
```js
|
|
2293
|
+
await api.menuLocations.registry()
|
|
2294
|
+
```
|
|
2295
|
+
|
|
2296
|
+
Expected: an array including `navbar`, `footer-primary`, `footer-legal`.
|
|
2297
|
+
|
|
2298
|
+
Notify the user that this requires browser interaction; ask them to run it if you cannot.
|
|
2299
|
+
|
|
2300
|
+
---
|
|
2301
|
+
|
|
2302
|
+
## Task 18: Sidebar + router updates
|
|
2303
|
+
|
|
2304
|
+
**Files:**
|
|
2305
|
+
|
|
2306
|
+
- Modify: `admin/js/config/sidebar-config.js`
|
|
2307
|
+
- Modify: `admin/js/app.js`
|
|
2308
|
+
|
|
2309
|
+
- [ ] **Step 18.1: Rename the Structure entry from Navigation → Menus**
|
|
2310
|
+
|
|
2311
|
+
Open `admin/js/config/sidebar-config.js`. Find the line containing `id: 'navigation', text: 'Navigation'` (around line 49). Replace the entire conditional block:
|
|
2312
|
+
|
|
2313
|
+
```js
|
|
2314
|
+
if (canAny('navigation') || canAny('layouts')) {
|
|
2315
|
+
items.push(
|
|
2316
|
+
{heading: 'Structure'},
|
|
2317
|
+
...(canAny('navigation') ? [{id: 'navigation', text: 'Navigation', icon: 'menu', url: '#/navigation', section: '#/navigation', badge: badge(counts.navigation)}] : []),
|
|
2318
|
+
...(canAny('layouts') ? [{id: 'layouts', text: 'Layouts', icon: 'layout', url: '#/layouts', section: '#/layouts', badge: badge(counts.layouts)}] : []),
|
|
2319
|
+
{divider: true}
|
|
2320
|
+
);
|
|
2321
|
+
}
|
|
2322
|
+
```
|
|
2323
|
+
|
|
2324
|
+
With:
|
|
2325
|
+
|
|
2326
|
+
```js
|
|
2327
|
+
if (canAny('menus') || canAny('navigation') || canAny('layouts')) {
|
|
2328
|
+
items.push(
|
|
2329
|
+
{heading: 'Structure'},
|
|
2330
|
+
...(canAny('menus') ? [{id: 'menus', text: 'Menus', icon: 'menu', url: '#/menus', section: '#/menus', badge: badge(counts.menus)}] : []),
|
|
2331
|
+
...(canAny('layouts') ? [{id: 'layouts', text: 'Layouts', icon: 'layout', url: '#/layouts', section: '#/layouts', badge: badge(counts.layouts)}] : []),
|
|
2332
|
+
{divider: true}
|
|
2333
|
+
);
|
|
2334
|
+
}
|
|
2335
|
+
```
|
|
2336
|
+
|
|
2337
|
+
- [ ] **Step 18.2: Add menu routes + a redirect from /navigation**
|
|
2338
|
+
|
|
2339
|
+
Open `admin/js/app.js`. Find the route table (around line 243 - the `coreRoutes` array). Replace the line:
|
|
2340
|
+
|
|
2341
|
+
```js
|
|
2342
|
+
{ path: '/navigation', view: 'navigation', title: 'Navigation - Domma CMS' },
|
|
2343
|
+
```
|
|
2344
|
+
|
|
2345
|
+
with:
|
|
2346
|
+
|
|
2347
|
+
```js
|
|
2348
|
+
{ path: '/menus', view: 'menus', title: 'Menus - Domma CMS' },
|
|
2349
|
+
{ path: '/menus/new', view: 'menuEditor', title: 'New Menu - Domma CMS' },
|
|
2350
|
+
{ path: '/menus/edit/:slug', view: 'menuEditor', title: 'Edit Menu - Domma CMS' },
|
|
2351
|
+
{ path: '/menu-locations', view: 'menuLocations', title: 'Menu Locations - Domma CMS' },
|
|
2352
|
+
// Backwards-compat redirect for bookmarks; the old navigation view is removed.
|
|
2353
|
+
{ path: '/navigation', view: 'menusRedirect', title: 'Menus - Domma CMS' },
|
|
2354
|
+
```
|
|
2355
|
+
|
|
2356
|
+
- [ ] **Step 18.3: Add the redirect view**
|
|
2357
|
+
|
|
2358
|
+
Open `admin/js/views/index.js`. Add (alongside the other view exports):
|
|
2359
|
+
|
|
2360
|
+
```js
|
|
2361
|
+
export const menusRedirect = {
|
|
2362
|
+
templateUrl: '',
|
|
2363
|
+
async onMount(/* $container */) {
|
|
2364
|
+
location.hash = '#/menus';
|
|
2365
|
+
}
|
|
2366
|
+
};
|
|
2367
|
+
```
|
|
2368
|
+
|
|
2369
|
+
If `index.js` uses a `views` object rather than named exports, add `menusRedirect` as a property.
|
|
2370
|
+
|
|
2371
|
+
- [ ] **Step 18.4: Browser smoke-test**
|
|
2372
|
+
|
|
2373
|
+
Hard-refresh the admin SPA. Click "Menus" in the sidebar - should land on `#/menus`. Manually navigate to `#/navigation` - should redirect to `#/menus`.
|
|
2374
|
+
|
|
2375
|
+
---
|
|
2376
|
+
|
|
2377
|
+
## Task 19: Menus list view
|
|
2378
|
+
|
|
2379
|
+
**Files:**
|
|
2380
|
+
|
|
2381
|
+
- Create: `admin/js/views/menus.js`
|
|
2382
|
+
- Create: `admin/js/templates/menus.html`
|
|
2383
|
+
- Modify: `admin/js/views/index.js`
|
|
2384
|
+
|
|
2385
|
+
- [ ] **Step 19.1: Create the list template**
|
|
2386
|
+
|
|
2387
|
+
Create `admin/js/templates/menus.html`:
|
|
2388
|
+
|
|
2389
|
+
```html
|
|
2390
|
+
<div class="page-header">
|
|
2391
|
+
<h2><span data-icon="menu"></span> Menus</h2>
|
|
2392
|
+
<div class="header-actions">
|
|
2393
|
+
<a class="btn btn-secondary" href="#/menu-locations"><span data-icon="map"></span> Manage locations</a>
|
|
2394
|
+
<a class="btn btn-primary" href="#/menus/new"><span data-icon="plus"></span> New menu</a>
|
|
2395
|
+
</div>
|
|
2396
|
+
</div>
|
|
2397
|
+
<div id="menus-table"></div>
|
|
2398
|
+
```
|
|
2399
|
+
|
|
2400
|
+
- [ ] **Step 19.2: Create the view**
|
|
2401
|
+
|
|
2402
|
+
Create `admin/js/views/menus.js`:
|
|
2403
|
+
|
|
2404
|
+
```js
|
|
2405
|
+
import {api} from '../api.js';
|
|
2406
|
+
|
|
2407
|
+
export const menusView = {
|
|
2408
|
+
templateUrl: '/admin/js/templates/menus.html',
|
|
2409
|
+
async onMount($container) {
|
|
2410
|
+
let [menus, locations] = await Promise.all([
|
|
2411
|
+
api.menus.list().catch(() => []),
|
|
2412
|
+
api.menuLocations.get().catch(() => ({}))
|
|
2413
|
+
]);
|
|
2414
|
+
|
|
2415
|
+
// Build slug → [slot, slot] map for the "Mapped" column
|
|
2416
|
+
const mappedBy = {};
|
|
2417
|
+
for (const [slot, slug] of Object.entries(locations)) {
|
|
2418
|
+
if (!mappedBy[slug]) mappedBy[slug] = [];
|
|
2419
|
+
mappedBy[slug].push(slot);
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
T.create('#menus-table', {
|
|
2423
|
+
data: menus,
|
|
2424
|
+
empty: 'No menus yet. Click "New menu" to create one.',
|
|
2425
|
+
columns: [
|
|
2426
|
+
{key: 'slug', label: 'Slug', sortable: true},
|
|
2427
|
+
{key: 'name', label: 'Name', sortable: true},
|
|
2428
|
+
{key: 'itemsCount', label: 'Items', render: row => String(row.itemsCount ?? 0)},
|
|
2429
|
+
{key: 'mapped', label: 'Mapped', render: row => {
|
|
2430
|
+
const slots = mappedBy[row.slug] || [];
|
|
2431
|
+
return slots.length
|
|
2432
|
+
? slots.map(s => `<span class="badge badge-info">${s}</span>`).join(' ')
|
|
2433
|
+
: '<span class="text-muted">-</span>';
|
|
2434
|
+
}},
|
|
2435
|
+
{key: 'bundled', label: 'Bundled', render: row =>
|
|
2436
|
+
row.meta?.bundled ? `<span class="badge badge-secondary">bundled</span>` : ''
|
|
2437
|
+
},
|
|
2438
|
+
{key: '_actions', label: 'Actions', render: row => {
|
|
2439
|
+
const mapped = (mappedBy[row.slug] || []).length > 0;
|
|
2440
|
+
const delTip = mapped ? 'data-tooltip="Unmap this menu before deleting"' : '';
|
|
2441
|
+
const delDisabled = mapped ? 'disabled' : '';
|
|
2442
|
+
return `
|
|
2443
|
+
<a class="btn btn-sm btn-ghost" href="#/menus/edit/${encodeURIComponent(row.slug)}"><span data-icon="edit"></span></a>
|
|
2444
|
+
<button class="btn btn-sm btn-ghost btn-duplicate" data-slug="${row.slug}" data-tooltip="Duplicate"><span data-icon="copy"></span></button>
|
|
2445
|
+
<button class="btn btn-sm btn-danger btn-delete" data-slug="${row.slug}" ${delDisabled} ${delTip}><span data-icon="trash"></span></button>
|
|
2446
|
+
`;
|
|
2447
|
+
}}
|
|
2448
|
+
]
|
|
2449
|
+
});
|
|
2450
|
+
|
|
2451
|
+
// Fill in item counts for the rendered rows (list endpoint omits items)
|
|
2452
|
+
// Done after the initial render to keep the table snappy.
|
|
2453
|
+
Domma.icons.scan('#menus-table');
|
|
2454
|
+
|
|
2455
|
+
$container.off('click', '.btn-duplicate').on('click', '.btn-duplicate', async function() {
|
|
2456
|
+
const slug = $(this).data('slug');
|
|
2457
|
+
try {
|
|
2458
|
+
const copy = await api.menus.duplicate(slug);
|
|
2459
|
+
E.toast(`Duplicated as "${copy.slug}".`, {type: 'success'});
|
|
2460
|
+
location.reload();
|
|
2461
|
+
} catch (err) {
|
|
2462
|
+
E.toast(`Duplicate failed: ${err.message || err}`, {type: 'error'});
|
|
2463
|
+
}
|
|
2464
|
+
});
|
|
2465
|
+
|
|
2466
|
+
$container.off('click', '.btn-delete').on('click', '.btn-delete', async function() {
|
|
2467
|
+
const slug = $(this).data('slug');
|
|
2468
|
+
if (!await E.confirm(`Delete menu "${slug}"? This cannot be undone.`)) return;
|
|
2469
|
+
try {
|
|
2470
|
+
await api.menus.remove(slug);
|
|
2471
|
+
E.toast('Menu deleted.', {type: 'success'});
|
|
2472
|
+
location.reload();
|
|
2473
|
+
} catch (err) {
|
|
2474
|
+
E.toast(`Delete failed: ${err.message || err}`, {type: 'error'});
|
|
2475
|
+
}
|
|
2476
|
+
});
|
|
2477
|
+
}
|
|
2478
|
+
};
|
|
2479
|
+
```
|
|
2480
|
+
|
|
2481
|
+
- [ ] **Step 19.3: Export the view**
|
|
2482
|
+
|
|
2483
|
+
In `admin/js/views/index.js`, add:
|
|
2484
|
+
|
|
2485
|
+
```js
|
|
2486
|
+
export {menusView as menus} from './menus.js';
|
|
2487
|
+
```
|
|
2488
|
+
|
|
2489
|
+
(Or add to the central `views` object if that's the pattern in the file.)
|
|
2490
|
+
|
|
2491
|
+
- [ ] **Step 19.4: Browser smoke-test**
|
|
2492
|
+
|
|
2493
|
+
Hard-refresh the admin, navigate to `#/menus`. Confirm:
|
|
2494
|
+
|
|
2495
|
+
- The migrated `main` and `footer` menus are listed.
|
|
2496
|
+
- "Mapped" column shows `navbar` next to `main`, `footer-primary` next to `footer`.
|
|
2497
|
+
- Delete is disabled (with tooltip) for mapped menus.
|
|
2498
|
+
- Duplicate works - creates `main-copy`.
|
|
2499
|
+
|
|
2500
|
+
Browser-test or surface as a blocker per `feedback_no_verified_without_browser.md`.
|
|
2501
|
+
|
|
2502
|
+
---
|
|
2503
|
+
|
|
2504
|
+
## Task 20: Menu editor - Items tab tree editor
|
|
2505
|
+
|
|
2506
|
+
**Files:**
|
|
2507
|
+
|
|
2508
|
+
- Create: `admin/js/views/menu-editor.js`
|
|
2509
|
+
- Create: `admin/js/templates/menu-editor.html`
|
|
2510
|
+
- Modify: `admin/js/views/index.js`
|
|
2511
|
+
- Modify: `admin/css/admin.css`
|
|
2512
|
+
|
|
2513
|
+
This is the heaviest UI piece - a recursive tree editor with indent/outdent buttons.
|
|
2514
|
+
|
|
2515
|
+
- [ ] **Step 20.1: Create the editor template**
|
|
2516
|
+
|
|
2517
|
+
Create `admin/js/templates/menu-editor.html`:
|
|
2518
|
+
|
|
2519
|
+
```html
|
|
2520
|
+
<div class="page-header">
|
|
2521
|
+
<h2><span data-icon="menu"></span> <span id="me-title">Edit menu</span></h2>
|
|
2522
|
+
<div class="header-actions">
|
|
2523
|
+
<a class="btn btn-secondary" href="#/menus"><span data-icon="arrow-left"></span> Back</a>
|
|
2524
|
+
<button class="btn btn-primary" id="me-save"><span data-icon="check"></span> Save</button>
|
|
2525
|
+
</div>
|
|
2526
|
+
</div>
|
|
2527
|
+
|
|
2528
|
+
<div class="tabs" id="me-tabs">
|
|
2529
|
+
<div class="tab-list">
|
|
2530
|
+
<button class="tab-item active" data-tab="me-items">Items</button>
|
|
2531
|
+
<button class="tab-item" data-tab="me-behaviour">Behaviour</button>
|
|
2532
|
+
<button class="tab-item" data-tab="me-meta">Meta</button>
|
|
2533
|
+
</div>
|
|
2534
|
+
<div class="tab-content">
|
|
2535
|
+
<div class="tab-panel active" id="me-items" data-panel="me-items">
|
|
2536
|
+
<div class="toolbar">
|
|
2537
|
+
<button class="btn btn-sm btn-secondary" id="me-add-item"><span data-icon="plus"></span> Add item</button>
|
|
2538
|
+
</div>
|
|
2539
|
+
<div id="me-items-list" class="menu-tree"></div>
|
|
2540
|
+
</div>
|
|
2541
|
+
|
|
2542
|
+
<div class="tab-panel" id="me-behaviour" data-panel="me-behaviour">
|
|
2543
|
+
<div id="me-behaviour-form"></div>
|
|
2544
|
+
</div>
|
|
2545
|
+
|
|
2546
|
+
<div class="tab-panel" id="me-meta" data-panel="me-meta">
|
|
2547
|
+
<div id="me-meta-form"></div>
|
|
2548
|
+
</div>
|
|
2549
|
+
</div>
|
|
2550
|
+
</div>
|
|
2551
|
+
```
|
|
2552
|
+
|
|
2553
|
+
- [ ] **Step 20.2: Create the editor view**
|
|
2554
|
+
|
|
2555
|
+
Create `admin/js/views/menu-editor.js`:
|
|
2556
|
+
|
|
2557
|
+
```js
|
|
2558
|
+
import {api} from '../api.js';
|
|
2559
|
+
|
|
2560
|
+
let _nextId = 1;
|
|
2561
|
+
const uid = () => 'i_' + (_nextId++);
|
|
2562
|
+
|
|
2563
|
+
// Convert the persisted nested-array form into a flat array with parentId
|
|
2564
|
+
// pointers, indexed so the tree renderer is simple recursive iteration.
|
|
2565
|
+
function flatten(items, parentId = null, out = []) {
|
|
2566
|
+
for (const it of (items || [])) {
|
|
2567
|
+
const id = uid();
|
|
2568
|
+
out.push({
|
|
2569
|
+
id, parentId,
|
|
2570
|
+
text: it.text || '', url: it.url || '', icon: it.icon || '',
|
|
2571
|
+
visibility: it.visibility || '',
|
|
2572
|
+
hidden: !!it.hidden, bundled: !!it.bundled
|
|
2573
|
+
});
|
|
2574
|
+
if (Array.isArray(it.items) && it.items.length) {
|
|
2575
|
+
flatten(it.items, id, out);
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
return out;
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
// Re-nest the flat list back into the persisted shape.
|
|
2582
|
+
function nest(flat) {
|
|
2583
|
+
const byParent = new Map();
|
|
2584
|
+
for (const r of flat) {
|
|
2585
|
+
if (!byParent.has(r.parentId)) byParent.set(r.parentId, []);
|
|
2586
|
+
byParent.get(r.parentId).push(r);
|
|
2587
|
+
}
|
|
2588
|
+
function build(parentId) {
|
|
2589
|
+
return (byParent.get(parentId) || []).map(r => {
|
|
2590
|
+
const children = build(r.id);
|
|
2591
|
+
return {
|
|
2592
|
+
text: r.text.trim(),
|
|
2593
|
+
url: r.url.trim(),
|
|
2594
|
+
...(r.icon && {icon: r.icon.trim()}),
|
|
2595
|
+
...(r.visibility && {visibility: r.visibility}),
|
|
2596
|
+
...(r.hidden && {hidden: true}),
|
|
2597
|
+
...(r.bundled && {bundled: true}),
|
|
2598
|
+
...(children.length && {items: children})
|
|
2599
|
+
};
|
|
2600
|
+
});
|
|
2601
|
+
}
|
|
2602
|
+
return build(null);
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
function escapeAttr(s) {
|
|
2606
|
+
return String(s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
function depthOf(id, flat) {
|
|
2610
|
+
let d = 0;
|
|
2611
|
+
let cur = flat.find(r => r.id === id);
|
|
2612
|
+
while (cur && cur.parentId) {
|
|
2613
|
+
d++;
|
|
2614
|
+
cur = flat.find(r => r.id === cur.parentId);
|
|
2615
|
+
}
|
|
2616
|
+
return d;
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
function renderTree($host, flat) {
|
|
2620
|
+
$host.html('');
|
|
2621
|
+
// Render in tree order: parents before children.
|
|
2622
|
+
function walk(parentId) {
|
|
2623
|
+
for (const row of flat.filter(r => r.parentId === parentId)) {
|
|
2624
|
+
const d = depthOf(row.id, flat);
|
|
2625
|
+
$host.append(`
|
|
2626
|
+
<div class="menu-tree-row${row.hidden ? ' menu-tree-row--hidden' : ''}" data-id="${row.id}" style="margin-left:${d * 28}px">
|
|
2627
|
+
<span class="menu-tree-grip" data-icon="grip-vertical"></span>
|
|
2628
|
+
<input type="text" class="form-input me-text" value="${escapeAttr(row.text)}" placeholder="Label">
|
|
2629
|
+
<input type="text" class="form-input me-url" value="${escapeAttr(row.url)}" placeholder="/url">
|
|
2630
|
+
<input type="text" class="form-input me-icon" value="${escapeAttr(row.icon)}" placeholder="icon">
|
|
2631
|
+
<input type="text" class="form-input me-vis" value="${escapeAttr(row.visibility)}" placeholder="visibility">
|
|
2632
|
+
<button class="btn btn-sm btn-ghost me-outdent" data-tooltip="Outdent"><span data-icon="chevron-left"></span></button>
|
|
2633
|
+
<button class="btn btn-sm btn-ghost me-indent" data-tooltip="Indent"><span data-icon="chevron-right"></span></button>
|
|
2634
|
+
<button class="btn btn-sm btn-ghost me-up" data-tooltip="Move up"><span data-icon="arrow-up"></span></button>
|
|
2635
|
+
<button class="btn btn-sm btn-ghost me-down" data-tooltip="Move down"><span data-icon="arrow-down"></span></button>
|
|
2636
|
+
<button class="btn btn-sm btn-ghost me-hidden ${row.hidden ? 'active' : ''}" data-tooltip="${row.hidden ? 'Show' : 'Hide'}"><span data-icon="eye-off"></span></button>
|
|
2637
|
+
<button class="btn btn-sm btn-danger me-remove" data-tooltip="Remove"><span data-icon="trash"></span></button>
|
|
2638
|
+
</div>
|
|
2639
|
+
`);
|
|
2640
|
+
walk(row.id);
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
walk(null);
|
|
2644
|
+
Domma.icons.scan($host.get(0));
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
function syncRowsFromDom($host, flat) {
|
|
2648
|
+
$host.find('.menu-tree-row').each(function() {
|
|
2649
|
+
const id = $(this).data('id');
|
|
2650
|
+
const row = flat.find(r => r.id === id);
|
|
2651
|
+
if (!row) return;
|
|
2652
|
+
row.text = $(this).find('.me-text').val();
|
|
2653
|
+
row.url = $(this).find('.me-url').val();
|
|
2654
|
+
row.icon = $(this).find('.me-icon').val();
|
|
2655
|
+
row.visibility = $(this).find('.me-vis').val();
|
|
2656
|
+
});
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
export const menuEditorView = {
|
|
2660
|
+
templateUrl: '/admin/js/templates/menu-editor.html',
|
|
2661
|
+
async onMount($container) {
|
|
2662
|
+
// Route params: empty slug = "new menu" mode
|
|
2663
|
+
const hashMatch = location.hash.match(/^#\/menus\/edit\/(.+)$/);
|
|
2664
|
+
const slug = hashMatch ? decodeURIComponent(hashMatch[1]) : null;
|
|
2665
|
+
const isNew = !slug;
|
|
2666
|
+
|
|
2667
|
+
let menu;
|
|
2668
|
+
if (isNew) {
|
|
2669
|
+
menu = {slug: '', name: '', description: '', items: []};
|
|
2670
|
+
} else {
|
|
2671
|
+
try {
|
|
2672
|
+
menu = await api.menus.get(slug);
|
|
2673
|
+
} catch (err) {
|
|
2674
|
+
E.toast(`Failed to load menu: ${err.message || err}`, {type: 'error'});
|
|
2675
|
+
location.hash = '#/menus';
|
|
2676
|
+
return;
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
$container.find('#me-title').text(isNew ? 'New menu' : `Edit "${menu.slug}"`);
|
|
2681
|
+
|
|
2682
|
+
let flat = flatten(menu.items);
|
|
2683
|
+
|
|
2684
|
+
// Items tab
|
|
2685
|
+
const $list = $container.find('#me-items-list');
|
|
2686
|
+
renderTree($list, flat);
|
|
2687
|
+
|
|
2688
|
+
$container.find('#me-add-item').on('click', () => {
|
|
2689
|
+
syncRowsFromDom($list, flat);
|
|
2690
|
+
flat.push({id: uid(), parentId: null, text: '', url: '/', icon: '', visibility: '', hidden: false, bundled: false});
|
|
2691
|
+
renderTree($list, flat);
|
|
2692
|
+
});
|
|
2693
|
+
|
|
2694
|
+
$container.off('click', '.me-remove').on('click', '.me-remove', function() {
|
|
2695
|
+
const id = $(this).closest('.menu-tree-row').data('id');
|
|
2696
|
+
flat = flat.filter(r => r.id !== id && r.parentId !== id);
|
|
2697
|
+
renderTree($list, flat);
|
|
2698
|
+
});
|
|
2699
|
+
|
|
2700
|
+
$container.off('click', '.me-indent').on('click', '.me-indent', function() {
|
|
2701
|
+
syncRowsFromDom($list, flat);
|
|
2702
|
+
const id = $(this).closest('.menu-tree-row').data('id');
|
|
2703
|
+
const row = flat.find(r => r.id === id);
|
|
2704
|
+
if (!row) return;
|
|
2705
|
+
// Indent: parent becomes the previous sibling at the same depth (within the same parent).
|
|
2706
|
+
const siblings = flat.filter(r => r.parentId === row.parentId);
|
|
2707
|
+
const idx = siblings.findIndex(r => r.id === id);
|
|
2708
|
+
if (idx <= 0) return; // can't indent first sibling
|
|
2709
|
+
row.parentId = siblings[idx - 1].id;
|
|
2710
|
+
renderTree($list, flat);
|
|
2711
|
+
});
|
|
2712
|
+
|
|
2713
|
+
$container.off('click', '.me-outdent').on('click', '.me-outdent', function() {
|
|
2714
|
+
syncRowsFromDom($list, flat);
|
|
2715
|
+
const id = $(this).closest('.menu-tree-row').data('id');
|
|
2716
|
+
const row = flat.find(r => r.id === id);
|
|
2717
|
+
if (!row || row.parentId == null) return;
|
|
2718
|
+
const parent = flat.find(r => r.id === row.parentId);
|
|
2719
|
+
row.parentId = parent ? parent.parentId : null;
|
|
2720
|
+
renderTree($list, flat);
|
|
2721
|
+
});
|
|
2722
|
+
|
|
2723
|
+
$container.off('click', '.me-up').on('click', '.me-up', function() {
|
|
2724
|
+
syncRowsFromDom($list, flat);
|
|
2725
|
+
const id = $(this).closest('.menu-tree-row').data('id');
|
|
2726
|
+
const siblings = flat.filter(r => r.parentId === flat.find(x => x.id === id)?.parentId);
|
|
2727
|
+
const idx = siblings.findIndex(r => r.id === id);
|
|
2728
|
+
if (idx <= 0) return;
|
|
2729
|
+
// Swap positions inside the master flat array
|
|
2730
|
+
const a = flat.indexOf(siblings[idx]);
|
|
2731
|
+
const b = flat.indexOf(siblings[idx - 1]);
|
|
2732
|
+
[flat[a], flat[b]] = [flat[b], flat[a]];
|
|
2733
|
+
renderTree($list, flat);
|
|
2734
|
+
});
|
|
2735
|
+
|
|
2736
|
+
$container.off('click', '.me-down').on('click', '.me-down', function() {
|
|
2737
|
+
syncRowsFromDom($list, flat);
|
|
2738
|
+
const id = $(this).closest('.menu-tree-row').data('id');
|
|
2739
|
+
const siblings = flat.filter(r => r.parentId === flat.find(x => x.id === id)?.parentId);
|
|
2740
|
+
const idx = siblings.findIndex(r => r.id === id);
|
|
2741
|
+
if (idx === -1 || idx >= siblings.length - 1) return;
|
|
2742
|
+
const a = flat.indexOf(siblings[idx]);
|
|
2743
|
+
const b = flat.indexOf(siblings[idx + 1]);
|
|
2744
|
+
[flat[a], flat[b]] = [flat[b], flat[a]];
|
|
2745
|
+
renderTree($list, flat);
|
|
2746
|
+
});
|
|
2747
|
+
|
|
2748
|
+
$container.off('click', '.me-hidden').on('click', '.me-hidden', function() {
|
|
2749
|
+
syncRowsFromDom($list, flat);
|
|
2750
|
+
const id = $(this).closest('.menu-tree-row').data('id');
|
|
2751
|
+
const row = flat.find(r => r.id === id);
|
|
2752
|
+
if (row) row.hidden = !row.hidden;
|
|
2753
|
+
renderTree($list, flat);
|
|
2754
|
+
});
|
|
2755
|
+
|
|
2756
|
+
// Tabs
|
|
2757
|
+
E.tabs($container.find('#me-tabs').get(0));
|
|
2758
|
+
|
|
2759
|
+
// Behaviour tab - Domma F.create signature is (blueprint, values, options).renderTo(el)
|
|
2760
|
+
const behBlueprint = {
|
|
2761
|
+
variant: {label: 'Variant', type: 'select', options: [
|
|
2762
|
+
{value: '', label: '- default -'},
|
|
2763
|
+
{value: 'transparent', label: 'Transparent'},
|
|
2764
|
+
{value: 'dark', label: 'Dark'},
|
|
2765
|
+
{value: 'light', label: 'Light'},
|
|
2766
|
+
{value: 'default', label: 'Default'}
|
|
2767
|
+
]},
|
|
2768
|
+
position: {label: 'Position', type: 'select', options: [
|
|
2769
|
+
{value: '', label: '- default -'},
|
|
2770
|
+
{value: 'sticky', label: 'Sticky'},
|
|
2771
|
+
{value: 'fixed', label: 'Fixed'},
|
|
2772
|
+
{value: 'static', label: 'Static'},
|
|
2773
|
+
{value: 'floating', label: 'Floating'}
|
|
2774
|
+
]},
|
|
2775
|
+
fontFamily: {label: 'Font family', type: 'text'},
|
|
2776
|
+
fontSize: {label: 'Font size', type: 'text'},
|
|
2777
|
+
fontWeight: {label: 'Font weight', type: 'text'},
|
|
2778
|
+
letterSpacing: {label: 'Letter spacing', type: 'text'},
|
|
2779
|
+
iconSize: {label: 'Icon size', type: 'text'}
|
|
2780
|
+
};
|
|
2781
|
+
const behValues = {
|
|
2782
|
+
variant: menu.variant || '',
|
|
2783
|
+
position: menu.position || '',
|
|
2784
|
+
fontFamily: menu.style?.fontFamily || '',
|
|
2785
|
+
fontSize: menu.style?.fontSize || '',
|
|
2786
|
+
fontWeight: menu.style?.fontWeight || '',
|
|
2787
|
+
letterSpacing: menu.style?.letterSpacing || '',
|
|
2788
|
+
iconSize: menu.style?.iconSize || ''
|
|
2789
|
+
};
|
|
2790
|
+
const behEl = $container.find('#me-behaviour-form').get(0);
|
|
2791
|
+
F.create(behBlueprint, behValues, {showSubmitButton: false}).renderTo(behEl);
|
|
2792
|
+
|
|
2793
|
+
// Meta tab
|
|
2794
|
+
const metaBlueprint = {
|
|
2795
|
+
slug: {label: 'Slug', type: 'text', required: true, hint: 'Lowercase, hyphens - affects file name'},
|
|
2796
|
+
name: {label: 'Name', type: 'text', required: true},
|
|
2797
|
+
description: {label: 'Description', type: 'text'}
|
|
2798
|
+
};
|
|
2799
|
+
const metaValues = {
|
|
2800
|
+
slug: menu.slug,
|
|
2801
|
+
name: menu.name,
|
|
2802
|
+
description: menu.description
|
|
2803
|
+
};
|
|
2804
|
+
const metaEl = $container.find('#me-meta-form').get(0);
|
|
2805
|
+
F.create(metaBlueprint, metaValues, {showSubmitButton: false}).renderTo(metaEl);
|
|
2806
|
+
|
|
2807
|
+
// Helper to read form values back via the rendered DOM
|
|
2808
|
+
// (Domma F.create doesn't expose a read API - read inputs by [name=…]).
|
|
2809
|
+
function readForm(el, keys) {
|
|
2810
|
+
const out = {};
|
|
2811
|
+
for (const k of keys) {
|
|
2812
|
+
const input = el.querySelector(`[name="${k}"]`);
|
|
2813
|
+
if (!input) continue;
|
|
2814
|
+
out[k] = input.type === 'checkbox' ? input.checked : input.value;
|
|
2815
|
+
}
|
|
2816
|
+
return out;
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
// Save handler
|
|
2820
|
+
$container.find('#me-save').on('click', async () => {
|
|
2821
|
+
syncRowsFromDom($list, flat);
|
|
2822
|
+
|
|
2823
|
+
const behForm = readForm(behEl, ['variant', 'position', 'fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'iconSize']);
|
|
2824
|
+
const metaForm = readForm(metaEl, ['slug', 'name', 'description']);
|
|
2825
|
+
|
|
2826
|
+
const next = {
|
|
2827
|
+
slug: metaForm.slug,
|
|
2828
|
+
name: metaForm.name,
|
|
2829
|
+
description: metaForm.description,
|
|
2830
|
+
...(behForm.variant && {variant: behForm.variant}),
|
|
2831
|
+
...(behForm.position && {position: behForm.position}),
|
|
2832
|
+
style: Object.fromEntries(
|
|
2833
|
+
Object.entries(behForm).filter(([k, v]) => ['fontFamily','fontSize','fontWeight','letterSpacing','iconSize'].includes(k) && v)
|
|
2834
|
+
),
|
|
2835
|
+
items: nest(flat)
|
|
2836
|
+
};
|
|
2837
|
+
if (Object.keys(next.style).length === 0) delete next.style;
|
|
2838
|
+
|
|
2839
|
+
try {
|
|
2840
|
+
if (isNew) {
|
|
2841
|
+
await api.menus.create(next);
|
|
2842
|
+
} else if (next.slug !== menu.slug) {
|
|
2843
|
+
// Rename: POST new → PUT locations rewrite → DELETE old
|
|
2844
|
+
await api.menus.create(next);
|
|
2845
|
+
const map = await api.menuLocations.get();
|
|
2846
|
+
let touched = false;
|
|
2847
|
+
for (const [slot, val] of Object.entries(map)) {
|
|
2848
|
+
if (val === menu.slug) { map[slot] = next.slug; touched = true; }
|
|
2849
|
+
}
|
|
2850
|
+
if (touched) await api.menuLocations.save(map);
|
|
2851
|
+
await api.menus.remove(menu.slug);
|
|
2852
|
+
} else {
|
|
2853
|
+
await api.menus.update(menu.slug, next);
|
|
2854
|
+
}
|
|
2855
|
+
E.toast('Saved.', {type: 'success'});
|
|
2856
|
+
location.hash = '#/menus/edit/' + encodeURIComponent(next.slug);
|
|
2857
|
+
} catch (err) {
|
|
2858
|
+
E.toast(`Save failed: ${err.message || err}`, {type: 'error'});
|
|
2859
|
+
}
|
|
2860
|
+
});
|
|
2861
|
+
}
|
|
2862
|
+
};
|
|
2863
|
+
```
|
|
2864
|
+
|
|
2865
|
+
- [ ] **Step 20.3: Export the view**
|
|
2866
|
+
|
|
2867
|
+
In `admin/js/views/index.js`, add:
|
|
2868
|
+
|
|
2869
|
+
```js
|
|
2870
|
+
export {menuEditorView as menuEditor} from './menu-editor.js';
|
|
2871
|
+
```
|
|
2872
|
+
|
|
2873
|
+
- [ ] **Step 20.4: Add tree-row styles**
|
|
2874
|
+
|
|
2875
|
+
Append to `admin/css/admin.css`:
|
|
2876
|
+
|
|
2877
|
+
```css
|
|
2878
|
+
.menu-tree-row {
|
|
2879
|
+
display: grid;
|
|
2880
|
+
grid-template-columns: 16px 1.5fr 1.5fr 0.8fr 0.8fr repeat(6, auto);
|
|
2881
|
+
gap: 6px;
|
|
2882
|
+
align-items: center;
|
|
2883
|
+
padding: 4px 0;
|
|
2884
|
+
transition: opacity 0.15s ease;
|
|
2885
|
+
}
|
|
2886
|
+
.menu-tree-row--hidden { opacity: 0.5; }
|
|
2887
|
+
.menu-tree-grip { cursor: grab; color: var(--dm-text-muted); }
|
|
2888
|
+
.menu-tree { padding: 8px 0; }
|
|
2889
|
+
```
|
|
2890
|
+
|
|
2891
|
+
- [ ] **Step 20.5: Browser smoke-test**
|
|
2892
|
+
|
|
2893
|
+
Hard-refresh admin, open `#/menus/edit/main`. Confirm:
|
|
2894
|
+
|
|
2895
|
+
- Items load and render as a tree, indented by depth.
|
|
2896
|
+
- Indent/outdent buttons reparent items.
|
|
2897
|
+
- Behaviour tab fields are populated from the migrated menu.
|
|
2898
|
+
- Save returns to the same edit URL and the toast confirms success.
|
|
2899
|
+
- Slug rename creates a new menu and updates the navbar location (verify via `/api/menu-locations`).
|
|
2900
|
+
|
|
2901
|
+
Limit-explicit: this is a UI-critical task. Surface a real browser limitation if you can't run it yourself.
|
|
2902
|
+
|
|
2903
|
+
---
|
|
2904
|
+
|
|
2905
|
+
## Task 21: Menu locations editor
|
|
2906
|
+
|
|
2907
|
+
**Files:**
|
|
2908
|
+
|
|
2909
|
+
- Create: `admin/js/views/menu-locations.js`
|
|
2910
|
+
- Create: `admin/js/templates/menu-locations.html`
|
|
2911
|
+
- Modify: `admin/js/views/index.js`
|
|
2912
|
+
|
|
2913
|
+
- [ ] **Step 21.1: Create the template**
|
|
2914
|
+
|
|
2915
|
+
Create `admin/js/templates/menu-locations.html`:
|
|
2916
|
+
|
|
2917
|
+
```html
|
|
2918
|
+
<div class="page-header">
|
|
2919
|
+
<h2><span data-icon="map"></span> Menu Locations</h2>
|
|
2920
|
+
<div class="header-actions">
|
|
2921
|
+
<a class="btn btn-secondary" href="#/menus"><span data-icon="arrow-left"></span> Back to Menus</a>
|
|
2922
|
+
<button class="btn btn-primary" id="ml-save"><span data-icon="check"></span> Save</button>
|
|
2923
|
+
</div>
|
|
2924
|
+
</div>
|
|
2925
|
+
<p class="text-muted">Map each registered slot to a menu. Plugins can register additional slots.</p>
|
|
2926
|
+
<div id="ml-table"></div>
|
|
2927
|
+
```
|
|
2928
|
+
|
|
2929
|
+
- [ ] **Step 21.2: Create the view**
|
|
2930
|
+
|
|
2931
|
+
Create `admin/js/views/menu-locations.js`:
|
|
2932
|
+
|
|
2933
|
+
```js
|
|
2934
|
+
import {api} from '../api.js';
|
|
2935
|
+
|
|
2936
|
+
function depthOfItems(items, current = 0) {
|
|
2937
|
+
let max = current;
|
|
2938
|
+
for (const it of (items || [])) {
|
|
2939
|
+
if (Array.isArray(it.items) && it.items.length) {
|
|
2940
|
+
max = Math.max(max, depthOfItems(it.items, current + 1));
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
return max + (items.length ? 1 : 0);
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
export const menuLocationsView = {
|
|
2947
|
+
templateUrl: '/admin/js/templates/menu-locations.html',
|
|
2948
|
+
async onMount($container) {
|
|
2949
|
+
const [registry, map, menus] = await Promise.all([
|
|
2950
|
+
api.menuLocations.registry(),
|
|
2951
|
+
api.menuLocations.get(),
|
|
2952
|
+
api.menus.list()
|
|
2953
|
+
]);
|
|
2954
|
+
|
|
2955
|
+
// Build a slug → max depth lookup so the warning column can render.
|
|
2956
|
+
// For depth we need the full menu - fetch in parallel.
|
|
2957
|
+
const fullMenus = await Promise.all(menus.map(m => api.menus.get(m.slug)));
|
|
2958
|
+
const depthBySlug = Object.fromEntries(fullMenus.map(m => [m.slug, depthOfItems(m.items || [])]));
|
|
2959
|
+
|
|
2960
|
+
const rows = registry.map(slot => ({
|
|
2961
|
+
slot: slot.slot,
|
|
2962
|
+
label: slot.label,
|
|
2963
|
+
source: slot.source,
|
|
2964
|
+
description: slot.description,
|
|
2965
|
+
maxDepth: slot.maxDepth,
|
|
2966
|
+
menu: map[slot.slot] || ''
|
|
2967
|
+
}));
|
|
2968
|
+
|
|
2969
|
+
T.create('#ml-table', {
|
|
2970
|
+
data: rows,
|
|
2971
|
+
columns: [
|
|
2972
|
+
{key: 'slot', label: 'Slot'},
|
|
2973
|
+
{key: 'label', label: 'Label'},
|
|
2974
|
+
{key: 'source', label: 'Source', render: r => `<span class="badge badge-secondary">${r.source}</span>`},
|
|
2975
|
+
{key: 'description', label: 'Description'},
|
|
2976
|
+
{key: 'menu', label: 'Menu', render: r => {
|
|
2977
|
+
const opts = ['<option value="">- none -</option>']
|
|
2978
|
+
.concat(menus.map(m =>
|
|
2979
|
+
`<option value="${m.slug}"${m.slug === r.menu ? ' selected' : ''}>${m.name || m.slug}</option>`
|
|
2980
|
+
));
|
|
2981
|
+
return `<select class="form-select ml-select" data-slot="${r.slot}">${opts.join('')}</select>`;
|
|
2982
|
+
}},
|
|
2983
|
+
{key: 'warning', label: '', render: r => {
|
|
2984
|
+
if (!r.menu || r.maxDepth == null) return '';
|
|
2985
|
+
const d = depthBySlug[r.menu] || 0;
|
|
2986
|
+
return d > r.maxDepth
|
|
2987
|
+
? `<span class="badge badge-warning" data-tooltip="This slot supports max depth ${r.maxDepth}; menu is depth ${d}">⚠ depth</span>`
|
|
2988
|
+
: '';
|
|
2989
|
+
}}
|
|
2990
|
+
]
|
|
2991
|
+
});
|
|
2992
|
+
|
|
2993
|
+
Domma.icons.scan('#ml-table');
|
|
2994
|
+
|
|
2995
|
+
$container.find('#ml-save').on('click', async () => {
|
|
2996
|
+
const next = {};
|
|
2997
|
+
$container.find('.ml-select').each(function() {
|
|
2998
|
+
const slot = $(this).data('slot');
|
|
2999
|
+
const val = $(this).val();
|
|
3000
|
+
if (val) next[slot] = val;
|
|
3001
|
+
});
|
|
3002
|
+
try {
|
|
3003
|
+
await api.menuLocations.save(next);
|
|
3004
|
+
E.toast('Locations saved.', {type: 'success'});
|
|
3005
|
+
} catch (err) {
|
|
3006
|
+
E.toast(`Save failed: ${err.message || err}`, {type: 'error'});
|
|
3007
|
+
}
|
|
3008
|
+
});
|
|
3009
|
+
}
|
|
3010
|
+
};
|
|
3011
|
+
```
|
|
3012
|
+
|
|
3013
|
+
- [ ] **Step 21.3: Export the view**
|
|
3014
|
+
|
|
3015
|
+
In `admin/js/views/index.js`:
|
|
3016
|
+
|
|
3017
|
+
```js
|
|
3018
|
+
export {menuLocationsView as menuLocations} from './menu-locations.js';
|
|
3019
|
+
```
|
|
3020
|
+
|
|
3021
|
+
- [ ] **Step 21.4: Browser smoke-test**
|
|
3022
|
+
|
|
3023
|
+
Hard-refresh, navigate to `#/menu-locations`. Confirm:
|
|
3024
|
+
|
|
3025
|
+
- All three core slots appear (`navbar`, `footer-primary`, `footer-legal`).
|
|
3026
|
+
- The mapped menu shows the migrated `main` / `footer` selections.
|
|
3027
|
+
- Changing the select + Save updates `/api/menu-locations`.
|
|
3028
|
+
|
|
3029
|
+
---
|
|
3030
|
+
|
|
3031
|
+
## Task 22: Documentation updates
|
|
3032
|
+
|
|
3033
|
+
**Files:**
|
|
3034
|
+
|
|
3035
|
+
- Modify: `docs/configuration.md`
|
|
3036
|
+
- Modify: `docs/markdown-shortcodes.md`
|
|
3037
|
+
- Modify: `docs/plugin-development.md`
|
|
3038
|
+
- Modify: `docs/scaffolding.md`
|
|
3039
|
+
- Modify: `CLAUDE.md`
|
|
3040
|
+
|
|
3041
|
+
- [ ] **Step 22.1: Update `docs/configuration.md`**
|
|
3042
|
+
|
|
3043
|
+
Replace the entire `## config/navigation.json` section with `## config/menus/<slug>.json` + `## config/menu-locations.json`. Copy the field tables from the spec - they're authoritative. Add a one-line backwards-compat note that mentions auto-migration on first boot and the `.bak` fallback.
|
|
3044
|
+
|
|
3045
|
+
- [ ] **Step 22.2: Update `docs/markdown-shortcodes.md`**
|
|
3046
|
+
|
|
3047
|
+
Add a `[menu]` entry alongside `[block]` / `[view]` / `[collection]`. Use the four attribute examples from the spec verbatim. Note the depth attribute and that `slug` or `location` is required.
|
|
3048
|
+
|
|
3049
|
+
- [ ] **Step 22.3: Update `docs/plugin-development.md`**
|
|
3050
|
+
|
|
3051
|
+
In the hooks section, add `registerMenuLocation()` with the same shape as other hook docs (signature, example, idempotency note).
|
|
3052
|
+
|
|
3053
|
+
- [ ] **Step 22.4: Update `docs/scaffolding.md`**
|
|
3054
|
+
|
|
3055
|
+
Add the `menus` recipe branch to the recipe format reference. Show the `{slug, name, items, variant?, position?, locations?, force?}` shape and call out that bundled-from-recipe menus get `meta.bundled: true` and `meta.presetOwner: <recipeSlug>`.
|
|
3056
|
+
|
|
3057
|
+
- [ ] **Step 22.5: Update `CLAUDE.md`**
|
|
3058
|
+
|
|
3059
|
+
In the project `CLAUDE.md`, replace any specific guidance about `config/navigation.json` (e.g. the "Navigation Dropdown Pattern" / "Navbar Dropdown Toggle" sections) with a pointer to the multi-menu architecture. Add a one-paragraph summary:
|
|
3060
|
+
|
|
3061
|
+
> **Multi-menu system.** Menus live in `config/menus/<slug>.json`. The slot → menu map lives in `config/menu-locations.json`. Built-in slots are `navbar`, `footer-primary`, `footer-legal`; plugins register additional slots via `hooks.registerMenuLocation()`. The public navbar's brand is on `site.json.brand` (parallel to `adminBrand`); items + variant + position come from the menu mapped to `navbar`. The `[menu]` shortcode renders any menu inline.
|
|
3062
|
+
|
|
3063
|
+
Keep the existing Domma navbar gotchas - they still apply.
|
|
3064
|
+
|
|
3065
|
+
---
|
|
3066
|
+
|
|
3067
|
+
## Task 23: Final verification pass
|
|
3068
|
+
|
|
3069
|
+
**Files:** none (verification only)
|
|
3070
|
+
|
|
3071
|
+
- [ ] **Step 23.1: Run the full test suite**
|
|
3072
|
+
|
|
3073
|
+
Run: `node --test tests/services/menus.test.js tests/services/menus-migration.test.js tests/services/menus-shortcode.test.js tests/services/scaffolder.test.js tests/routes/menus.test.js`
|
|
3074
|
+
|
|
3075
|
+
Expected: all pass.
|
|
3076
|
+
|
|
3077
|
+
- [ ] **Step 23.2: Live browser walkthrough**
|
|
3078
|
+
|
|
3079
|
+
Hard-refresh the admin SPA. Walk through the full happy path:
|
|
3080
|
+
|
|
3081
|
+
1. Sidebar shows "Menus" (not "Navigation"). Click it → land on `#/menus` list.
|
|
3082
|
+
2. `main` and `footer` menus listed, "Mapped" chips visible.
|
|
3083
|
+
3. Edit `main` → all migrated items visible in tree form.
|
|
3084
|
+
4. Add a new item, save → toast confirms, item appears in the public navbar after hard-refresh of `/`.
|
|
3085
|
+
5. Manage locations → all three slots, correct sources, save works.
|
|
3086
|
+
6. New menu via "New menu" button → save → new menu file written.
|
|
3087
|
+
7. `[menu slug="footer" /]` in a page → renders nested `<ul>` on the public side.
|
|
3088
|
+
8. `#/navigation` → redirects to `#/menus`.
|
|
3089
|
+
|
|
3090
|
+
Surface a real browser limit explicitly per `feedback_no_verified_without_browser.md`. The user should drive this if you can't.
|
|
3091
|
+
|
|
3092
|
+
- [ ] **Step 23.3: Hand off to the user for commit**
|
|
3093
|
+
|
|
3094
|
+
Per `feedback_no_commits.md`, do not commit. Summarise the changes and let the user commit when ready. Suggest a commit message like:
|
|
3095
|
+
|
|
3096
|
+
> Multi-menu system with locations registry + auto-migration of navigation.json + footer.links; new [menu] shortcode; scaffolder recipe support; permissions family
|