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
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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}}"
|
|
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
|
-
|
|
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=
|
|
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
|
-
<!--
|
|
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}}
|