domma-cms 0.116.7 → 0.117.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.
@@ -17,6 +17,12 @@ export const API_TOKENS_COLLECTION_SLUG = 'api-tokens';
17
17
 
18
18
  const TOKEN_RE = /^dcms_[a-f0-9]{64}$/;
19
19
  const VERBS = ['create', 'read', 'update', 'delete'];
20
+
21
+ /**
22
+ * What a `{content}` scope may name - the Content API's resources. Content
23
+ * scopes are read-only (the API has no writes yet), so they carry no verbs.
24
+ */
25
+ export const CONTENT_RESOURCES = Object.freeze(['pages', 'menus', 'site', 'search']);
20
26
  const LAST_USED_THROTTLE_MS = 60_000;
21
27
 
22
28
  /** In-memory hash → entry cache; null = needs rebuild. */
@@ -75,16 +81,23 @@ function sanitise(entry) {
75
81
  }
76
82
 
77
83
  /**
78
- * Validate a scopes declaration: array of { collection, verbs[] }.
79
- * Empty array = the token covers every collection in its project.
84
+ * Validate a scopes declaration. Each scope is EITHER a collection scope
85
+ * `{collection, verbs?}` OR a content scope `{content}` (a Content API
86
+ * resource). Empty array = the token covers everything in its project.
80
87
  *
81
88
  * @param {*} scopes
82
89
  * @returns {string|null} Error message, or null when valid
83
90
  */
84
- function validateScopes(scopes) {
91
+ export function validateScopes(scopes) {
85
92
  if (scopes == null) return null;
86
93
  if (!Array.isArray(scopes)) return 'scopes must be an array';
87
94
  for (const s of scopes) {
95
+ if (s && typeof s === 'object' && s.content !== undefined) {
96
+ if (s.collection !== undefined) return 'a scope names either a collection or a content resource, not both';
97
+ if (!CONTENT_RESOURCES.includes(s.content)) return `unknown content resource "${s.content}"`;
98
+ if (s.verbs != null) return 'content scopes are read-only and take no verbs';
99
+ continue;
100
+ }
88
101
  if (!s || typeof s !== 'object' || typeof s.collection !== 'string' || !s.collection.trim()) {
89
102
  return 'each scope needs a collection slug';
90
103
  }
@@ -101,18 +114,61 @@ function validateScopes(scopes) {
101
114
  * Check whether a token's scopes permit an operation on a collection.
102
115
  * Empty/missing scopes → everything in the token's project is allowed.
103
116
  *
104
- * @param {Array<{collection: string, verbs?: string[]}>} scopes
117
+ * @param {Array<{collection?: string, verbs?: string[], content?: string}>} scopes
105
118
  * @param {string} collectionSlug
106
119
  * @param {string} verb - create | read | update | delete
107
120
  * @returns {boolean}
108
121
  */
109
122
  export function scopeAllows(scopes, collectionSlug, verb) {
110
123
  if (!Array.isArray(scopes) || scopes.length === 0) return true;
111
- const scope = scopes.find(s => s.collection === collectionSlug);
124
+ // Content scopes never match: a collection named `pages` is still a collection.
125
+ const scope = scopes.find(s => s.collection !== undefined && s.collection === collectionSlug);
112
126
  if (!scope) return false;
113
127
  return !Array.isArray(scope.verbs) || scope.verbs.length === 0 || scope.verbs.includes(verb);
114
128
  }
115
129
 
130
+ /**
131
+ * Does a token's scope list permit a Content API resource? Empty = everything
132
+ * in the project. A list that names no content resource permits none - a token
133
+ * scoped to collections is never widened by the Content API existing.
134
+ *
135
+ * @param {object[]} scopes
136
+ * @param {string} resource - one of CONTENT_RESOURCES
137
+ * @returns {boolean}
138
+ */
139
+ export function contentScopeAllows(scopes, resource) {
140
+ if (!Array.isArray(scopes) || scopes.length === 0) return true;
141
+ return scopes.some(s => s.content === resource);
142
+ }
143
+
144
+ const BEARER_RE = /^Bearer (dcms_[a-f0-9]{64})$/;
145
+
146
+ /**
147
+ * The strict token check, shared by every externally consumable gate - the
148
+ * collections API (and the API Builder endpoints that reuse it) and the
149
+ * Content API. A token is the ONLY credential accepted: callers decide when
150
+ * token mode applies, and this never looks at a JWT.
151
+ *
152
+ * `project` binds the token to the resource's project. Omit it when the
153
+ * caller binds per resource instead (a Content API page list spans projects
154
+ * and is filtered to the token's). `allows` receives the token's scopes.
155
+ *
156
+ * @param {{headers?: object}} request
157
+ * @param {{project?: string, allows: (scopes: object[]) => boolean, projectError?: string}} opts
158
+ * @returns {Promise<{ok: true, token: object} | {ok: false, status: 401|403, error: string}>}
159
+ */
160
+ export async function authenticateApiToken(request, {project, allows, projectError}) {
161
+ const match = (request.headers?.authorization || '').match(BEARER_RE);
162
+ if (!match) return {ok: false, status: 401, error: 'API token required'};
163
+ const token = await validateToken(match[1]);
164
+ if (!token) return {ok: false, status: 401, error: 'Invalid, disabled or expired API token'};
165
+ if (project !== undefined && token.project !== project) {
166
+ return {ok: false, status: 403, error: projectError || "Token is not valid for this resource's project"};
167
+ }
168
+ if (!allows(token.scopes)) return {ok: false, status: 403, error: 'Token scope does not permit this operation'};
169
+ return {ok: true, token};
170
+ }
171
+
116
172
  /**
117
173
  * Create a new token. The plaintext is returned ONCE here and never again.
118
174
  *
@@ -167,7 +167,7 @@ export async function createPage(urlPath, frontmatter, body) {
167
167
 
168
168
  await fs.writeFile(filePath, serialiseMarkdown(meta, body || ''), 'utf8');
169
169
  const page = await readPageFile(filePath);
170
- await cache.invalidateTags([`page:${urlPath}`, 'sitemap']);
170
+ await cache.invalidateTags([`page:${urlPath}`, 'sitemap', 'pages:list']);
171
171
  hooks.emit('content:pageCreated', {page, urlPath});
172
172
  return page;
173
173
  }
@@ -222,7 +222,7 @@ export async function updatePage(urlPath, frontmatter, body, {author} = {}) {
222
222
 
223
223
  await fs.writeFile(filePath, serialiseMarkdown(meta, body ?? existingContent), 'utf8');
224
224
  const page = await readPageFile(filePath);
225
- await cache.invalidateTags([`page:${urlPath}`, 'sitemap']);
225
+ await cache.invalidateTags([`page:${urlPath}`, 'sitemap', 'pages:list']);
226
226
  hooks.emit('content:pageUpdated', {page, urlPath});
227
227
  return page;
228
228
  }
@@ -240,7 +240,7 @@ export async function renamePage(oldUrlPath, newUrlPath) {
240
240
  const newFile = urlPathToFilePath(newUrlPath);
241
241
  await fs.mkdir(path.dirname(newFile), { recursive: true });
242
242
  await fs.rename(oldFile, newFile);
243
- await cache.invalidateTags([`page:${oldUrlPath}`, `page:${newUrlPath}`, 'sitemap']);
243
+ await cache.invalidateTags([`page:${oldUrlPath}`, `page:${newUrlPath}`, 'sitemap', 'pages:list']);
244
244
  try {
245
245
  await renameVersionDir(oldUrlPath, newUrlPath);
246
246
  } catch {
@@ -259,7 +259,7 @@ export async function renamePage(oldUrlPath, newUrlPath) {
259
259
  export async function deletePage(urlPath) {
260
260
  const filePath = await resolveExistingFilePath(urlPath);
261
261
  await fs.unlink(filePath);
262
- await cache.invalidateTags([`page:${urlPath}`, 'sitemap']);
262
+ await cache.invalidateTags([`page:${urlPath}`, 'sitemap', 'pages:list']);
263
263
  hooks.emit('content:pageDeleted', {urlPath});
264
264
  try {
265
265
  await deleteAllVersions(urlPath);
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Content API - settings and payloads.
3
+ *
4
+ * Settings live in config/content-api.json (optional; absence = the defaults,
5
+ * which are OFF - an upgrade must not expose a site that never chose to be).
6
+ *
7
+ * Every payload is built from an ALLOWLIST. Frontmatter is open-ended (authors
8
+ * and plugins add keys freely) and site.json holds SMTP credentials, so a
9
+ * spread-then-delete is one forgotten key away from a leak.
10
+ */
11
+ import {getConfig, saveConfig} from '../config.js';
12
+ import {CONTENT_RESOURCES} from './apiTokens.js';
13
+ import {isPubliclyListed} from './search.js';
14
+ import {plural} from './sidebarBadges.js';
15
+
16
+ export const FORMATS = Object.freeze(['html', 'markdown']);
17
+
18
+ export const CONTENT_API_DEFAULTS = Object.freeze({
19
+ enabled: false,
20
+ access: 'token',
21
+ resources: Object.freeze(Object.fromEntries(CONTENT_RESOURCES.map(k => [k, Object.freeze({enabled: true})]))),
22
+ formats: Object.freeze(['html']),
23
+ listable: true,
24
+ maxLimit: 200
25
+ });
26
+
27
+ const MAX_LIMIT = 200;
28
+
29
+ /**
30
+ * @param {object} [raw] - config/content-api.json as stored, or a patch merged onto it
31
+ * @returns {object} A fresh, complete settings object
32
+ */
33
+ export function normaliseContentApiSettings(raw) {
34
+ const r = raw && typeof raw === 'object' ? raw : {};
35
+ const access = typeof r.access === 'string' && r.access.trim() ? r.access.trim() : CONTENT_API_DEFAULTS.access;
36
+ const formats = Array.isArray(r.formats) ? FORMATS.filter(f => r.formats.includes(f)) : [];
37
+ const limit = Math.trunc(Number(r.maxLimit));
38
+ return {
39
+ enabled: r.enabled === true,
40
+ access,
41
+ resources: Object.fromEntries(CONTENT_RESOURCES.map(k => [k, {enabled: r.resources?.[k]?.enabled !== false}])),
42
+ formats: formats.length ? formats : [...CONTENT_API_DEFAULTS.formats],
43
+ listable: r.listable !== false,
44
+ maxLimit: Number.isFinite(limit) ? Math.min(MAX_LIMIT, Math.max(1, limit)) : CONTENT_API_DEFAULTS.maxLimit
45
+ };
46
+ }
47
+
48
+ /** Current settings, defaults filled in. A site with no file gets the (off) defaults. */
49
+ export function getContentApiSettings() {
50
+ try {
51
+ return normaliseContentApiSettings(getConfig('content-api'));
52
+ } catch {
53
+ return normaliseContentApiSettings({});
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Save settings, returning what was actually stored.
59
+ *
60
+ * @param {object} patch - Partial or complete settings
61
+ * @returns {object}
62
+ */
63
+ export function saveContentApiSettings(patch) {
64
+ const next = normaliseContentApiSettings({...getContentApiSettings(), ...(patch || {})});
65
+ saveConfig('content-api', next);
66
+ return next;
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Payloads
71
+ // ---------------------------------------------------------------------------
72
+
73
+ const SUMMARY_KEYS = ['urlPath', 'title', 'description', 'tags', 'category', 'layout', 'updatedAt'];
74
+ const DETAIL_KEYS = [...SUMMARY_KEYS, 'createdAt'];
75
+ const SEO_KEYS = ['title', 'description', 'image', 'canonical', 'noindex'];
76
+
77
+ const pick = (obj, keys) => Object.fromEntries(keys.filter(k => obj?.[k] !== undefined).map(k => [k, obj[k]]));
78
+
79
+ /**
80
+ * The Domma behaviour a page's HTML relies on: the markup public/js/site.js
81
+ * (and public/js/effects.js) initialise. A client rendering `html` without Domma gets these inert, so
82
+ * the API names them. `usedComponents` does not cover them - it lists only
83
+ * custom `[component]`s, and a page of tabs reports none.
84
+ *
85
+ * Matched on real attributes only: markup shown in a code block is escaped
86
+ * (`&lt;div class=&quot;tabs`) and never matches. Keep in step with site.js.
87
+ */
88
+ const DOMMA_MARKERS = [
89
+ ['tabs', {cls: 'tabs'}],
90
+ ['accordion', {cls: 'accordion'}],
91
+ ['carousel', {cls: 'carousel'}],
92
+ ['countdown', {cls: 'dm-countdown'}],
93
+ ['progression', {cls: 'dm-progression'}],
94
+ ['slideover', {cls: 'dm-so-trigger'}],
95
+ ['cta', {cls: 'dm-cta-trigger'}],
96
+ ['collapsible', {attr: 'data-collapsible'}],
97
+ ['tooltip', {attr: 'data-tooltip'}],
98
+ ['reveal', {attr: 'data-reveal'}],
99
+ ['collection-table', {attr: 'data-collection-table'}],
100
+ ['form', {attr: 'data-form-inline'}],
101
+ ['collection', {cls: 'dm-collection-hydrate'}],
102
+ ['page-config', {cls: 'dm-page-config'}],
103
+ // Every effect (scribe, reveal, celebrate, counter, …) is a dm-fx-<name> class.
104
+ ['effects', {prefix: 'dm-fx-'}]
105
+ ];
106
+
107
+ /**
108
+ * @param {string} html
109
+ * @returns {string[]} Marker names found, in DOMMA_MARKERS order
110
+ */
111
+ export function domma(html) {
112
+ if (typeof html !== 'string' || !html) return [];
113
+ const classes = new Set();
114
+ for (const m of html.matchAll(/<[^>]*?\sclass="([^"]*)"/g)) {
115
+ for (const c of m[1].split(/\s+/)) if (c) classes.add(c);
116
+ }
117
+ const hasAttr = (name) => new RegExp(`<[^>]*\\s${name}(?=[\\s=/>])`).test(html);
118
+ return DOMMA_MARKERS
119
+ .filter(([, m]) => {
120
+ if (m.prefix) return [...classes].some(c => c.startsWith(m.prefix));
121
+ return m.cls ? classes.has(m.cls) : hasAttr(m.attr);
122
+ })
123
+ .map(([name]) => name);
124
+ }
125
+
126
+ /**
127
+ * A page in a list: enough to link to it, never its body - the list must stay
128
+ * cheap enough to run over every page without rendering any.
129
+ *
130
+ * @param {object} page - a listPages() row
131
+ * @returns {object}
132
+ */
133
+ export function pageSummary(page) {
134
+ return pick(page, SUMMARY_KEYS);
135
+ }
136
+
137
+ /**
138
+ * One page. `html` is the body only (page.html), not the full document with
139
+ * navbar and footer; `markdown` is the source, shortcodes and all.
140
+ *
141
+ * @param {object} page - a getPage() result the caller is allowed to see
142
+ * @param {{format: 'html'|'markdown'|'both'}} opts
143
+ * @returns {object}
144
+ */
145
+ export function pageDetail(page, {format}) {
146
+ const out = pick(page, DETAIL_KEYS);
147
+ if (page.seo && typeof page.seo === 'object') out.seo = pick(page.seo, SEO_KEYS);
148
+ if (format === 'html' || format === 'both') out.html = page.html || '';
149
+ if (format === 'markdown' || format === 'both') out.markdown = page.content || '';
150
+ const custom = Array.isArray(page.usedComponents) ? page.usedComponents : [];
151
+ const components = [...new Set([...domma(page.html), ...custom])];
152
+ out.requires = {domma: components.length > 0, components};
153
+ out.perViewer = Boolean(page.perViewer);
154
+ return out;
155
+ }
156
+
157
+ /**
158
+ * Site settings safe for any caller: exactly what every public page already
159
+ * shows - the title, navbar brand, footer (hidden links stay hidden, as in
160
+ * renderer.js), social links, the SEO defaults in its meta tags, and the theme
161
+ * class. `tagline`, `url`, `smtp` and the admin settings never reach a public
162
+ * page, so they never reach this either.
163
+ *
164
+ * @param {object} [site] - config/site.json
165
+ * @returns {object}
166
+ */
167
+ export function publicSiteSettings(site = {}) {
168
+ const out = pick(site, ['title', 'social', 'theme']);
169
+ if (site.brand && typeof site.brand === 'object') out.brand = pick(site.brand, ['text', 'logo', 'url']);
170
+ if (site.footer && typeof site.footer === 'object') {
171
+ out.footer = {...site.footer, links: (site.footer.links || []).filter(l => !l?.hidden)};
172
+ }
173
+ if (site.seo && typeof site.seo === 'object') out.seo = pick(site.seo, ['defaultTitle', 'titleSeparator', 'defaultDescription']);
174
+ return out;
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // The admin screen: coverage, and the sidebar badge
179
+ // ---------------------------------------------------------------------------
180
+
181
+ /**
182
+ * How many pages the API can serve an anonymous caller - the same rule as its
183
+ * page list (isPubliclyListed, then `api: false`). A signed-in role-mode caller
184
+ * may get more: the gated pages they can open.
185
+ *
186
+ * @param {object[]} pages - listPages()
187
+ * @param {{offline?: Set<string>, noindex?: Set<string>}|null} [exclusions] - searchExclusions()
188
+ * @returns {{served: number, keptOff: number, total: number}}
189
+ */
190
+ export function contentApiCoverage(pages = [], exclusions = null) {
191
+ let served = 0;
192
+ let keptOff = 0;
193
+ for (const p of pages) {
194
+ if (!isPubliclyListed(p, null, exclusions)) continue;
195
+ if (p.api === false) keptOff++;
196
+ else served++;
197
+ }
198
+ return {served, keptOff, total: pages.length};
199
+ }
200
+
201
+ /** How a settings `access` value reads to a person. */
202
+ export function accessLabel(access) {
203
+ if (access === 'public') return 'public';
204
+ if (access === 'token') return 'API token';
205
+ return `role: ${access} and above`;
206
+ }
207
+
208
+ const FORMAT_LABEL = {html: 'HTML', markdown: 'Markdown'};
209
+
210
+ /**
211
+ * The sidebar badge on System › Content API (its countUrl, GET
212
+ * /api/content-api/_count): the pages served, and how the API is set up in
213
+ * the popover. An inventory, not a signal - plain text throughout.
214
+ *
215
+ * @param {object} settings
216
+ * @param {object[]} pages
217
+ * @param {object|null} [exclusions]
218
+ */
219
+ export function contentApiBadge(settings, pages = [], exclusions = null) {
220
+ const s = normaliseContentApiSettings(settings);
221
+ const c = contentApiCoverage(pages, exclusions);
222
+ const served = plural(c.served, 'page');
223
+ const access = accessLabel(s.access);
224
+ const resources = CONTENT_RESOURCES.filter(k => s.resources[k].enabled);
225
+ return {
226
+ count: c.served,
227
+ label: s.enabled
228
+ ? `Content API is on (${access}) - ${served} served`
229
+ : `Content API is off - ${served} would be served`,
230
+ tone: 'info',
231
+ details: {
232
+ title: 'Content API',
233
+ rows: [
234
+ {label: 'Status', value: s.enabled ? 'On' : 'Off'},
235
+ {label: 'Access', value: access.charAt(0).toUpperCase() + access.slice(1)},
236
+ {label: 'Resources', value: resources.length ? resources.join(', ') : 'none'},
237
+ {label: 'Formats', value: s.formats.map(f => FORMAT_LABEL[f]).join(', ')},
238
+ {label: 'Page list', value: s.listable ? 'On' : 'Off - single pages only'},
239
+ {label: 'Pages served', value: String(c.served)},
240
+ {label: 'Kept off (api: false)', value: String(c.keptOff)}
241
+ ],
242
+ items: [],
243
+ empty: s.enabled
244
+ ? 'Callers get the published pages an anonymous visitor could open, except pages marked api: false.'
245
+ : 'The Content API is switched off - every /api/content/v1 address answers 404.'
246
+ }
247
+ };
248
+ }
@@ -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: [
@@ -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++;