domma-cms 1.8.35 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/admin/index.html +1 -1
  3. package/admin/js/app.js +1 -1
  4. package/admin/js/lib/seo-analyse.js +1 -1
  5. package/admin/js/lib/seo-settings.js +2 -2
  6. package/admin/js/templates/docs/usage-search-engines.html +84 -0
  7. package/admin/js/templates/documentation.html +4 -0
  8. package/admin/js/views/doc-pages.js +1 -1
  9. package/admin/js/views/seo.js +15 -14
  10. package/docs/DommaFeatures.md +9 -2
  11. package/docs/plugin-development.md +1 -1
  12. package/package.json +1 -1
  13. package/plugins/booking/CLAUDE.md +5 -0
  14. package/plugins/booking/admin/views/settings.js +1 -1
  15. package/plugins/booking/docs/guide.md +2 -0
  16. package/plugins/booking/plugin.json +2 -2
  17. package/plugins/booking/plugin.public.js +15 -2
  18. package/plugins/booking/tests/seo.test.js +71 -0
  19. package/plugins/free-tier.lock.json +18 -16
  20. package/plugins/shopping-cart/CLAUDE.md +9 -0
  21. package/plugins/shopping-cart/admin/views/settings.js +1 -1
  22. package/plugins/shopping-cart/docs/guide.md +2 -1
  23. package/plugins/shopping-cart/plugin.js +18 -0
  24. package/plugins/shopping-cart/plugin.json +2 -2
  25. package/plugins/shopping-cart/plugin.public.js +39 -4
  26. package/plugins/shopping-cart/tests/seo.test.js +95 -0
  27. package/public/js/site.js +1 -1
  28. package/public/js/vendor/dompurify/LICENSE +202 -0
  29. package/public/js/vendor/dompurify/LICENSE-MPL +373 -0
  30. package/public/js/vendor/dompurify/purify.min.js +7 -0
  31. package/scripts/build.js +3 -0
  32. package/server/routes/api/collections.js +2 -0
  33. package/server/routes/api/effects.js +5 -1
  34. package/server/routes/api/forms.js +2 -0
  35. package/server/routes/api/views.js +2 -0
  36. package/server/routes/public.js +20 -1
  37. package/server/server.js +1 -0
  38. package/server/services/docs.js +1 -0
  39. package/server/services/hooks.js +3 -2
  40. package/server/services/markdown.js +200 -120
  41. package/server/services/nojsLinks.js +130 -0
  42. package/server/services/renderer.js +23 -6
  43. package/server/services/sitemap.js +100 -2
  44. 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
- fastify.get('/effects/settings', async () => loadSettings());
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);
@@ -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';
@@ -83,6 +84,24 @@ export async function publicRoutes(fastify) {
83
84
  return reply.type('application/xml').send(xml);
84
85
  });
85
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
+
86
105
  // SEO: robots.txt - cheap to build, no cache wrap needed.
87
106
  fastify.get('/robots.txt', async (request, reply) => {
88
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'}});
@@ -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'],
@@ -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. A source that
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 displayAttr = attrs.display || '';
1046
- const emptyMsg = attrs.empty || 'No results found';
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 - render the same first-page state with the static renderer.
1433
- const fallbackAttrs = {...attrs, limit: String(pageSize)};
1434
- const fallback = await renderCollectionFragment(fallbackAttrs, {extraFilter, scope});
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"></div>` +
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
- const {entries} = await listEntries(slug, listOpts);
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}></div>`;
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, '&amp;')
35
+ .replace(/</g, '&lt;')
36
+ .replace(/>/g, '&gt;')
37
+ .replace(/"/g, '&quot;')
38
+ .replace(/'/g, '&#39;');
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
+ }