domma-cms 0.114.0 → 0.115.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.
@@ -9,6 +9,8 @@
9
9
  * DELETE /api/projects/:slug - delete (403 for core; 409 if any artefacts tagged)
10
10
  * GET /api/projects/:slug/artefacts - grouped artefacts
11
11
  * POST /api/projects/:slug/untag-all - escape hatch before delete (403 for core)
12
+ * POST /api/projects/:slug/remove-contents - delete the project AND everything filed under it;
13
+ * body {confirm: <slug>} (403 core/protected, 409 users/roles)
12
14
  *
13
15
  * Auth middlewares are accepted as DI options so tests can supply no-ops.
14
16
  */
@@ -26,6 +28,7 @@ import {
26
28
  getProject,
27
29
  listProjectsForUser,
28
30
  projectsBadge,
31
+ removeProjectContents,
29
32
  untagAllForProject,
30
33
  updateProject
31
34
  } from '../../services/projects.js';
@@ -112,6 +115,31 @@ export async function projectsRoutes(fastify, opts = {}) {
112
115
  return {success: true};
113
116
  });
114
117
 
118
+ // Deletes pages and data, so it asks for more than projects:delete: deleting pages and
119
+ // collections too, and access to this project (a project-scoped user cannot reach others).
120
+ const canRemoveAll = {preHandler: [authenticate, requirePermission('projects', 'delete'),
121
+ requirePermission('pages', 'delete'), requirePermission('collections', 'delete')]};
122
+ fastify.post('/projects/:slug/remove-contents', canRemoveAll, async (request, reply) => {
123
+ const {slug} = request.params;
124
+ const visible = await listProjectsForUser(request.user);
125
+ if (!visible.some(p => p.slug === slug)) return reply.status(404).send({error: 'Project not found'});
126
+ // The slug typed back: this deletes pages and data, and cannot be undone.
127
+ if (request.body?.confirm !== slug) {
128
+ return reply.status(400).send({error: `Type the project's slug ("${slug}") to confirm.`});
129
+ }
130
+ try {
131
+ const host = String(request.headers['x-forwarded-host'] || request.headers.host || '').split(',')[0].trim();
132
+ const result = await removeProjectContents(slug, {siteHost: host});
133
+ await cache.invalidateTags(['projects', `project:${slug}`, 'site', 'nav']);
134
+ if (result.failed.length) {
135
+ return reply.status(500).send({error: `Some things could not be removed, so the project was kept: ${result.failed.join('; ')}`, ...result});
136
+ }
137
+ return {success: true, ...result};
138
+ } catch (err) {
139
+ return reply.status(err.status || 400).send({error: err.message});
140
+ }
141
+ });
142
+
115
143
  fastify.get('/projects/:slug/artefacts', canRead, async (request, reply) => {
116
144
  if (!(await getProject(request.params.slug))) {
117
145
  return reply.status(404).send({error: 'Project not found'});
@@ -0,0 +1,59 @@
1
+ /**
2
+ * /favicon.svg and /favicon.ico - every site has an icon in the browser tab.
3
+ *
4
+ * There was none: no <link rel="icon">, no route, so every page load asked for
5
+ * /favicon.ico and got a 404. Now site.json `favicon`, else the brand logo
6
+ * (brand.logo) - a redirect, so the logo in Media stays the one source -
7
+ * and otherwise a generated icon: the first letter of the site's name.
8
+ *
9
+ * Read per request, so a new logo or title shows without a restart.
10
+ */
11
+ import {getConfig} from '../config.js';
12
+
13
+ /** A same-site path or an https URL - never a scheme that could run anything. */
14
+ // A backslash is refused anywhere: browsers read /\evil.com as //evil.com.
15
+ const SAFE_LOGO = /^(\/(?![/\\])[^\s"'<>\\]*|https:\/\/[^\s"'<>\\]+)$/i;
16
+
17
+ /**
18
+ * The generated icon: a rounded square with the site's initial.
19
+ *
20
+ * @param {string} title - the site's name
21
+ * @returns {string} SVG
22
+ */
23
+ export function letterIcon(title = '') {
24
+ const ch = (String(title).match(/[\p{L}\p{N}]/u) || ['D'])[0].toUpperCase();
25
+ const esc = ch.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
26
+ return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
27
+ + '<rect width="64" height="64" rx="14" fill="#4f46e5"/>'
28
+ + `<text x="32" y="44" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="36" font-weight="700" fill="#ffffff">${esc}</text>`
29
+ + '</svg>';
30
+ }
31
+
32
+ /**
33
+ * What to answer for the icon, from site.json.
34
+ *
35
+ * @param {object} site
36
+ * @returns {{redirect: string}|{svg: string}}
37
+ */
38
+ export function faviconFor(site = {}) {
39
+ // site.json `favicon` (a Settings key) first, then the brand logo.
40
+ for (const v of [site?.favicon, site?.brand?.logo]) {
41
+ const url = typeof v === 'string' ? v.trim() : '';
42
+ if (url && SAFE_LOGO.test(url)) return {redirect: url};
43
+ }
44
+ return {svg: letterIcon(site?.title || site?.brand?.text || '')};
45
+ }
46
+
47
+ export async function faviconRoutes(fastify) {
48
+ const answer = async (request, reply) => {
49
+ let site = {};
50
+ try { site = getConfig('site') || {}; } catch { /* no site config: the letter D */ }
51
+ const out = faviconFor(site);
52
+ reply.header('Cache-Control', 'public, max-age=3600');
53
+ if (out.redirect) return reply.redirect(out.redirect, 302);
54
+ return reply.type('image/svg+xml').send(out.svg);
55
+ };
56
+ fastify.get('/favicon.svg', answer);
57
+ // Browsers ask for /favicon.ico whatever the page says; an SVG body is accepted by every current browser.
58
+ fastify.get('/favicon.ico', answer);
59
+ }
package/server/server.js CHANGED
@@ -31,6 +31,7 @@ import {refreshComponentTagAllowlist} from './services/markdown.js';
31
31
  import * as cache from './services/cache/index.js';
32
32
  import {parseTrustProxy} from './services/network.js';
33
33
  import {rateLimitOptions} from './services/rate-limit.js';
34
+ import {frameSources} from './services/embedCsp.js';
34
35
 
35
36
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
37
  const ROOT = path.resolve(__dirname, '..');
@@ -122,6 +123,8 @@ await app.register(helmet, {
122
123
  imgSrc: ["'self'", 'data:', 'blob:'],
123
124
  fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'],
124
125
  connectSrc: ["'self'", 'https://cdn.jsdelivr.net'],
126
+ // [embed] players and config/embeds.json hosts - without this, default-src blocked them all.
127
+ frameSrc: frameSources((() => { try { return getConfig('embeds')?.allowedHosts; } catch { return []; } })()),
125
128
  upgradeInsecureRequests: null, // prevent browsers forcing HTTPS on HTTP deployments
126
129
  }
127
130
  },
@@ -710,6 +713,9 @@ await app.register(docsPublicRoutes);
710
713
  const { callPublicRoutes } = await import('./routes/call-public.js');
711
714
  await app.register(callPublicRoutes);
712
715
 
716
+ // Before the pages: /favicon.* must never reach the page router.
717
+ const { faviconRoutes } = await import('./routes/favicon.js');
718
+ await app.register(faviconRoutes);
713
719
  const { publicRoutes } = await import('./routes/public.js');
714
720
  await app.register(publicRoutes);
715
721
 
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The frame-src the Content-Security-Policy needs for [embed].
3
+ *
4
+ * The CSP had no frame-src, so it fell back to default-src 'self' and the
5
+ * browser blocked every embedded player - YouTube and Vimeo included, which the
6
+ * [embed] shortcode documents as always allowed. The page rendered the iframe
7
+ * and the visitor saw a blocked frame.
8
+ *
9
+ * So: 'self' (the [demo] shortcode frames the site's own pages), the two players
10
+ * [embed] rewrites links to (youtube-nocookie.com, player.vimeo.com), and every
11
+ * host in config/embeds.json `allowedHosts` - the same list the renderer and the
12
+ * sanitiser already enforce (server.js passes getConfig('embeds'), as the
13
+ * renderer reads it). Read at boot: a change to embeds.json needs a restart to
14
+ * reach the header.
15
+ */
16
+ /** The players [embed] always uses. */
17
+ // youtube.com too: the HTML sanitiser lets its iframes through, so the policy must agree.
18
+ export const ALWAYS_FRAMED = ['https://www.youtube-nocookie.com', 'https://www.youtube.com', 'https://youtube.com', 'https://player.vimeo.com'];
19
+
20
+ /** A bare hostname, or nothing: a host in embeds.json must never smuggle in a scheme, path or wildcard. */
21
+ const HOST_RE = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
22
+
23
+ /**
24
+ * @param {string[]} allowedHosts - config/embeds.json allowedHosts
25
+ * @returns {string[]} frame-src sources
26
+ */
27
+ export function frameSources(allowedHosts = []) {
28
+ const extra = (Array.isArray(allowedHosts) ? allowedHosts : [])
29
+ .map(h => String(h).trim().toLowerCase())
30
+ .filter(h => HOST_RE.test(h))
31
+ .map(h => `https://${h}`);
32
+ return [...new Set(["'self'", ...ALWAYS_FRAMED, ...extra])];
33
+ }
@@ -694,7 +694,7 @@ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emp
694
694
  * @param {string} opts.titleField - entry data field used as item title
695
695
  * @param {string} [opts.dateField] - optional field surfaced as data-date
696
696
  * @param {string} [opts.statusField]- optional field surfaced as data-status
697
- * @param {string} [opts.iconField] - optional field surfaced as data-icon
697
+ * @param {string} [opts.iconField] - optional field surfaced as data-event-icon
698
698
  * @param {string} [opts.bodyField] - optional Markdown body field
699
699
  * @param {string} opts.layout - vertical | centred | horizontal
700
700
  * @param {string} opts.theme - minimal | corporate | modern
@@ -712,7 +712,9 @@ function renderCollectionTimeline(entries, opts, ctxOpts = {}) {
712
712
  const title = escapeHtmlText(String(e.data?.[titleField] ?? e.id ?? '(untitled)'));
713
713
  const date = dateField && e.data?.[dateField] != null ? ` data-date="${escapeAttr(String(e.data[dateField]))}"` : '';
714
714
  const status = statusField && e.data?.[statusField] != null ? ` data-status="${escapeAttr(String(e.data[statusField]))}"` : '';
715
- const icon = iconField && e.data?.[iconField] != null ? ` data-icon="${escapeAttr(String(e.data[iconField]))}"` : '';
715
+ // data-event-icon, never data-icon: the icon scanner turns ANY [data-icon] element into an
716
+ // icon, replacing its children - which wiped each item's title and body before site.js read them.
717
+ const icon = iconField && e.data?.[iconField] != null ? ` data-event-icon="${escapeAttr(String(e.data[iconField]))}"` : '';
716
718
  const bodyHtml = bodyField
717
719
  ? marked.parse(String(e.data?.[bodyField] ?? ''))
718
720
  : '';
@@ -2581,6 +2583,9 @@ LAYOUT_RENDERERS['pricing'] = (attrs, body) => {
2581
2583
  const period = escapeAttr(attrs.period || '');
2582
2584
  const gradient = escapeAttr(attrs.gradient || 'indigo');
2583
2585
  const cta = escapeAttr(attrs.footer || 'Get started');
2586
+ // Where the button goes - it always went to "#" before. Same handling as [button]'s href;
2587
+ // the sanitiser removes a javascript: URL.
2588
+ const href = escapeAttr(attrs.href || '#');
2584
2589
  const features = parseSubTagList(body, 'feature');
2585
2590
  const featHtml = features.map(f => `<div class="card-pricing-feature"><span class="card-pricing-check">\u2713</span>${escapeAttr(f.content)}</div>`).join('');
2586
2591
  return `${cardRoot(attrs)}<div class="card-header card-gradient-${gradient}">
@@ -2588,7 +2593,7 @@ LAYOUT_RENDERERS['pricing'] = (attrs, body) => {
2588
2593
  <div class="card-stat-value" style="color:#fff">${price}</div>
2589
2594
  ${period ? `<div style="opacity:.75;font-size:.8rem">${period}</div>` : ''}
2590
2595
  </div><div class="card-pricing-features">${featHtml}</div>
2591
- <a class="card-pricing-cta" href="#">${cta}</a></div>\n`;
2596
+ <a class="card-pricing-cta" href="${href}">${cta}</a></div>\n`;
2592
2597
  };
2593
2598
 
2594
2599
  LAYOUT_RENDERERS['feature-comparison'] = (attrs, body) => {
@@ -3178,7 +3183,8 @@ function processTimelineBlocks(markdown) {
3178
3183
  const title = escapeAttr(ia.title || '');
3179
3184
  const date = ia.date ? ` data-date="${escapeAttr(ia.date)}"` : '';
3180
3185
  const status = ia.status ? ` data-status="${escapeAttr(ia.status)}"` : '';
3181
- const icon = ia.icon ? ` data-icon="${escapeAttr(ia.icon)}"` : '';
3186
+ // data-event-icon, never data-icon - see renderCollectionTimeline: the icon scanner would empty the item.
3187
+ const icon = ia.icon ? ` data-event-icon="${escapeAttr(ia.icon)}"` : '';
3182
3188
  const descHtml = marked.parse(processCardBlocks(processGridBlocks(processInlineShortcodes(restore(itemBody.trim())))));
3183
3189
  return `<div class="dm-progression-item"${date}${status}${icon}><div class="dm-progression-item-title">${title}</div><div class="dm-progression-item-body">${descHtml}</div></div>`;
3184
3190
  }
@@ -52,7 +52,9 @@ const PRESETS = [
52
52
  {name: 'rootUrl', label: 'Root URL', type: 'text'},
53
53
  {name: 'sortOrder', label: 'Sort Order', type: 'number', default: 0},
54
54
  {name: 'enabled', label: 'Enabled', type: 'boolean', default: true},
55
- {name: 'protected', label: 'Protected', type: 'boolean', default: false}
55
+ {name: 'protected', label: 'Protected', type: 'boolean', default: false},
56
+ // Example content a starter bundle put on the site: the admin offers "Remove sample content".
57
+ {name: 'sample', label: 'Sample content', type: 'boolean', default: false}
56
58
  ],
57
59
  api: {
58
60
  create: {enabled: false, access: 'admin'},
@@ -510,7 +510,8 @@ export async function getArtefactsByProject() {
510
510
  try {
511
511
  const {listPages} = await import('./content.js');
512
512
  for (const p of await listPages()) {
513
- const url = p.url || p.urlPath || '/';
513
+ // urlPath: the page's own address (its file) - what removal deletes, too.
514
+ const url = p.urlPath || p.url || '/';
514
515
  const explicit = (p.project === undefined) ? undefined : p.project;
515
516
  const pageProject = await getProjectForPage(url, explicit);
516
517
  out(pageProject).pages.push(p);
@@ -672,6 +673,203 @@ export async function untagAllForProject(projectSlug) {
672
673
  return counts;
673
674
  }
674
675
 
676
+ /**
677
+ * The path a menu link leads to on THIS site, or null when it leads elsewhere.
678
+ * Relative, root-relative and absolute same-site links all count; the query and
679
+ * hash are dropped and a trailing slash ignored, so /a, /a/, /a?x=1, /a#top and
680
+ * https://<this site>/a are one page.
681
+ *
682
+ * @param {string} url
683
+ * @param {string} [siteHost] - this site's host, for absolute links
684
+ * @returns {string|null}
685
+ */
686
+ export function linkPath(url, siteHost = '') {
687
+ if (typeof url !== 'string' || !url.trim()) return null;
688
+ let u;
689
+ try { u = new URL(url.trim(), 'http://this.site/'); } catch { return null; }
690
+ const absolute = /^[a-z][a-z0-9+.-]*:/i.test(url.trim()) || url.trim().startsWith('//');
691
+ if (absolute && !(siteHost && u.host.toLowerCase() === siteHost.toLowerCase())) return null;
692
+ if (!['http:', 'https:'].includes(u.protocol)) return null;
693
+ return u.pathname.replace(/\/+$/, '') || '/';
694
+ }
695
+
696
+ /**
697
+ * Menu items with the links to removed pages taken out, PURE. An item whose
698
+ * link leads to a removed page is dropped; its sub-items that still lead
699
+ * somewhere take its place, so removing a section never takes an unrelated
700
+ * page out of the nav.
701
+ *
702
+ * @param {object[]} items
703
+ * @param {Set<string>} goneUrls - normalised paths (see linkPath)
704
+ * @param {string} [siteHost]
705
+ * @returns {{items: object[], removed: number}}
706
+ */
707
+ export function pruneMenuItems(items, goneUrls, siteHost = '') {
708
+ let removed = 0;
709
+ const walk = (list) => {
710
+ const out = [];
711
+ for (const item of Array.isArray(list) ? list : []) {
712
+ const kids = Array.isArray(item?.items) ? walk(item.items) : null;
713
+ const path = linkPath(item?.url, siteHost);
714
+ if (path && goneUrls.has(path)) {
715
+ removed++;
716
+ if (kids?.length) out.push(...kids);
717
+ continue;
718
+ }
719
+ out.push(kids ? {...item, items: kids} : item);
720
+ }
721
+ return out;
722
+ };
723
+ return {items: walk(items), removed};
724
+ }
725
+
726
+ /**
727
+ * What removing a project would refuse on, and why - checked BEFORE anything is
728
+ * deleted. People are never deleted with content; a menu in a site slot, or a
729
+ * built-in collection, is never deleted by a project removal either.
730
+ */
731
+ async function removalBlockers(slug, grouped) {
732
+ const blockers = [];
733
+ if (grouped.users.length || grouped.roles.length) {
734
+ blockers.push(`${grouped.users.length} user(s) and ${grouped.roles.length} role(s) are filed under this project - move them to another project first; people are never deleted along with content`);
735
+ }
736
+ try {
737
+ const {getLocations} = await import('./menus.js');
738
+ const map = await getLocations();
739
+ for (const m of grouped.menus) {
740
+ const slot = Object.entries(map).find(([, s]) => s === m.slug)?.[0];
741
+ if (slot) blockers.push(`the menu "${m.slug}" is the site's ${slot} menu - map another menu to that slot, or move this one out of the project, first`);
742
+ }
743
+ } catch { /* no locations */ }
744
+ try {
745
+ const {PRESET_COLLECTION_SLUGS} = await import('./presetCollections.js');
746
+ const builtIn = new Set(['roles', 'user-profiles', ...PRESET_COLLECTION_SLUGS]);
747
+ for (const c of grouped.collections) {
748
+ if (builtIn.has(c.slug)) blockers.push(`the built-in collection "${c.slug}" is filed under this project - built-in collections are never deleted; move it out first`);
749
+ }
750
+ } catch { /* no presets */ }
751
+ return blockers;
752
+ }
753
+
754
+ /**
755
+ * Delete a project AND everything filed under it - "Remove sample content".
756
+ *
757
+ * Deletes its pages (with their versions and share links), collections (with
758
+ * their entries), forms, blocks, views, components, actions, menus, API
759
+ * endpoints and API tokens; takes the links to its pages out of every other
760
+ * menu; drops the project from menu and context-menu bindings, and deletes
761
+ * context menus that were bound only to its pages or to it; then deletes the
762
+ * project record.
763
+ *
764
+ * Refused - with nothing deleted - for core and protected projects, while any
765
+ * user or role is filed under it, while one of its menus fills a site slot,
766
+ * and while a built-in collection is filed under it. If anything fails part
767
+ * way, the project record is KEPT (so what is left can still be found under
768
+ * it) and the failures are reported.
769
+ *
770
+ * @param {string} slug
771
+ * @param {{siteHost?: string}} [opts] - this site's host, so absolute menu links to it are pruned too
772
+ * @returns {Promise<{removed: Record<string, number>, links: number, contextMenus: number, failed: string[], projectDeleted: boolean}>}
773
+ * @throws {Error} with `.status` 403 (core/protected), 404 (unknown) or 409 (blocked)
774
+ */
775
+ export async function removeProjectContents(slug, {siteHost = ''} = {}) {
776
+ const fail = (status, message) => Object.assign(new Error(message), {status});
777
+ if (slug === CORE_PROJECT_SLUG) throw fail(403, 'The core project is the site itself - it cannot be removed.');
778
+ const project = await getProject(slug);
779
+ if (!project) throw fail(404, 'Project not found');
780
+ if (project.protected === true) throw fail(403, 'This project is protected and cannot be removed.');
781
+
782
+ const grouped = await getArtefactsForProject(slug);
783
+ const blockers = await removalBlockers(slug, grouped);
784
+ if (blockers.length) throw fail(409, `Nothing was removed: ${blockers.join('; ')}.`);
785
+
786
+ const removed = {pages: 0, collections: 0, forms: 0, blocks: 0, views: 0, components: 0, actions: 0, menus: 0, apis: 0, tokens: 0};
787
+ const failed = [];
788
+ const goneUrls = new Set();
789
+ const tryEach = async (list, fn, key, label) => {
790
+ for (const item of list) {
791
+ try {
792
+ const ok = await fn(item);
793
+ if (ok === false) throw new Error('not found');
794
+ removed[key]++;
795
+ } catch (err) {
796
+ failed.push(`${key} ${label(item)}: ${err.message}`);
797
+ }
798
+ }
799
+ };
800
+
801
+ const {deletePage} = await import('./content.js');
802
+ const {deletePreviewLinksForPage} = await import('./previewLinks.js');
803
+ // urlPath is where the page's file is - the same address membership resolves it by.
804
+ await tryEach(grouped.pages, async (p) => {
805
+ const url = p.urlPath || p.url;
806
+ await deletePage(url);
807
+ goneUrls.add(linkPath(url) || url);
808
+ try { await deletePreviewLinksForPage(url); } catch { /* none */ }
809
+ }, 'pages', (p) => p.urlPath || p.url);
810
+ const {deleteCollection} = await import('./collections.js');
811
+ await tryEach(grouped.collections, (c) => deleteCollection(c.slug), 'collections', (c) => c.slug);
812
+ const {deleteForm} = await import('./forms.js');
813
+ await tryEach(grouped.forms, (f) => deleteForm(f.slug), 'forms', (f) => f.slug);
814
+ const {deleteBlock} = await import('./blocks.js');
815
+ await tryEach(grouped.blocks, (b) => deleteBlock(b.name), 'blocks', (b) => b.name);
816
+ const {deleteView} = await import('./views.js');
817
+ await tryEach(grouped.views, (v) => deleteView(v.slug), 'views', (v) => v.slug);
818
+ const {deleteComponent} = await import('./components.js');
819
+ await tryEach(grouped.components, (c) => deleteComponent(c.name), 'components', (c) => c.name);
820
+ const {deleteAction} = await import('./actions.js');
821
+ await tryEach(grouped.actions, (a) => deleteAction(a.slug), 'actions', (a) => a.slug);
822
+ const menus = await import('./menus.js');
823
+ await tryEach(grouped.menus, (m) => menus.deleteMenu(m.slug), 'menus', (m) => m.slug);
824
+ // Through the endpoints service: it drops them from the live /api/x registry at once.
825
+ const {deleteEndpoint} = await import('./apiEndpoints.js');
826
+ await tryEach(grouped.apis, (e) => deleteEndpoint(e.id), 'apis', (e) => e.path || e.id);
827
+ // A token for this project would be a live key to whatever takes the slug next.
828
+ try {
829
+ const {API_TOKENS_COLLECTION_SLUG, revokeToken} = await import('./apiTokens.js');
830
+ const {entries} = await listEntries(API_TOKENS_COLLECTION_SLUG, {limit: 0});
831
+ await tryEach(entries.filter(e => e.data?.project === slug), (e) => revokeToken(e.id), 'tokens', (e) => e.data?.name || e.id);
832
+ } catch (err) { failed.push(`tokens: ${err.message}`); }
833
+
834
+ // Links to the removed pages, and bindings to the project, in the menus that remain.
835
+ let links = 0;
836
+ for (const m of await menus.listMenus().catch(() => [])) {
837
+ try {
838
+ const full = await menus.getMenu(m.slug);
839
+ if (!full) continue;
840
+ const {items, removed: n} = goneUrls.size ? pruneMenuItems(full.items, goneUrls, siteHost) : {items: full.items, removed: 0};
841
+ const bound = Array.isArray(full.binding?.projects) && full.binding.projects.includes(slug);
842
+ if (!n && !bound) continue;
843
+ const next = {...full, items};
844
+ if (bound) next.binding = {...full.binding, projects: full.binding.projects.filter(p => p !== slug)};
845
+ await menus.updateMenu(m.slug, next);
846
+ links += n;
847
+ } catch (err) { failed.push(`menu links in ${m.slug}: ${err.message}`); }
848
+ }
849
+
850
+ // Context menus: bound only to removed pages and/or this project -> deleted; otherwise the project unbound.
851
+ let contextMenus = 0;
852
+ try {
853
+ const ctx = await import('./contextMenus.js');
854
+ for (const m of await ctx.getAllContextMenus()) {
855
+ const pages = Array.isArray(m?.binding?.pages) ? m.binding.pages : [];
856
+ const projects = Array.isArray(m?.binding?.projects) ? m.binding.projects : [];
857
+ const livePages = pages.filter(p => !goneUrls.has(linkPath(p) || p));
858
+ const liveProjects = projects.filter(p => p !== slug);
859
+ if (livePages.length === pages.length && liveProjects.length === projects.length) continue;
860
+ try {
861
+ if (!livePages.length && !liveProjects.length) { await ctx.deleteContextMenu(m.slug); contextMenus++; }
862
+ else await ctx.updateContextMenu(m.slug, {...m, binding: {...m.binding, pages: livePages, projects: liveProjects}});
863
+ } catch (err) { failed.push(`context menu ${m.slug}: ${err.message}`); }
864
+ }
865
+ } catch { /* no context menus */ }
866
+
867
+ // Anything left behind keeps the project, so it can still be found and dealt with.
868
+ let projectDeleted = false;
869
+ if (!failed.length) projectDeleted = await deleteProject(slug);
870
+ return {removed, links, contextMenus, failed, projectDeleted};
871
+ }
872
+
675
873
  /**
676
874
  * The sidebar badge on Manage projects (its countUrl, GET /api/projects/_count):
677
875
  * how many projects this user sees - a count to glance at, not a warning -
@@ -5,6 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>{{seoTitle}}</title>
7
7
  <meta name="description" content="{{seoDescription}}">
8
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
8
9
  {{seoTags}}
9
10
 
10
11
  <!-- Set on preview renders (editor preview, draft preview, share link).
@@ -24,7 +25,7 @@
24
25
  <link rel="stylesheet" href="/dist/domma/themes/domma-themes.css">
25
26
 
26
27
  <!-- Site CSS -->
27
- <link rel="stylesheet" href="/public/css/site.css?v=20260929-card-span">
28
+ <link rel="stylesheet" href="/public/css/site.css?v=20261004-pricing-cta">
28
29
  <link rel="stylesheet" href="/public/css/forms.css?v=20260927-submit-btn-css">
29
30
  <!-- Menu item hover / current-page effects. Loaded after site.css so a
30
31
  menu's own highlight wins over the blanket link styling there. -->
@@ -136,7 +137,7 @@
136
137
  </script>
137
138
 
138
139
  <!-- Site initialisation -->
139
- <script src="/public/js/site.js?v=20260927-submit-btn" type="module"></script>
140
+ <script src="/public/js/site.js?v=20261004-countdown" type="module"></script>
140
141
  <script src="/public/js/collection-context.js?v=20260927-ctx" type="module"></script>
141
142
  {{ctxMenusModule}}
142
143