domma-cms 1.8.34 → 1.8.36
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 +12 -0
- package/admin/index.html +1 -1
- package/admin/js/app.js +1 -1
- package/admin/js/lib/seo-analyse.js +1 -1
- package/admin/js/lib/seo-settings.js +2 -2
- package/admin/js/templates/docs/usage-search-engines.html +84 -0
- package/admin/js/templates/documentation.html +4 -0
- package/admin/js/views/doc-pages.js +1 -1
- package/admin/js/views/seo.js +15 -14
- package/docs/DommaFeatures.md +9 -2
- package/docs/plugin-development.md +1 -1
- package/package.json +1 -1
- package/plugins/booking/CLAUDE.md +5 -0
- package/plugins/booking/admin/views/settings.js +1 -1
- package/plugins/booking/docs/guide.md +2 -0
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/plugin.public.js +15 -2
- package/plugins/booking/tests/seo.test.js +71 -0
- package/plugins/free-tier.lock.json +18 -16
- package/plugins/shopping-cart/CLAUDE.md +9 -0
- package/plugins/shopping-cart/admin/views/settings.js +1 -1
- package/plugins/shopping-cart/docs/guide.md +2 -1
- package/plugins/shopping-cart/plugin.js +18 -0
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/plugin.public.js +39 -4
- package/plugins/shopping-cart/tests/seo.test.js +95 -0
- package/public/js/site.js +1 -1
- package/public/js/vendor/dompurify/LICENSE +202 -0
- package/public/js/vendor/dompurify/LICENSE-MPL +373 -0
- package/public/js/vendor/dompurify/purify.min.js +7 -0
- package/scripts/build.js +3 -0
- package/server/routes/api/collections.js +2 -0
- package/server/routes/api/effects.js +5 -1
- package/server/routes/api/forms.js +2 -0
- package/server/routes/api/views.js +2 -0
- package/server/routes/public.js +36 -1
- package/server/server.js +1 -0
- package/server/services/docs.js +1 -0
- package/server/services/hooks.js +3 -2
- package/server/services/markdown.js +200 -120
- package/server/services/nojsLinks.js +130 -0
- package/server/services/renderer.js +23 -6
- package/server/services/sitemap.js +100 -2
- package/server/templates/page.html +17 -12
|
@@ -703,6 +703,8 @@ export async function collectionsRoutes(fastify) {
|
|
|
703
703
|
* client-side hydration (e.g. "My Applications").
|
|
704
704
|
*/
|
|
705
705
|
async function publicListEntries(request, reply) {
|
|
706
|
+
// Allowed in robots.txt so Google can render pages that read it; the JSON itself is never a search result.
|
|
707
|
+
reply.header('x-robots-tag', 'noindex');
|
|
706
708
|
const schema = await getCollection(request.params.slug);
|
|
707
709
|
if (!schema) return reply.status(404).send({ error: 'Collection not found' });
|
|
708
710
|
|
|
@@ -92,7 +92,11 @@ export async function effectsRoutes(fastify, opts = {}) {
|
|
|
92
92
|
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
93
93
|
const requireAdmin = opts.requireAdmin || defaultRequireAdmin;
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
// Allowed in robots.txt so Google can render pages that read it; the JSON itself is never a search result.
|
|
96
|
+
fastify.get('/effects/settings', async (request, reply) => {
|
|
97
|
+
reply.header('x-robots-tag', 'noindex');
|
|
98
|
+
return loadSettings();
|
|
99
|
+
});
|
|
96
100
|
|
|
97
101
|
fastify.get('/effects/_count', {preHandler: [authenticate]}, async () => {
|
|
98
102
|
const {effectsByCategory} = await import('../../../admin/js/lib/effect-defs.js');
|
|
@@ -505,6 +505,8 @@ export async function formsRoutes(fastify) {
|
|
|
505
505
|
// GET /forms/:slug/public - get form for public rendering (no actions)
|
|
506
506
|
// -----------------------------------------------------------------------
|
|
507
507
|
fastify.get('/forms/:slug/public', async (request, reply) => {
|
|
508
|
+
// Allowed in robots.txt so Google can render pages that read it; the JSON itself is never a search result.
|
|
509
|
+
reply.header('x-robots-tag', 'noindex');
|
|
508
510
|
try {
|
|
509
511
|
const form = await readForm(request.params.slug);
|
|
510
512
|
const { actions: _actions, ...safe } = form;
|
|
@@ -192,6 +192,8 @@ export async function viewsRoutes(fastify) {
|
|
|
192
192
|
// -------------------------------------------------------------------------
|
|
193
193
|
|
|
194
194
|
fastify.get('/views/:slug/public', async (request, reply) => {
|
|
195
|
+
// Allowed in robots.txt so Google can render pages that read it; the JSON itself is never a search result.
|
|
196
|
+
reply.header('x-robots-tag', 'noindex');
|
|
195
197
|
let view;
|
|
196
198
|
try {
|
|
197
199
|
view = await getView(request.params.slug);
|
package/server/routes/public.js
CHANGED
|
@@ -10,7 +10,8 @@ import {getPage, getPageMtime} from '../services/content.js';
|
|
|
10
10
|
import {getProjectForPage, isProjectEnabled} from '../services/projects.js';
|
|
11
11
|
import {footerBlockCacheTags, renderPage} from '../services/renderer.js';
|
|
12
12
|
import {resolvePublishedPage} from '../services/pagePublishing.js';
|
|
13
|
-
import {buildRobotsTxt, generate as generateSitemap} from '../services/sitemap.js';
|
|
13
|
+
import {buildLlmsTxt, buildRobotsTxt, generate as generateSitemap, llmsTxtAllowed} from '../services/sitemap.js';
|
|
14
|
+
import {seoEngine} from '../services/seo/index.js';
|
|
14
15
|
import {resolveViewer} from '../services/viewerSession.js';
|
|
15
16
|
import {validSessionCookie, newSessionCookie} from '../services/shortcodeExpiry.js';
|
|
16
17
|
import {touchPreviewLink, verifyPreviewToken} from '../services/previewLinks.js';
|
|
@@ -44,6 +45,22 @@ export function getBaseUrl(request) {
|
|
|
44
45
|
import {sendHtml} from '../services/htmlResponse.js';
|
|
45
46
|
export {sendHtml};
|
|
46
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Escape user-controlled strings before interpolating into HTML.
|
|
50
|
+
* Prevents reflected XSS in the built-in 403 and 404 pages.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} str
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
function escapeHtml(str) {
|
|
56
|
+
return String(str)
|
|
57
|
+
.replace(/&/g, '&')
|
|
58
|
+
.replace(/</g, '<')
|
|
59
|
+
.replace(/>/g, '>')
|
|
60
|
+
.replace(/"/g, '"')
|
|
61
|
+
.replace(/'/g, ''');
|
|
62
|
+
}
|
|
63
|
+
|
|
47
64
|
export async function publicRoutes(fastify) {
|
|
48
65
|
// Admin panel: serve index.html for all /admin/* paths (SPA fallback)
|
|
49
66
|
fastify.get('/admin', async (request, reply) => {
|
|
@@ -67,6 +84,24 @@ export async function publicRoutes(fastify) {
|
|
|
67
84
|
return reply.type('application/xml').send(xml);
|
|
68
85
|
});
|
|
69
86
|
|
|
87
|
+
// SEO: llms.txt (llmstxt.org) - the sitemap's pages as a Markdown list for
|
|
88
|
+
// AI assistants. Cached and invalidated with the sitemap and site settings. Off when the SEO
|
|
89
|
+
// settings switch it off or ask every crawler to stay away; with SEO
|
|
90
|
+
// switched off there are no settings, and like the sitemap it is served.
|
|
91
|
+
fastify.get('/llms.txt', async (request, reply) => {
|
|
92
|
+
if (!llmsTxtAllowed(seoEngine()?.settings?.() || null)) {
|
|
93
|
+
return reply.status(404).type('text/plain; charset=utf-8').send('Not found\n');
|
|
94
|
+
}
|
|
95
|
+
const baseUrl = getBaseUrl(request);
|
|
96
|
+
const txt = await cache.wrap(
|
|
97
|
+
`llms:txt:${baseUrl}`,
|
|
98
|
+
() => buildLlmsTxt(baseUrl, {log: fastify.log}),
|
|
99
|
+
// `site` too: the header is the site's title and description.
|
|
100
|
+
{tags: ['sitemap', 'site'], ttlSeconds: 600}
|
|
101
|
+
);
|
|
102
|
+
return reply.type('text/plain; charset=utf-8').send(txt);
|
|
103
|
+
});
|
|
104
|
+
|
|
70
105
|
// SEO: robots.txt - cheap to build, no cache wrap needed.
|
|
71
106
|
fastify.get('/robots.txt', async (request, reply) => {
|
|
72
107
|
return reply.type('text/plain').send(buildRobotsTxt(getBaseUrl(request)));
|
package/server/server.js
CHANGED
|
@@ -705,6 +705,7 @@ registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Collecti
|
|
|
705
705
|
registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Blocks', url: '#/docs/usage/blocks', icon: 'box'}});
|
|
706
706
|
registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Built-in Tools', url: '#/docs/usage/tools', icon: 'tool'}});
|
|
707
707
|
registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Dashboard', url: '#/docs/usage/dashboard', icon: 'home'}});
|
|
708
|
+
registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Search Engines & AI', url: '#/docs/usage/search-engines', icon: 'search'}});
|
|
708
709
|
registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Editions & Licences', url: '#/docs/usage/editions', icon: 'key'}});
|
|
709
710
|
registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'Forms', url: '#/docs/api/forms', icon: 'layout'}});
|
|
710
711
|
registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'Scaffold', url: '#/docs/api/scaffold', icon: 'package'}});
|
package/server/services/docs.js
CHANGED
|
@@ -62,6 +62,7 @@ const MAP = [
|
|
|
62
62
|
['usage-blocks', 'usage/blocks', 'Blocks'],
|
|
63
63
|
['usage-tools', 'usage/tools', 'Built-in Tools'],
|
|
64
64
|
['usage-dashboard', 'usage/dashboard', 'Dashboard'],
|
|
65
|
+
['usage-search-engines', 'usage/search-engines', 'Search Engines & AI'],
|
|
65
66
|
['tutorial-crud', 'tutorials/crud', 'Building a CRUD App'],
|
|
66
67
|
['tutorial-editor', 'tutorials/editor', 'The Editor'],
|
|
67
68
|
['tutorial-plugin', 'tutorials/plugin', 'Writing a Tool'],
|
package/server/services/hooks.js
CHANGED
|
@@ -465,8 +465,9 @@ export async function sendDirectMessage({fromUserId, toUserId, text} = {}) {
|
|
|
465
465
|
//
|
|
466
466
|
// Core lists its own pages. Anything a plugin serves (blog posts, job listings)
|
|
467
467
|
// is invisible to it, so the plugin registers a source. `list({baseUrl})`
|
|
468
|
-
// resolves to `[{urlPath, title?, lastmod?, changefreq?, priority?, image?}]`,
|
|
469
|
-
// public items only - the sitemap is for anonymous visitors.
|
|
468
|
+
// resolves to `[{urlPath, title?, lastmod?, changefreq?, priority?, image?, description?}]`,
|
|
469
|
+
// public items only - the sitemap is for anonymous visitors. The same list feeds
|
|
470
|
+
// /llms.txt, where `description` follows each link. A source that
|
|
470
471
|
// throws is skipped (and logged) rather than taking the sitemap down.
|
|
471
472
|
//
|
|
472
473
|
// Transforms beside it (registerTransform):
|
|
@@ -989,6 +989,138 @@ function renderCollectionListgroup(entries, opts, ctxOpts = {}) {
|
|
|
989
989
|
return `${styleTag}<div class="${wrapperClasses.join(' ')}"${ctx}>\n${items}\n</div>`;
|
|
990
990
|
}
|
|
991
991
|
|
|
992
|
+
/**
|
|
993
|
+
* Draw a saved view's rows in the shortcode's display (table, cards, list,
|
|
994
|
+
* accordion, timeline, carousel, listgroup or block). Shared by the static
|
|
995
|
+
* `[view]` render and the no-JS fallback inside the interactive `[view]`
|
|
996
|
+
* shell, so a robot that runs no JavaScript reads the same rows the browser
|
|
997
|
+
* shows. The caller has already checked `canReadView` and run the pipeline.
|
|
998
|
+
*
|
|
999
|
+
* @param {string} slug
|
|
1000
|
+
* @param {Record<string, string>} attrs
|
|
1001
|
+
* @param {object} viewConfig
|
|
1002
|
+
* @param {Array<{id: string, data: object}>} entries
|
|
1003
|
+
* @returns {Promise<string>}
|
|
1004
|
+
*/
|
|
1005
|
+
async function renderViewEntries(slug, attrs, viewConfig, entries) {
|
|
1006
|
+
const displayAttr = attrs.display || '';
|
|
1007
|
+
const emptyMsg = attrs.empty || 'No results found';
|
|
1008
|
+
const titleField = attrs['title-field'] || '';
|
|
1009
|
+
const columns = attrs.columns || '3';
|
|
1010
|
+
const fieldFilter = attrs.fields ? attrs.fields.split(',').map(f => f.trim()).filter(Boolean) : null;
|
|
1011
|
+
|
|
1012
|
+
const ctaOpts = attrs.action ? {
|
|
1013
|
+
action: attrs.action,
|
|
1014
|
+
label: attrs['cta-label'] || 'Run',
|
|
1015
|
+
style: attrs['cta-style'] || 'primary',
|
|
1016
|
+
icon: attrs['cta-icon'] || '',
|
|
1017
|
+
confirm: attrs['cta-confirm'] || ''
|
|
1018
|
+
} : null;
|
|
1019
|
+
|
|
1020
|
+
let replacement = `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
|
|
1021
|
+
|
|
1022
|
+
// Resolve the view's underlying collection schema to compute export
|
|
1023
|
+
// permission. Views don't own a schema themselves - [dis]allowing
|
|
1024
|
+
// export must fall back CLOSED if the source collection can't be
|
|
1025
|
+
// resolved, since an open fallback would be a permission bypass.
|
|
1026
|
+
const sourceSlug = viewConfig.pipeline?.source || '';
|
|
1027
|
+
const sourceSchema = sourceSlug ? await getCollection(sourceSlug) : null;
|
|
1028
|
+
const ctxOpts = sourceSchema
|
|
1029
|
+
? {
|
|
1030
|
+
slug: sourceSlug,
|
|
1031
|
+
export: resolveExportConfig(sourceSchema.export, attrs, getRoleHierarchy().at(-1)),
|
|
1032
|
+
itemClass: attrs['item-class']
|
|
1033
|
+
}
|
|
1034
|
+
: {slug: sourceSlug || undefined, export: {allowed: false, access: null, formats: []}, itemClass: attrs['item-class']};
|
|
1035
|
+
|
|
1036
|
+
// Derive visible fields from attr filter or first entry's data keys
|
|
1037
|
+
let fields;
|
|
1038
|
+
if (fieldFilter?.length) {
|
|
1039
|
+
fields = fieldFilter.map(name => ({ name, label: name }));
|
|
1040
|
+
} else if (entries.length) {
|
|
1041
|
+
fields = Object.keys(entries[0].data || {})
|
|
1042
|
+
.filter(k => !['_id', 'id', 'meta'].includes(k))
|
|
1043
|
+
.map(k => ({name: k, label: k}));
|
|
1044
|
+
} else {
|
|
1045
|
+
fields = [];
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const display = displayAttr || viewConfig?.display?.mode || 'table';
|
|
1049
|
+
|
|
1050
|
+
if (display === 'cards') {
|
|
1051
|
+
replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts, ctxOpts);
|
|
1052
|
+
} else if (display === 'list') {
|
|
1053
|
+
replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts, ctxOpts);
|
|
1054
|
+
} else if (display === 'accordion') {
|
|
1055
|
+
const accordionTitleField = attrs['title-field'] || 'title';
|
|
1056
|
+
const bodyField = attrs['body-field'] || 'description';
|
|
1057
|
+
const multiple = attrs.multiple === 'true';
|
|
1058
|
+
replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg, ctxOpts);
|
|
1059
|
+
} else if (display === 'timeline') {
|
|
1060
|
+
const timelineLayout = ['vertical', 'centred', 'horizontal'].includes(attrs.layout) ? attrs.layout : 'vertical';
|
|
1061
|
+
const timelineTheme = ['minimal', 'corporate', 'modern'].includes(attrs.theme) ? attrs.theme : 'minimal';
|
|
1062
|
+
const timelineMode = ['timeline', 'roadmap'].includes(attrs.mode) ? attrs.mode : 'timeline';
|
|
1063
|
+
replacement = renderCollectionTimeline(entries, {
|
|
1064
|
+
titleField: attrs['title-field'] || 'title',
|
|
1065
|
+
dateField: attrs['date-field'] || '',
|
|
1066
|
+
statusField: attrs['status-field'] || '',
|
|
1067
|
+
iconField: attrs['icon-field'] || '',
|
|
1068
|
+
bodyField: attrs['body-field'] || '',
|
|
1069
|
+
layout: timelineLayout,
|
|
1070
|
+
theme: timelineTheme,
|
|
1071
|
+
mode: timelineMode,
|
|
1072
|
+
emptyMsg,
|
|
1073
|
+
}, ctxOpts);
|
|
1074
|
+
} else if (display === 'carousel') {
|
|
1075
|
+
const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
|
|
1076
|
+
replacement = missing
|
|
1077
|
+
? blockNotFoundHtml(attrs.block)
|
|
1078
|
+
: renderCollectionCarousel(entries, {
|
|
1079
|
+
imageField: attrs['image-field'] || '',
|
|
1080
|
+
titleField: attrs['title-field'] || 'title',
|
|
1081
|
+
bodyField: attrs['body-field'] || '',
|
|
1082
|
+
blockTemplate: tpl,
|
|
1083
|
+
blockName: attrs.block || '',
|
|
1084
|
+
blockCss: css,
|
|
1085
|
+
dataAttrs: attrs,
|
|
1086
|
+
emptyMsg,
|
|
1087
|
+
}, ctxOpts);
|
|
1088
|
+
} else if (display === 'listgroup') {
|
|
1089
|
+
const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
|
|
1090
|
+
replacement = missing
|
|
1091
|
+
? blockNotFoundHtml(attrs.block)
|
|
1092
|
+
: renderCollectionListgroup(entries, {
|
|
1093
|
+
titleField: attrs['title-field'] || 'title',
|
|
1094
|
+
bodyField: attrs['body-field'] || '',
|
|
1095
|
+
variant: attrs.variant || '',
|
|
1096
|
+
size: attrs.size || '',
|
|
1097
|
+
itemVariant: attrs.itemvariant || '',
|
|
1098
|
+
action: attrs.action === 'true',
|
|
1099
|
+
blockTemplate: tpl,
|
|
1100
|
+
blockName: attrs.block || '',
|
|
1101
|
+
blockCss: css,
|
|
1102
|
+
emptyMsg,
|
|
1103
|
+
}, ctxOpts);
|
|
1104
|
+
} else if (display === 'block') {
|
|
1105
|
+
const blockName = attrs.block || viewConfig?.display?.block || '';
|
|
1106
|
+
if (blockName) {
|
|
1107
|
+
try {
|
|
1108
|
+
const [tpl, css] = await Promise.all([
|
|
1109
|
+
loadBlockTemplate(blockName),
|
|
1110
|
+
loadBlockCss(blockName),
|
|
1111
|
+
]);
|
|
1112
|
+
replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, '', blockName, css, ctxOpts);
|
|
1113
|
+
} catch {
|
|
1114
|
+
replacement = blockNotFoundHtml(blockName);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
} else {
|
|
1118
|
+
replacement = renderCollectionTable(`view:${slug}`, entries, fields, attrs, ctaOpts, ctxOpts);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
return replacement;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
992
1124
|
/**
|
|
993
1125
|
* Process [view slug="..." display="table|cards|list" /] shortcodes.
|
|
994
1126
|
* Executes the View's aggregation pipeline and renders results using the
|
|
@@ -1042,20 +1174,8 @@ async function processViewBlocks(markdown, tagSet, user = null, flags = {}) {
|
|
|
1042
1174
|
continue;
|
|
1043
1175
|
}
|
|
1044
1176
|
|
|
1045
|
-
const
|
|
1046
|
-
const
|
|
1047
|
-
const titleField = attrs['title-field'] || '';
|
|
1048
|
-
const columns = attrs.columns || '3';
|
|
1049
|
-
const limitAttr = parseInt(attrs.limit, 10) || 25;
|
|
1050
|
-
const fieldFilter = attrs.fields ? attrs.fields.split(',').map(f => f.trim()).filter(Boolean) : null;
|
|
1051
|
-
|
|
1052
|
-
const ctaOpts = attrs.action ? {
|
|
1053
|
-
action: attrs.action,
|
|
1054
|
-
label: attrs['cta-label'] || 'Run',
|
|
1055
|
-
style: attrs['cta-style'] || 'primary',
|
|
1056
|
-
icon: attrs['cta-icon'] || '',
|
|
1057
|
-
confirm: attrs['cta-confirm'] || ''
|
|
1058
|
-
} : null;
|
|
1177
|
+
const emptyMsg = attrs.empty || 'No results found';
|
|
1178
|
+
const limitAttr = parseInt(attrs.limit, 10) || 25;
|
|
1059
1179
|
|
|
1060
1180
|
let replacement = `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
|
|
1061
1181
|
|
|
@@ -1072,105 +1192,7 @@ async function processViewBlocks(markdown, tagSet, user = null, flags = {}) {
|
|
|
1072
1192
|
|
|
1073
1193
|
// Map MongoDB docs to { id, data } shape. MongoAdapter docs have a top-level id field.
|
|
1074
1194
|
const entries = results.map(doc => ({id: doc.id || '', data: doc.data || doc}));
|
|
1075
|
-
|
|
1076
|
-
// Resolve the view's underlying collection schema to compute export
|
|
1077
|
-
// permission. Views don't own a schema themselves - [dis]allowing
|
|
1078
|
-
// export must fall back CLOSED if the source collection can't be
|
|
1079
|
-
// resolved, since an open fallback would be a permission bypass.
|
|
1080
|
-
const sourceSlug = viewConfig.pipeline?.source || '';
|
|
1081
|
-
const sourceSchema = sourceSlug ? await getCollection(sourceSlug) : null;
|
|
1082
|
-
const ctxOpts = sourceSchema
|
|
1083
|
-
? {
|
|
1084
|
-
slug: sourceSlug,
|
|
1085
|
-
export: resolveExportConfig(sourceSchema.export, attrs, getRoleHierarchy().at(-1)),
|
|
1086
|
-
itemClass: attrs['item-class']
|
|
1087
|
-
}
|
|
1088
|
-
: {slug: sourceSlug || undefined, export: {allowed: false, access: null, formats: []}, itemClass: attrs['item-class']};
|
|
1089
|
-
|
|
1090
|
-
// Derive visible fields from attr filter or first entry's data keys
|
|
1091
|
-
let fields;
|
|
1092
|
-
if (fieldFilter?.length) {
|
|
1093
|
-
fields = fieldFilter.map(name => ({ name, label: name }));
|
|
1094
|
-
} else if (entries.length) {
|
|
1095
|
-
fields = Object.keys(entries[0].data || {})
|
|
1096
|
-
.filter(k => !['_id', 'id', 'meta'].includes(k))
|
|
1097
|
-
.map(k => ({name: k, label: k}));
|
|
1098
|
-
} else {
|
|
1099
|
-
fields = [];
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
const display = displayAttr || viewConfig?.display?.mode || 'table';
|
|
1103
|
-
|
|
1104
|
-
if (display === 'cards') {
|
|
1105
|
-
replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts, ctxOpts);
|
|
1106
|
-
} else if (display === 'list') {
|
|
1107
|
-
replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts, ctxOpts);
|
|
1108
|
-
} else if (display === 'accordion') {
|
|
1109
|
-
const accordionTitleField = attrs['title-field'] || 'title';
|
|
1110
|
-
const bodyField = attrs['body-field'] || 'description';
|
|
1111
|
-
const multiple = attrs.multiple === 'true';
|
|
1112
|
-
replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg, ctxOpts);
|
|
1113
|
-
} else if (display === 'timeline') {
|
|
1114
|
-
const timelineLayout = ['vertical', 'centred', 'horizontal'].includes(attrs.layout) ? attrs.layout : 'vertical';
|
|
1115
|
-
const timelineTheme = ['minimal', 'corporate', 'modern'].includes(attrs.theme) ? attrs.theme : 'minimal';
|
|
1116
|
-
const timelineMode = ['timeline', 'roadmap'].includes(attrs.mode) ? attrs.mode : 'timeline';
|
|
1117
|
-
replacement = renderCollectionTimeline(entries, {
|
|
1118
|
-
titleField: attrs['title-field'] || 'title',
|
|
1119
|
-
dateField: attrs['date-field'] || '',
|
|
1120
|
-
statusField: attrs['status-field'] || '',
|
|
1121
|
-
iconField: attrs['icon-field'] || '',
|
|
1122
|
-
bodyField: attrs['body-field'] || '',
|
|
1123
|
-
layout: timelineLayout,
|
|
1124
|
-
theme: timelineTheme,
|
|
1125
|
-
mode: timelineMode,
|
|
1126
|
-
emptyMsg,
|
|
1127
|
-
}, ctxOpts);
|
|
1128
|
-
} else if (display === 'carousel') {
|
|
1129
|
-
const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
|
|
1130
|
-
replacement = missing
|
|
1131
|
-
? blockNotFoundHtml(attrs.block)
|
|
1132
|
-
: renderCollectionCarousel(entries, {
|
|
1133
|
-
imageField: attrs['image-field'] || '',
|
|
1134
|
-
titleField: attrs['title-field'] || 'title',
|
|
1135
|
-
bodyField: attrs['body-field'] || '',
|
|
1136
|
-
blockTemplate: tpl,
|
|
1137
|
-
blockName: attrs.block || '',
|
|
1138
|
-
blockCss: css,
|
|
1139
|
-
dataAttrs: attrs,
|
|
1140
|
-
emptyMsg,
|
|
1141
|
-
}, ctxOpts);
|
|
1142
|
-
} else if (display === 'listgroup') {
|
|
1143
|
-
const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
|
|
1144
|
-
replacement = missing
|
|
1145
|
-
? blockNotFoundHtml(attrs.block)
|
|
1146
|
-
: renderCollectionListgroup(entries, {
|
|
1147
|
-
titleField: attrs['title-field'] || 'title',
|
|
1148
|
-
bodyField: attrs['body-field'] || '',
|
|
1149
|
-
variant: attrs.variant || '',
|
|
1150
|
-
size: attrs.size || '',
|
|
1151
|
-
itemVariant: attrs.itemvariant || '',
|
|
1152
|
-
action: attrs.action === 'true',
|
|
1153
|
-
blockTemplate: tpl,
|
|
1154
|
-
blockName: attrs.block || '',
|
|
1155
|
-
blockCss: css,
|
|
1156
|
-
emptyMsg,
|
|
1157
|
-
}, ctxOpts);
|
|
1158
|
-
} else if (display === 'block') {
|
|
1159
|
-
const blockName = attrs.block || viewConfig?.display?.block || '';
|
|
1160
|
-
if (blockName) {
|
|
1161
|
-
try {
|
|
1162
|
-
const [tpl, css] = await Promise.all([
|
|
1163
|
-
loadBlockTemplate(blockName),
|
|
1164
|
-
loadBlockCss(blockName),
|
|
1165
|
-
]);
|
|
1166
|
-
replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, '', blockName, css, ctxOpts);
|
|
1167
|
-
} catch {
|
|
1168
|
-
replacement = blockNotFoundHtml(blockName);
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
} else {
|
|
1172
|
-
replacement = renderCollectionTable(`view:${slug}`, entries, fields, attrs, ctaOpts, ctxOpts);
|
|
1173
|
-
}
|
|
1195
|
+
replacement = await renderViewEntries(slug, attrs, viewConfig, entries);
|
|
1174
1196
|
} catch {
|
|
1175
1197
|
// View not found or pipeline error - show empty state
|
|
1176
1198
|
}
|
|
@@ -1429,9 +1451,16 @@ async function renderCollectionBrowserShell(attrs, {extraFilter = null, scope =
|
|
|
1429
1451
|
syncUrl: !isTruthyAttr(attrs['no-url-sync']) // default ON
|
|
1430
1452
|
};
|
|
1431
1453
|
|
|
1432
|
-
// No-JS fallback
|
|
1433
|
-
|
|
1434
|
-
|
|
1454
|
+
// No-JS fallback, drawn by the static renderer in the browser's display
|
|
1455
|
+
// from the entries already fetched for the payload (no second query).
|
|
1456
|
+
// Client mode ships every entry in data-cb-data, so the fallback lists
|
|
1457
|
+
// them too, up to the author's `limit` and FALLBACK_MAX - a robot that
|
|
1458
|
+
// runs no JavaScript reads the collection, not just page one, and a huge
|
|
1459
|
+
// one does not double the page. Server mode ships one page: that page.
|
|
1460
|
+
const fallback = await renderCollectionFragment({...attrs, display: config.display}, {
|
|
1461
|
+
extraFilter, scope,
|
|
1462
|
+
entries: entries.slice(0, fallbackLimit(attrs, entries.length))
|
|
1463
|
+
});
|
|
1435
1464
|
|
|
1436
1465
|
// Encode payloads. Schema can be modest; data can be large in client mode,
|
|
1437
1466
|
// so we base64 once each and let the browser decode.
|
|
@@ -1541,6 +1570,19 @@ async function renderViewBrowserShell(attrs, {viewConfig = null, allowed = false
|
|
|
1541
1570
|
sourceKind: 'view' // hint for client-side: server-mode would need a different fetch endpoint
|
|
1542
1571
|
};
|
|
1543
1572
|
|
|
1573
|
+
// No-JS fallback: the rows the browser was handed (all of them in client
|
|
1574
|
+
// mode, the first page in server mode), drawn by the static [view]
|
|
1575
|
+
// renderer. A refused viewer has no rows and gets the empty state.
|
|
1576
|
+
let fallback = '';
|
|
1577
|
+
try {
|
|
1578
|
+
fallback = viewConfig && allowed
|
|
1579
|
+
? await renderViewEntries(slug, {...attrs, display: config.display},
|
|
1580
|
+
viewConfig, entries.slice(0, fallbackLimit(attrs, entries.length)))
|
|
1581
|
+
: `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(config.emptyMsg)}</p></div>`;
|
|
1582
|
+
} catch {
|
|
1583
|
+
// A fallback that cannot be drawn must not take the browser with it.
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1544
1586
|
const enc = (obj) => Buffer.from(JSON.stringify(obj), 'utf8').toString('base64');
|
|
1545
1587
|
const wrapperCls = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
|
|
1546
1588
|
const wrapperId = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : ` id="${escapeAttr(config.id)}"`;
|
|
@@ -1549,10 +1591,27 @@ async function renderViewBrowserShell(attrs, {viewConfig = null, allowed = false
|
|
|
1549
1591
|
`data-cb-config="${enc(config)}" ` +
|
|
1550
1592
|
`data-cb-schema="${enc({fields: schemaFields, title: viewConfig?.title || slug})}" ` +
|
|
1551
1593
|
`data-cb-data="${enc({entries, total, page: 1, pageSize})}">` +
|
|
1552
|
-
`<div class="dm-cb-fallback"
|
|
1594
|
+
`<div class="dm-cb-fallback">${fallback}</div>` +
|
|
1553
1595
|
`</div>`;
|
|
1554
1596
|
}
|
|
1555
1597
|
|
|
1598
|
+
/**
|
|
1599
|
+
* How many entries an interactive browser's no-JS fallback draws: the
|
|
1600
|
+
* author's `limit` when set, never more than the browser itself holds, and
|
|
1601
|
+
* never more than FALLBACK_MAX.
|
|
1602
|
+
*
|
|
1603
|
+
* @param {Record<string, string>} attrs
|
|
1604
|
+
* @param {number} held - entries the browser ships (all of them in client mode, one page in server mode)
|
|
1605
|
+
* @returns {number}
|
|
1606
|
+
*/
|
|
1607
|
+
function fallbackLimit(attrs, held) {
|
|
1608
|
+
const limit = parseInt(attrs.limit, 10);
|
|
1609
|
+
return Math.min(limit > 0 ? limit : held, held, FALLBACK_MAX);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/** The most entries a browser's no-JS fallback draws; the rest stay in the payload for the browser. */
|
|
1613
|
+
export const FALLBACK_MAX = 200;
|
|
1614
|
+
|
|
1556
1615
|
/**
|
|
1557
1616
|
* Render a `[collection]` block's HTML fragment from parsed attributes.
|
|
1558
1617
|
*
|
|
@@ -1574,9 +1633,10 @@ async function renderViewBrowserShell(attrs, {viewConfig = null, allowed = false
|
|
|
1574
1633
|
* @param {object} [opts]
|
|
1575
1634
|
* @param {Record<string, unknown>} [opts.extraFilter] - Additional filter conditions to AND in
|
|
1576
1635
|
* @param {string} [opts.scope] - 'mine': CTA buttons carry data-scope
|
|
1636
|
+
* @param {Array<object>} [opts.entries] - Entries already fetched (a browser shell's), drawn instead of querying
|
|
1577
1637
|
* @returns {Promise<string>} HTML fragment ready to inject into a page
|
|
1578
1638
|
*/
|
|
1579
|
-
export async function renderCollectionFragment(attrs, { extraFilter = null, scope = '' } = {}) {
|
|
1639
|
+
export async function renderCollectionFragment(attrs, { extraFilter = null, scope = '', entries: given = null } = {}) {
|
|
1580
1640
|
const slug = attrs.slug || '';
|
|
1581
1641
|
if (!slug) return '';
|
|
1582
1642
|
|
|
@@ -1625,7 +1685,8 @@ export async function renderCollectionFragment(attrs, { extraFilter = null, scop
|
|
|
1625
1685
|
};
|
|
1626
1686
|
if (Object.keys(filter).length) listOpts.filter = filter;
|
|
1627
1687
|
|
|
1628
|
-
|
|
1688
|
+
// A browser shell hands over the entries it already fetched (refs resolved).
|
|
1689
|
+
const {entries} = Array.isArray(given) ? {entries: given} : await listEntries(slug, listOpts);
|
|
1629
1690
|
|
|
1630
1691
|
let fields = schema.fields || [];
|
|
1631
1692
|
if (fieldFilter?.length) {
|
|
@@ -4191,6 +4252,25 @@ async function expandReferenceFields(form) {
|
|
|
4191
4252
|
}
|
|
4192
4253
|
}
|
|
4193
4254
|
|
|
4255
|
+
/**
|
|
4256
|
+
* What a form is for, as text inside its embed: the title and description,
|
|
4257
|
+
* visually hidden (the drawn form does not show them either), so a robot that
|
|
4258
|
+
* runs no JavaScript still reads what the form is. site.js removes it when it
|
|
4259
|
+
* draws the form. Values are inert: a form's text is not a shortcode.
|
|
4260
|
+
*
|
|
4261
|
+
* @param {{title?: string, description?: string}} form
|
|
4262
|
+
* @returns {string} '' when the form has neither
|
|
4263
|
+
*/
|
|
4264
|
+
export function formSummaryHtml(form) {
|
|
4265
|
+
const title = String(form?.title ?? '').trim();
|
|
4266
|
+
const description = String(form?.description ?? '').trim();
|
|
4267
|
+
if (!title && !description) return '';
|
|
4268
|
+
return inertShortcodes(`<div class="sr-only cms-form-embed-summary">`
|
|
4269
|
+
+ (title ? `<p><strong>${escapeHtmlText(title)}</strong></p>` : '')
|
|
4270
|
+
+ (description ? `<p>${escapeHtmlText(description)}</p>` : '')
|
|
4271
|
+
+ `</div>`);
|
|
4272
|
+
}
|
|
4273
|
+
|
|
4194
4274
|
async function processFormBlocks(markdown, tagSet) {
|
|
4195
4275
|
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
4196
4276
|
const regex = /\[form([^\]]*?)\/\]/gi;
|
|
@@ -4230,7 +4310,7 @@ async function processFormBlocks(markdown, tagSet) {
|
|
|
4230
4310
|
const encoded = Buffer.from(JSON.stringify(publicForm)).toString('base64');
|
|
4231
4311
|
const extraClass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
|
|
4232
4312
|
const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
4233
|
-
replacement = `<div class="cms-form-embed${extraClass}" data-form-inline="${escapeAttr(encoded)}"${idAttr}
|
|
4313
|
+
replacement = `<div class="cms-form-embed${extraClass}" data-form-inline="${escapeAttr(encoded)}"${idAttr}>${formSummaryHtml(form)}</div>`;
|
|
4234
4314
|
} catch {
|
|
4235
4315
|
const extraClass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
|
|
4236
4316
|
const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* No-JS navbar and footer
|
|
3
|
+
*
|
|
4
|
+
* The public navbar and footer are drawn in the browser by site.js from
|
|
5
|
+
* window.__CMS_NAV__ / __CMS_FOOTER__ / __CMS_SITE__. A robot that runs no
|
|
6
|
+
* JavaScript (GPTBot, ClaudeBot) saw an empty <nav> and <footer>, so the
|
|
7
|
+
* renderer also writes the same links as plain HTML inside them, built from
|
|
8
|
+
* the SAME per-viewer data it serialises (visibility already filtered).
|
|
9
|
+
*
|
|
10
|
+
* The block is visually hidden (Domma's `.sr-only` - clipped, not
|
|
11
|
+
* display:none, so it is still in the document), so it can neither flash nor
|
|
12
|
+
* shift the layout before site.js runs; site.js removes it (`.cms-nojs-links`)
|
|
13
|
+
* once it has drawn the real navbar and footer.
|
|
14
|
+
*
|
|
15
|
+
* Pure: no I/O, so both render paths in renderer.js call it the same way.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Structural menu items carry no text or link (see the domma-menus skill). */
|
|
19
|
+
const STRUCTURAL = new Set(['separator', 'spacer', 'theme-switcher']);
|
|
20
|
+
|
|
21
|
+
/** The social networks site.js draws in the footer, in its order, with its labels. */
|
|
22
|
+
const SOCIAL = [
|
|
23
|
+
['twitter', 'X / Twitter'],
|
|
24
|
+
['facebook', 'Facebook'],
|
|
25
|
+
['instagram', 'Instagram'],
|
|
26
|
+
['linkedin', 'LinkedIn'],
|
|
27
|
+
['github', 'GitHub'],
|
|
28
|
+
['youtube', 'YouTube']
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** Text for an element body or a double-quoted attribute. */
|
|
32
|
+
function esc(v) {
|
|
33
|
+
return String(v ?? '')
|
|
34
|
+
.replace(/&/g, '&')
|
|
35
|
+
.replace(/</g, '<')
|
|
36
|
+
.replace(/>/g, '>')
|
|
37
|
+
.replace(/"/g, '"')
|
|
38
|
+
.replace(/'/g, ''');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A link target worth writing: a non-empty string that is not a script or
|
|
43
|
+
* data address (the browser's sanitiser would drop those on the drawn
|
|
44
|
+
* navbar, so the fallback does not carry them either).
|
|
45
|
+
*
|
|
46
|
+
* @param {unknown} url
|
|
47
|
+
* @returns {string} the trimmed url, '' when it is not one
|
|
48
|
+
*/
|
|
49
|
+
export function safeHref(url) {
|
|
50
|
+
const u = typeof url === 'string' ? url.trim() : '';
|
|
51
|
+
if (!u || u === '#') return '';
|
|
52
|
+
// Control characters and spaces inside a scheme are ignored by browsers.
|
|
53
|
+
const scheme = u.replace(/[\u0000- ]/g, '').toLowerCase();
|
|
54
|
+
if (/^(javascript|vbscript|data):/.test(scheme)) return '';
|
|
55
|
+
return u;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Menu items as nested <ul> lists. Structural and hidden items are skipped,
|
|
60
|
+
* and so is an item with no usable url - unless it has children with links,
|
|
61
|
+
* when its label stays as plain text above them (a dropdown heading).
|
|
62
|
+
*
|
|
63
|
+
* @param {Array<object>} items
|
|
64
|
+
* @returns {string} '' when nothing is left to list
|
|
65
|
+
*/
|
|
66
|
+
export function linkList(items) {
|
|
67
|
+
const lis = (Array.isArray(items) ? items : []).map((it) => {
|
|
68
|
+
if (!it || typeof it !== 'object' || it.hidden || STRUCTURAL.has(it.type)) return '';
|
|
69
|
+
const kids = linkList(it.items);
|
|
70
|
+
const href = safeHref(it.url);
|
|
71
|
+
const text = String(it.text ?? '').trim() || (href ? href : '');
|
|
72
|
+
if (!href && !kids) return '';
|
|
73
|
+
const label = href ? `<a href="${esc(href)}">${esc(text)}</a>` : (text ? `<span>${esc(text)}</span>` : '');
|
|
74
|
+
return `<li>${label}${kids}</li>`;
|
|
75
|
+
}).filter(Boolean);
|
|
76
|
+
return lis.length ? `<ul>${lis.join('')}</ul>` : '';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The navbar's links as plain HTML, for inside <nav id="site-navbar">.
|
|
81
|
+
*
|
|
82
|
+
* @param {{brand?: object, items?: Array<object>}} navigation - the object __CMS_NAV__ carries
|
|
83
|
+
* @param {{title?: string}} [site]
|
|
84
|
+
* @returns {string} '' when there is nothing to link
|
|
85
|
+
*/
|
|
86
|
+
export function buildNavFallback(navigation = {}, site = {}) {
|
|
87
|
+
const brand = navigation?.brand || {};
|
|
88
|
+
const brandText = String(brand.text || site?.title || '').trim();
|
|
89
|
+
const brandHref = safeHref(brand.url) || '/';
|
|
90
|
+
const list = linkList(navigation?.items);
|
|
91
|
+
if (!brandText && !list) return '';
|
|
92
|
+
const home = brandText ? `<a href="${esc(brandHref)}">${esc(brandText)}</a>` : '';
|
|
93
|
+
return `<div class="sr-only cms-nojs-links cms-nojs-nav">${home}${list}</div>`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The footer's text and links as plain HTML, for inside <footer id="site-footer">:
|
|
98
|
+
* the blurb (when the footer is drawn in columns, where site.js shows it), the
|
|
99
|
+
* footer and legal menus, the social links and the copyright line.
|
|
100
|
+
*
|
|
101
|
+
* @param {{primary?: Array<object>, legal?: Array<object>, copyright?: string}} footer - __CMS_FOOTER__
|
|
102
|
+
* @param {{footer?: object, social?: object}} [site]
|
|
103
|
+
* @returns {string} '' when there is nothing to say
|
|
104
|
+
*/
|
|
105
|
+
export function buildFooterFallback(footer = {}, site = {}) {
|
|
106
|
+
const primary = Array.isArray(footer?.primary) ? footer.primary : [];
|
|
107
|
+
const layout = site?.footer?.layout;
|
|
108
|
+
// The same choice site.js makes: columns when asked for, or (auto) when the footer menu has groups.
|
|
109
|
+
const columns = layout === 'columns'
|
|
110
|
+
|| (!['columns', 'row'].includes(layout) && primary.some(it => Array.isArray(it?.items) && it.items.length));
|
|
111
|
+
const blurb = columns ? String(site?.footer?.blurb || '').trim() : '';
|
|
112
|
+
const primaryList = linkList(primary);
|
|
113
|
+
const legalList = linkList(footer?.legal);
|
|
114
|
+
// site.js draws the social links only when the site has footer settings at all.
|
|
115
|
+
const social = site?.footer && site?.social ? SOCIAL
|
|
116
|
+
.map(([key, label]) => [safeHref(site.social[key]), label])
|
|
117
|
+
.filter(([href]) => href)
|
|
118
|
+
.map(([href, label]) => `<li><a href="${esc(href)}" rel="noopener noreferrer">${esc(label)}</a></li>`)
|
|
119
|
+
: [];
|
|
120
|
+
const copyright = String(footer?.copyright || '').trim();
|
|
121
|
+
|
|
122
|
+
const parts = [
|
|
123
|
+
blurb ? `<p>${esc(blurb)}</p>` : '',
|
|
124
|
+
primaryList ? `<nav aria-label="Footer">${primaryList}</nav>` : '',
|
|
125
|
+
legalList ? `<nav aria-label="Legal">${legalList}</nav>` : '',
|
|
126
|
+
social.length ? `<ul>${social.join('')}</ul>` : '',
|
|
127
|
+
copyright ? `<p>${esc(copyright)}</p>` : ''
|
|
128
|
+
].filter(Boolean);
|
|
129
|
+
return parts.length ? `<div class="sr-only cms-nojs-links cms-nojs-footer">${parts.join('')}</div>` : '';
|
|
130
|
+
}
|