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.
Files changed (44) hide show
  1. package/CHANGELOG.md +12 -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 +36 -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
@@ -11,6 +11,7 @@ import {applyTransforms} from './hooks.js';
11
11
  import {contentsPaint, readContentsOverride, resolveContents} from './pageContents.js';
12
12
  import {applyMenuOverride, hasThemeSwitcherItem, resolveLocation, resolveMenuDecorations, resolveOverlaysForPage} from './menus.js';
13
13
  import {buildMenuNav} from './menuRender.js';
14
+ import {buildFooterFallback, buildNavFallback} from './nojsLinks.js';
14
15
  import {getProjectForPage} from './projects.js';
15
16
  import {resolveContextMenusForPage} from './contextMenus.js';
16
17
  import {buildCustomThemeStyleTag, buildOverrideStyleTag, getThemeTokenMap, listThemeIds, loadThemeConfig, resolveThemeClasses} from './themeSettings.js';
@@ -75,7 +76,7 @@ function buildSearchAssets() {
75
76
  return {
76
77
  headTag: `<link rel="stylesheet" href="/public/css/search.css?v=${SEARCH_ASSET_V}">`,
77
78
  bodyTag: `<script>window.__CMS_SEARCH__ = ${json};</script>\n`
78
- + `<script src="/public/js/search.js?v=${SEARCH_ASSET_V}"></script>`
79
+ + `<script src="/public/js/search.js?v=${SEARCH_ASSET_V}" defer></script>`
79
80
  };
80
81
  }
81
82
 
@@ -97,7 +98,7 @@ const ANALYTICS_ASSET_V = '20260922-analytics';
97
98
  function buildAnalyticsTag() {
98
99
  // Unless the site has switched Analytics off (config/tools.json).
99
100
  if (!isToolEnabled('analytics')) return '';
100
- return `<script src="/public/js/analytics.js?v=${ANALYTICS_ASSET_V}"></script>`;
101
+ return `<script src="/public/js/analytics.js?v=${ANALYTICS_ASSET_V}" defer></script>`;
101
102
  }
102
103
 
103
104
  /**
@@ -441,6 +442,12 @@ export async function renderPage(page, opts = {}) {
441
442
  ...(footerLegal?.class && {legalClass: footerLegal.class})
442
443
  };
443
444
  const footerJson = JSON.stringify(footer).replace(/<\/script>/gi, '<\\/script>');
445
+ // The navbar's and footer's links as plain HTML too, for robots that run no
446
+ // JavaScript - built from the same per-viewer data as the globals above;
447
+ // site.js removes them once it has drawn the real ones (nojsLinks.js).
448
+ const publicNav = filterHiddenNavItems(navigation);
449
+ const navFallbackHtml = buildNavFallback(publicNav, site);
450
+ const footerFallbackHtml = buildFooterFallback(footer, site);
444
451
 
445
452
  // A preview is not a visit. Without this the editor's preview iframe posts a
446
453
  // page view on every re-render - recorded under the srcdoc document's path,
@@ -498,7 +505,9 @@ export async function renderPage(page, opts = {}) {
498
505
  contentsStyle: contentsPaint(pageContents).style,
499
506
  contentsJson: escapeAttrValue(JSON.stringify(pageContents)),
500
507
  contentsCollapsible: pageContents.collapsible === true && (preset.sidebar === true || page.sidebar === true) && !pageContents.hidden,
501
- navJson: JSON.stringify(filterHiddenNavItems(navigation)).replace(/<\/script>/gi, '<\\/script>'),
508
+ navJson: JSON.stringify(publicNav).replace(/<\/script>/gi, '<\\/script>'),
509
+ navFallbackHtml,
510
+ footerFallbackHtml,
502
511
  footerScript: footerJson ? `window.__CMS_FOOTER__ = ${footerJson};` : '',
503
512
  ctxMenusScript: ctxMenusScript,
504
513
  ctxMenusModule: ctxMenusModule,
@@ -536,8 +545,8 @@ export async function renderPage(page, opts = {}) {
536
545
  searchAssets.bodyTag,
537
546
  switcherAssets.bodyTag,
538
547
  buildAnalyticsTag(),
539
- site.backToTop?.enabled ? '<script src="/public/js/btt.js"></script>' : '',
540
- site.cookieConsent?.enabled ? '<script src="/public/js/cookie-consent.js"></script>' : '',
548
+ site.backToTop?.enabled ? '<script src="/public/js/btt.js" defer></script>' : '',
549
+ site.cookieConsent?.enabled ? '<script src="/public/js/cookie-consent.js" defer></script>' : '',
541
550
  (page.usedComponents || [])
542
551
  .map(n => `<script type="module" src="/api/components/${n}.js"></script>`)
543
552
  .join('\n')
@@ -1233,6 +1242,12 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
1233
1242
  ...(footerLegal?.class && {legalClass: footerLegal.class})
1234
1243
  };
1235
1244
  const footerJson = JSON.stringify(footer).replace(/<\/script>/gi, '<\\/script>');
1245
+ // The navbar's and footer's links as plain HTML too, for robots that run no
1246
+ // JavaScript - built from the same per-viewer data as the globals above;
1247
+ // site.js removes them once it has drawn the real ones (nojsLinks.js).
1248
+ const publicNav = filterHiddenNavItems(navigation);
1249
+ const navFallbackHtml = buildNavFallback(publicNav, site);
1250
+ const footerFallbackHtml = buildFooterFallback(footer, site);
1236
1251
 
1237
1252
  /*
1238
1253
  * Authored context menus. Resolved server-side so a visitor only receives
@@ -1278,7 +1293,9 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
1278
1293
  showFooter: true,
1279
1294
  footerBlocksHtml,
1280
1295
  showSidebar: false,
1281
- navJson: JSON.stringify(filterHiddenNavItems(navigation)).replace(/<\/script>/gi, '<\\/script>'),
1296
+ navJson: JSON.stringify(publicNav).replace(/<\/script>/gi, '<\\/script>'),
1297
+ navFallbackHtml,
1298
+ footerFallbackHtml,
1282
1299
  footerScript: footerJson ? `window.__CMS_FOOTER__ = ${footerJson};` : '',
1283
1300
  ctxMenusScript: ctxMenusScript,
1284
1301
  ctxMenusModule: ctxMenusModule,
@@ -35,9 +35,11 @@ function shouldIncludeInSitemap(page) {
35
35
  && !page.seo?.noindex;
36
36
  }
37
37
 
38
- /** The sitemap entry of a core page. */
38
+ /** The sitemap entry of a core page. `description` is for llms.txt; the XML ignores it. */
39
39
  function pageEntry(page) {
40
- return {urlPath: page.urlPath, title: page.title || '', lastmod: page.updatedAt || null, source: 'pages'};
40
+ const description = String(page.seo?.description || page.description || '').trim();
41
+ return {urlPath: page.urlPath, title: page.title || '', lastmod: page.updatedAt || null,
42
+ ...(description && {description}), source: 'pages'};
41
43
  }
42
44
 
43
45
  /**
@@ -77,6 +79,7 @@ export async function listSitemapEntries(baseUrl, {log = console} = {}) {
77
79
  ...(CHANGEFREQ.has(item.changefreq) && {changefreq: item.changefreq}),
78
80
  ...(validPriority(item.priority) && {priority: Number(item.priority)}),
79
81
  ...(typeof item.image === 'string' && item.image && {image: item.image}),
82
+ ...(typeof item.description === 'string' && item.description.trim() && {description: item.description.trim()}),
80
83
  source: source.id
81
84
  });
82
85
  }
@@ -106,6 +109,24 @@ export async function generate(baseUrl, opts = {}) {
106
109
  return wrapInUrlset(entries.map(e => buildUrlEntry(e, normalised)), {withImages});
107
110
  }
108
111
 
112
+ /**
113
+ * The public GET endpoints a public page calls while it renders: effects
114
+ * settings (effects.js, every page), a form's definition (forms.js), a
115
+ * component's compiled module (the page's <script> tags) and the public
116
+ * listings an interactive [collection] / [view] pages through. Never a route
117
+ * that needs a sign-in or writes anything.
118
+ */
119
+ export const PUBLIC_API_ALLOW = [
120
+ '/api/effects/settings$',
121
+ '/api/forms/*/public$',
122
+ '/api/components/*.js$',
123
+ // The listing itself, with or without its query string - not /public/<id>.
124
+ '/api/collections/*/public$',
125
+ '/api/collections/*/public?',
126
+ '/api/views/*/public$',
127
+ '/api/views/*/public?'
128
+ ];
129
+
109
130
  /**
110
131
  * Build the robots.txt document. Points at /sitemap.xml on the given base URL.
111
132
  * The 'robots:lines' transform may change the lines (the SEO plugin's editor).
@@ -123,6 +144,11 @@ export function buildRobotsTxt(baseUrl) {
123
144
  let lines = [
124
145
  'User-agent: *',
125
146
  'Allow: /',
147
+ // Google renders pages with JavaScript, and a page's scripts read these
148
+ // public endpoints while it renders - so they are let through ahead of
149
+ // the /api/ block (the longest matching rule wins). Read-only and
150
+ // anonymous, each still answering to its own access rules.
151
+ ...PUBLIC_API_ALLOW.map(p => `Allow: ${p}`),
126
152
  'Disallow: /admin/',
127
153
  'Disallow: /api/',
128
154
  '',
@@ -138,10 +164,82 @@ export function buildRobotsTxt(baseUrl) {
138
164
  return lines.join('\n') + '\n';
139
165
  }
140
166
 
167
+ /**
168
+ * May /llms.txt be served? Its own switch in the SEO settings (on unless
169
+ * switched off), and never while robots.txt asks every crawler to stay away.
170
+ * `settings` is the SEO engine's (config/seo.json over the defaults), or
171
+ * null when SEO is switched off on the site - then, as with the sitemap,
172
+ * the core default applies and it is served.
173
+ *
174
+ * @param {object|null} settings
175
+ * @returns {boolean}
176
+ */
177
+ export function llmsTxtAllowed(settings) {
178
+ if (!settings) return true;
179
+ return settings.llms?.enabled !== false && settings.robots?.blockAll !== true;
180
+ }
181
+
182
+ /**
183
+ * Build /llms.txt (llmstxt.org): the site's name, its description, then one
184
+ * section per source - the site's pages first, then each plugin's sitemap
185
+ * source (blog posts...) - listing `- [Title](absolute url): description`.
186
+ * The same published, public, indexable set the sitemap lists, so anything
187
+ * kept out of the sitemap is kept out of here too.
188
+ *
189
+ * @param {string} baseUrl - Absolute origin; site.json's baseUrl when set
190
+ * @param {{log?: {warn: Function}}} [opts]
191
+ * @returns {Promise<string>}
192
+ */
193
+ export async function buildLlmsTxt(baseUrl, opts = {}) {
194
+ const site = getConfig('site') || {};
195
+ const normalised = normaliseBaseUrl(baseUrl);
196
+ const entries = await listSitemapEntries(normalised, opts);
197
+ const labels = new Map(getSitemapSources().map(s => [s.id, s.label || s.id]));
198
+
199
+ const title = oneLine(site.title || site.seo?.defaultTitle || 'Website');
200
+ const description = oneLine(site.seo?.defaultDescription || site.description || '');
201
+ const out = [`# ${title}`, ''];
202
+ if (description) out.push(`> ${description}`, '');
203
+
204
+ // Sections in first-seen order: core pages lead, then each source.
205
+ const sections = new Map();
206
+ for (const e of entries) {
207
+ const key = e.source || 'pages';
208
+ if (!sections.has(key)) sections.set(key, []);
209
+ sections.get(key).push(e);
210
+ }
211
+ for (const [key, list] of sections) {
212
+ out.push(`## ${oneLine(key === 'pages' ? 'Pages' : (labels.get(key) || key))}`, '');
213
+ for (const e of list) {
214
+ const url = `${normalised}${e.urlPath}`;
215
+ const name = linkText(e.title || e.urlPath);
216
+ const about = oneLine(e.description || '');
217
+ out.push(`- [${name}](${linkUrl(url)})${about ? `: ${about}` : ''}`);
218
+ }
219
+ out.push('');
220
+ }
221
+ return out.join('\n').replace(/\n+$/, '') + '\n';
222
+ }
223
+
141
224
  // ---------------------------------------------------------------------------
142
225
  // Helpers
143
226
  // ---------------------------------------------------------------------------
144
227
 
228
+ /** Text on one line, runs of whitespace collapsed. */
229
+ function oneLine(v) {
230
+ return String(v ?? '').replace(/\s+/g, ' ').trim();
231
+ }
232
+
233
+ /** An address safe inside a Markdown link's (parentheses): brackets and whitespace percent-encoded. */
234
+ function linkUrl(url) {
235
+ return String(url).replace(/[()\s]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'));
236
+ }
237
+
238
+ /** Text safe inside a Markdown link's [brackets]. */
239
+ function linkText(v) {
240
+ return oneLine(v).replace(/[\\[\]]/g, '\\$&');
241
+ }
242
+
145
243
  function buildUrlEntry(entry, baseUrl) {
146
244
  const loc = escapeXml(baseUrl ? `${baseUrl}${entry.urlPath}` : entry.urlPath);
147
245
  const when = entry.lastmod ? new Date(entry.lastmod) : null;
@@ -62,7 +62,7 @@
62
62
  {{/if}}
63
63
 
64
64
  {{#if showNavbar}}
65
- <nav id="site-navbar" class="{{navbarClass}}"></nav>
65
+ <nav id="site-navbar" class="{{navbarClass}}">{{navFallbackHtml}}</nav>
66
66
  {{/if}}
67
67
 
68
68
  <main class="site-main {{#if showSidebar}}with-sidebar {{contentsClass}}{{/if}}"{{#if contentsStyle}} style="{{contentsStyle}}"{{/if}}>
@@ -91,11 +91,12 @@
91
91
  </main>
92
92
 
93
93
  {{#if showFooter}}
94
- <footer id="site-footer" class="page-footer">{{footerBlocksHtml}}</footer>
94
+ <footer id="site-footer" class="page-footer">{{footerBlocksHtml}}{{footerFallbackHtml}}</footer>
95
95
  {{/if}}
96
96
 
97
- <!-- DOMPurify - must load before DommaJS -->
98
- <script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
97
+ <!-- DOMPurify - must load before DommaJS. Served from this site (public/js/vendor),
98
+ pinned, rather than a floating CDN version. -->
99
+ <script src="/public/js/vendor/dompurify/purify.min.js?v=3.4.17"></script>
99
100
 
100
101
  <!-- DommaJS -->
101
102
  <script src="/dist/domma/domma.min.js"></script>
@@ -172,8 +173,17 @@
172
173
  {{dconfigScript}}
173
174
  </script>
174
175
 
176
+ <!-- Deferred classic scripts. They run once the page is parsed, in document
177
+ order with the module scripts below - so they stay ABOVE site.js, which
178
+ reads window.FormLogicEngine and window.DommaCollectionBrowser. -->
179
+ <!-- Interactive Collection Browser (used by [collection] shortcodes with
180
+ searchable / filterable / sortable / paginate attributes) -->
181
+ <script src="/public/js/collection-browser.js?v=20260927-scope-mine" defer></script>
182
+ <!-- Core Forms logic engine (must run before forms.js and site.js) -->
183
+ <script src="/public/js/form-logic-engine.js?v=20260927-submit-btn" defer></script>
184
+
175
185
  <!-- Site initialisation -->
176
- <script src="/public/js/site.js?v=20261016-footer-blocks" type="module"></script>
186
+ <script src="/public/js/site.js?v=20261022-nojs-links" type="module"></script>
177
187
  <script src="/public/js/collection-context.js?v=20261010-utf8-payloads" type="module"></script>
178
188
  {{ctxMenusModule}}
179
189
 
@@ -182,16 +192,11 @@
182
192
  [menu] shortcodes are wired even on pages with no floating navbar. -->
183
193
  <script src="/public/js/menu-float.js?v=20260927-float" type="module"></script>
184
194
 
185
- <!-- Interactive Collection Browser (used by [collection] shortcodes with
186
- searchable / filterable / sortable / paginate attributes) -->
187
- <script src="/public/js/collection-browser.js?v=20260927-scope-mine"></script>
188
-
189
- <!-- Core Forms (logic engine must load before renderer) -->
190
- <script src="/public/js/form-logic-engine.js?v=20260927-submit-btn"></script>
195
+ <!-- Core Forms renderer -->
191
196
  <script src="/public/js/forms.js?v=20261015-smooth-caret" type="module"></script>
192
197
 
193
198
  <!-- Core effects runtime -->
194
- <script src="/public/js/effects.js?v=20261015-occasions"></script>
199
+ <script src="/public/js/effects.js?v=20261015-occasions" defer></script>
195
200
 
196
201
  <!-- Plugin body-end injection -->
197
202
  {{bodyEndInject}}