domma-cms 0.116.8 → 0.118.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.
Files changed (37) hide show
  1. package/CLAUDE.md +7 -4
  2. package/admin/js/app.js +2 -2
  3. package/admin/js/lib/api-tokens-arrange.js +3 -3
  4. package/admin/js/lib/content-api-arrange.js +1 -0
  5. package/admin/js/lib/menu-editor-arrange.js +1 -1
  6. package/admin/js/lib/menu-rows.js +1 -1
  7. package/admin/js/lib/sidebar-renderer.js +1 -1
  8. package/admin/js/templates/api-reference.html +2 -1
  9. package/admin/js/templates/api-tokens.html +3 -3
  10. package/admin/js/templates/content-api.html +208 -0
  11. package/admin/js/templates/docs/api-content.html +183 -0
  12. package/admin/js/templates/docs/api-external.html +2 -1
  13. package/admin/js/templates/docs/usage-editions.html +1 -1
  14. package/admin/js/templates/menu-editor.html +6 -0
  15. package/admin/js/views/api-tokens.js +6 -6
  16. package/admin/js/views/content-api.js +7 -0
  17. package/admin/js/views/doc-pages.js +1 -1
  18. package/admin/js/views/index.js +1 -1
  19. package/admin/js/views/menu-editor.js +14 -14
  20. package/package.json +1 -1
  21. package/public/js/menu-decor.mjs +1 -1
  22. package/public/js/site.js +1 -1
  23. package/server/routes/api/collections.js +10 -17
  24. package/server/routes/api/content-api.js +70 -0
  25. package/server/routes/api/content-public.js +299 -0
  26. package/server/routes/public.js +17 -68
  27. package/server/server.js +11 -4
  28. package/server/services/apiTokens.js +61 -5
  29. package/server/services/content.js +4 -4
  30. package/server/services/contentApi.js +248 -0
  31. package/server/services/menuRender.js +9 -3
  32. package/server/services/menus.js +4 -1
  33. package/server/services/pagePublishing.js +85 -0
  34. package/server/services/permissionRegistry.js +1 -1
  35. package/server/services/renderer.js +12 -2
  36. package/server/services/search.js +31 -12
  37. package/server/templates/page.html +1 -1
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Page publishing - may this viewer see this page?
3
+ *
4
+ * The one implementation of the public page access decision. The public site
5
+ * (routes/public.js) calls it, and so will the Content API, so a page
6
+ * reachable over the API is reachable on the site by the same viewer and the
7
+ * reverse. It decides; it never replies or renders.
8
+ *
9
+ * Order matters and matches what the site has always done:
10
+ * 1. the page (with the directory-index fallback)
11
+ * 2. its project's kill-switch - a disabled project is a 404, no oracle
12
+ * 3. drafts - anything not `published` is a 404 unless drafts are allowed
13
+ * AND the viewer holds pages.read
14
+ * 4. visibility - checkVisibility(), the ladder / `=role` rule
15
+ */
16
+ import {getPage} from './content.js';
17
+ import {getProjectForPage, isProjectEnabled} from './projects.js';
18
+ import {checkVisibility} from '../middleware/auth.js';
19
+ import {getPermissionsFor} from './roles.js';
20
+ import {getEffectiveRoles} from './userRoles.js';
21
+
22
+ /**
23
+ * Does a visibility value let everyone in? Missing, 'public', an empty array,
24
+ * or an array containing 'public'.
25
+ *
26
+ * @param {string|string[]|undefined} vis
27
+ * @returns {boolean}
28
+ */
29
+ export function isEffectivelyPublic(vis) {
30
+ return !vis
31
+ || vis === 'public'
32
+ || (Array.isArray(vis) && (vis.length === 0 || vis.includes('public')));
33
+ }
34
+
35
+ /**
36
+ * May this viewer see unpublished pages?
37
+ *
38
+ * Gated on `pages.read` - the same permission that opens the Pages list in the
39
+ * admin. Anyone who can already read the draft's source there loses nothing by
40
+ * seeing it rendered, and nobody else gains anything.
41
+ *
42
+ * @param {object|null} user - Resolved viewer (`{role, additionalRoles}`) or null
43
+ * @returns {boolean}
44
+ */
45
+ export function canPreviewDrafts(user) {
46
+ if (!user) return false;
47
+ const allowed = getPermissionsFor('pages', 'read');
48
+ return getEffectiveRoles(user).some(role => allowed.includes(role));
49
+ }
50
+
51
+ /**
52
+ * The page is fetched anonymously: the verdict needs only its metadata. A
53
+ * caller rendering the body for a signed-in viewer re-fetches it with
54
+ * `getPage(page.urlPath, {user})` so `[menu]` and row-scoped `[view]` see them.
55
+ *
56
+ * A `forbidden` verdict carries no page, so nothing downstream can render it
57
+ * by mistake.
58
+ *
59
+ * @param {string} urlPath - '/about', '/services', '/'
60
+ * @param {object|null} viewer - resolveViewer() result, a JWT user, or null
61
+ * @param {{allowDrafts?: boolean}} [opts]
62
+ * @returns {Promise<
63
+ * {status: 'ok', page: object, project: string, isDraft: boolean, isPublic: boolean} |
64
+ * {status: 'not-found'} |
65
+ * {status: 'forbidden'}
66
+ * >}
67
+ */
68
+ export async function resolvePublishedPage(urlPath, viewer, {allowDrafts = false} = {}) {
69
+ let page = await getPage(urlPath);
70
+ if (!page && !urlPath.endsWith('/index')) {
71
+ page = await getPage(urlPath.replace(/\/$/, '') || '/');
72
+ }
73
+ if (!page) return {status: 'not-found'};
74
+
75
+ const project = await getProjectForPage(page.urlPath || urlPath, page.project);
76
+ if (!(await isProjectEnabled(project))) return {status: 'not-found'};
77
+
78
+ const isDraft = page.status !== 'published';
79
+ if (isDraft && !(allowDrafts && canPreviewDrafts(viewer))) return {status: 'not-found'};
80
+
81
+ const isPublic = isEffectivelyPublic(page.visibility);
82
+ if (!isPublic && !checkVisibility(viewer, page.visibility)) return {status: 'forbidden'};
83
+
84
+ return {status: 'ok', page, project, isDraft, isPublic};
85
+ }
@@ -314,7 +314,7 @@ export const REGISTRY = [
314
314
  {
315
315
  key: 'api-tokens',
316
316
  label: 'API Tokens',
317
- description: 'Manage project-scoped tokens for the external collections API.',
317
+ description: 'Manage project-scoped tokens for the external collections API and the Content API.',
318
318
  icon: 'key',
319
319
  group: 'Configuration',
320
320
  actions: [
@@ -266,6 +266,8 @@ export async function renderPage(page, opts = {}) {
266
266
  // Item highlighting: the navbar is built in the browser, so the
267
267
  // config has to travel to site.js rather than being rendered here.
268
268
  ...(navMenu.highlight && {highlight: navMenu.highlight}),
269
+ // Designer classes for the navbar element itself (site.js adds them).
270
+ ...(navMenu.class && {class: navMenu.class}),
269
271
  ...(navMenu.style && {style: navMenu.style})
270
272
  }
271
273
  : (await readLegacyNavBackup()) || {brand: site.brand || {}, items: []};
@@ -333,7 +335,10 @@ export async function renderPage(page, opts = {}) {
333
335
  copyright: site.footer?.copyright || '',
334
336
  // Highlighting is per menu, and the two footer columns are two menus.
335
337
  ...(footerPrimary?.highlight && {primaryHighlight: footerPrimary.highlight}),
336
- ...(footerLegal?.highlight && {legalHighlight: footerLegal.highlight})
338
+ ...(footerLegal?.highlight && {legalHighlight: footerLegal.highlight}),
339
+ // Each column's menu-level designer classes, for its <nav>.
340
+ ...(footerPrimary?.class && {primaryClass: footerPrimary.class}),
341
+ ...(footerLegal?.class && {legalClass: footerLegal.class})
337
342
  };
338
343
  const footerJson = JSON.stringify(footer).replace(/<\/script>/gi, '<\\/script>');
339
344
 
@@ -993,6 +998,8 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
993
998
  // Item highlighting: the navbar is built in the browser, so the
994
999
  // config has to travel to site.js rather than being rendered here.
995
1000
  ...(navMenu.highlight && {highlight: navMenu.highlight}),
1001
+ // Designer classes for the navbar element itself (site.js adds them).
1002
+ ...(navMenu.class && {class: navMenu.class}),
996
1003
  ...(navMenu.style && {style: navMenu.style})
997
1004
  }
998
1005
  : (await readLegacyNavBackup()) || {brand: site.brand || {}, items: []};
@@ -1048,7 +1055,10 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
1048
1055
  copyright: site.footer?.copyright || '',
1049
1056
  // Highlighting is per menu, and the two footer columns are two menus.
1050
1057
  ...(footerPrimary?.highlight && {primaryHighlight: footerPrimary.highlight}),
1051
- ...(footerLegal?.highlight && {legalHighlight: footerLegal.highlight})
1058
+ ...(footerLegal?.highlight && {legalHighlight: footerLegal.highlight}),
1059
+ // Each column's menu-level designer classes, for its <nav>.
1060
+ ...(footerPrimary?.class && {primaryClass: footerPrimary.class}),
1061
+ ...(footerLegal?.class && {legalClass: footerLegal.class})
1052
1062
  };
1053
1063
  const footerJson = JSON.stringify(footer).replace(/<\/script>/gi, '<\\/script>');
1054
1064
 
@@ -457,25 +457,43 @@ export function isOffline(page, exclusions = null) {
457
457
  }
458
458
 
459
459
  /**
460
- * May this page appear in this viewer's search results? Drafts never; pages
461
- * in a disabled project never; pages that ask not to be indexed (frontmatter
462
- * or the SEO screen) never; otherwise exactly when the viewer could open the
463
- * page - checkVisibility() is the one visibility rule, the same the public
464
- * page renderer applies, so a role-gated page (`member`, `=candidate`,
465
- * `private`) is found only by someone it would be served to.
460
+ * Could this viewer open this page from a list? Published, in an enabled
461
+ * project, and visible to them. Site search and the Content API's page list
462
+ * share it; noindex is search's own business and is not part of it.
463
+ *
464
+ * `status !== 'published'`, not `=== 'draft'`: the public route has always
465
+ * 404ed every unpublished status, and search used to find a `scheduled` or
466
+ * status-less page that then 404ed.
467
+ *
468
+ * checkVisibility() is the one visibility rule, the same the public page
469
+ * renderer applies, so a role-gated page (`member`, `=candidate`, `private`)
470
+ * is listed only for someone it would be served to.
466
471
  *
467
472
  * @param {object} page
468
473
  * @param {object|null} [viewer] - resolved viewer, null for anonymous
469
474
  * @param {{offline?: Set<string>, noindex?: Set<string>}|null} [exclusions] - searchExclusions()
470
475
  * @returns {boolean}
471
476
  */
472
- export function isSearchable(page, viewer = null, exclusions = null) {
473
- if (!page || page.status === 'draft') return false;
477
+ export function isPubliclyListed(page, viewer = null, exclusions = null) {
478
+ if (!page || page.status !== 'published') return false;
474
479
  if (isOffline(page, exclusions)) return false;
475
- if (isNoindex(page, exclusions)) return false;
476
480
  return checkVisibility(viewer, page.visibility);
477
481
  }
478
482
 
483
+ /**
484
+ * May this page appear in this viewer's search results? Exactly when it is
485
+ * publicly listed for them (isPubliclyListed) and does not ask not to be
486
+ * indexed (frontmatter or the SEO screen).
487
+ *
488
+ * @param {object} page
489
+ * @param {object|null} [viewer] - resolved viewer, null for anonymous
490
+ * @param {{offline?: Set<string>, noindex?: Set<string>}|null} [exclusions] - searchExclusions()
491
+ * @returns {boolean}
492
+ */
493
+ export function isSearchable(page, viewer = null, exclusions = null) {
494
+ return isPubliclyListed(page, viewer, exclusions) && !isNoindex(page, exclusions);
495
+ }
496
+
479
497
  /**
480
498
  * Search a set of pages.
481
499
  *
@@ -536,8 +554,9 @@ export function searchPages(pages, query, {maxResults = 10, onError = () => {},
536
554
  * Which pages an anonymous visitor's search can return, and which it leaves
537
555
  * out - the same rule searchPages() applies (isSearchable() with no viewer).
538
556
  * Each page left out is counted once, under the first reason that applies:
539
- * a draft, then offline (its project is disabled), then restricted (private
540
- * or role-gated), then noindex (frontmatter or the SEO screen). A signed-in
557
+ * unpublished (`drafts` - any status but `published`, as isPubliclyListed()),
558
+ * then offline (its project is disabled), then restricted (private or
559
+ * role-gated), then noindex (frontmatter or the SEO screen). A signed-in
541
560
  * visitor may find more than `searchable` - the gated pages they can open.
542
561
  *
543
562
  * @param {object[]} pages
@@ -550,7 +569,7 @@ export function searchCoverage(pages = [], exclusions = null) {
550
569
  let restricted = 0;
551
570
  let noindex = 0;
552
571
  for (const p of pages) {
553
- if (p?.status === 'draft') drafts++;
572
+ if (p?.status !== 'published') drafts++;
554
573
  else if (isOffline(p, exclusions)) offline++;
555
574
  else if (!checkVisibility(null, p?.visibility)) restricted++;
556
575
  else if (isNoindex(p, exclusions)) noindex++;
@@ -137,7 +137,7 @@
137
137
  </script>
138
138
 
139
139
  <!-- Site initialisation -->
140
- <script src="/public/js/site.js?v=20261004-countdown" type="module"></script>
140
+ <script src="/public/js/site.js?v=20261009-menu-classes" type="module"></script>
141
141
  <script src="/public/js/collection-context.js?v=20260927-ctx" type="module"></script>
142
142
  {{ctxMenusModule}}
143
143