domma-cms 0.116.8 → 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.
- package/CLAUDE.md +7 -4
- package/admin/js/app.js +2 -2
- package/admin/js/lib/api-tokens-arrange.js +3 -3
- package/admin/js/lib/content-api-arrange.js +1 -0
- package/admin/js/templates/api-reference.html +2 -1
- package/admin/js/templates/api-tokens.html +3 -3
- package/admin/js/templates/content-api.html +208 -0
- package/admin/js/templates/docs/api-content.html +183 -0
- package/admin/js/templates/docs/api-external.html +2 -1
- package/admin/js/templates/docs/usage-editions.html +1 -1
- package/admin/js/views/api-tokens.js +6 -6
- package/admin/js/views/content-api.js +7 -0
- package/admin/js/views/doc-pages.js +1 -1
- package/admin/js/views/index.js +1 -1
- package/package.json +1 -1
- package/server/routes/api/collections.js +10 -17
- package/server/routes/api/content-api.js +70 -0
- package/server/routes/api/content-public.js +299 -0
- package/server/routes/public.js +17 -68
- package/server/server.js +11 -4
- package/server/services/apiTokens.js +61 -5
- package/server/services/content.js +4 -4
- package/server/services/contentApi.js +248 -0
- package/server/services/pagePublishing.js +85 -0
- package/server/services/permissionRegistry.js +1 -1
- package/server/services/search.js +31 -12
|
@@ -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
|
+
* (`<div class="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
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
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
|
|
473
|
-
if (!page || page.status
|
|
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
|
-
*
|
|
540
|
-
*
|
|
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
|
|
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++;
|