domma-cms 1.8.27 → 1.8.29
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 +9 -0
- package/README.md +3 -0
- package/admin/css/admin.css +1 -1
- package/admin/js/lib/page-editor-arrange.js +1 -1
- package/admin/js/lib/seo-structured-data.js +1 -0
- package/admin/js/templates/page-editor.html +16 -0
- package/admin/js/views/page-editor.js +12 -12
- package/docs/cache.md +4 -0
- package/package.json +1 -1
- package/public/css/site.css +1 -1
- package/server/routes/api/cache.js +2 -1
- package/server/server.js +7 -0
- package/server/services/assetCache.js +141 -0
- package/server/services/htmlResponse.js +6 -3
- package/server/services/renderer.js +30 -0
- package/server/templates/page.html +1 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static assets from memory, compressed once.
|
|
3
|
+
*
|
|
4
|
+
* @fastify/static streams every stylesheet and script from disk on every
|
|
5
|
+
* request - stat, open, read, close - and @fastify/compress then brotli's the
|
|
6
|
+
* stream for every visitor. On a busy site that was a whole core for about a
|
|
7
|
+
* thousand assets a second, all of it repeated work: the files do not change
|
|
8
|
+
* between requests. This hook, placed BEFORE @fastify/compress's, keeps the
|
|
9
|
+
* served form of a text asset in memory - the bytes as sent, per encoding
|
|
10
|
+
* (br, gzip, or identity for a client that accepts none) - and answers the
|
|
11
|
+
* next request with the buffer. The file is still stat'ed each time, so an
|
|
12
|
+
* edit (a new mtime or size) is seen at once, and @fastify/static still does
|
|
13
|
+
* everything else: it resolved the path, set the type, the cache-control the
|
|
14
|
+
* mount asked for, the ETag and Last-Modified, and answered the 304s, the
|
|
15
|
+
* HEADs and the 404s before this runs. The hook only swaps the stream for a
|
|
16
|
+
* buffer, so a file @fastify/static would not have served is never served
|
|
17
|
+
* here either.
|
|
18
|
+
*
|
|
19
|
+
* Only what is worth it: GET, a 200, a compressible type (the same rule
|
|
20
|
+
* @fastify/compress uses), no Range, a file between 1 KB and `maxFileBytes`.
|
|
21
|
+
* Images, fonts and media stream as before. On while the response cache is
|
|
22
|
+
* on, like the page memo - the admin's cache switch means all of it - and
|
|
23
|
+
* emptied when the cache is cleared or re-initialised. Bounded by bytes, not
|
|
24
|
+
* entries, with the least recently served evicted first.
|
|
25
|
+
*/
|
|
26
|
+
import fs from 'node:fs/promises';
|
|
27
|
+
import * as cache from './cache/index.js';
|
|
28
|
+
import {encode, pickEncoding} from './htmlResponse.js';
|
|
29
|
+
|
|
30
|
+
/** @fastify/compress's own rule for what is text enough to compress. */
|
|
31
|
+
const COMPRESSIBLE = /^text\/(?!event-stream)|(?:\+|\/)json(?:;|$)|(?:\+|\/)text(?:;|$)|(?:\+|\/)xml(?:;|$)|javascript(?:;|$)/u;
|
|
32
|
+
|
|
33
|
+
const DEFAULTS = {maxBytes: 64 * 1024 * 1024, maxFileBytes: 2 * 1024 * 1024, minBytes: 1024};
|
|
34
|
+
|
|
35
|
+
/** @type {Map<string, {mtimeMs: number, size: number, body: Buffer}>} key = encoding + NUL + path */
|
|
36
|
+
const _entries = new Map();
|
|
37
|
+
/** @type {Map<string, Promise<Buffer>>} */
|
|
38
|
+
const _inflight = new Map();
|
|
39
|
+
let _bytes = 0;
|
|
40
|
+
let _limits = {...DEFAULTS};
|
|
41
|
+
const _stats = {hits: 0, misses: 0, evictions: 0};
|
|
42
|
+
|
|
43
|
+
cache.onInvalidate((reason) => {
|
|
44
|
+
// Tags name pages, collections and blocks, never files; a file's own
|
|
45
|
+
// change is seen through its mtime. A clear, a re-init or the cache going
|
|
46
|
+
// off empties this too.
|
|
47
|
+
if (reason !== 'tags') clearAssetCache();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export function clearAssetCache() {
|
|
51
|
+
_entries.clear();
|
|
52
|
+
_inflight.clear();
|
|
53
|
+
_bytes = 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function assetCacheStats() {
|
|
57
|
+
return {entries: _entries.size, bytes: _bytes, maxBytes: _limits.maxBytes, hits: _stats.hits, misses: _stats.misses, evictions: _stats.evictions};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function _resetAssetCacheForTests() {
|
|
61
|
+
clearAssetCache();
|
|
62
|
+
_stats.hits = 0;
|
|
63
|
+
_stats.misses = 0;
|
|
64
|
+
_stats.evictions = 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Is this a stream @fastify/static made for a file? @fastify/send opens the
|
|
69
|
+
* file with fs.createReadStream, which carries the path it opened.
|
|
70
|
+
*/
|
|
71
|
+
function fileOf(payload) {
|
|
72
|
+
return payload && typeof payload.pipe === 'function' && typeof payload.path === 'string' ? payload.path : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function bodyFor(file, st, encoding) {
|
|
76
|
+
const key = `${encoding}\0${file}`;
|
|
77
|
+
const kept = _entries.get(key);
|
|
78
|
+
if (kept && kept.mtimeMs === st.mtimeMs && kept.size === st.size) {
|
|
79
|
+
_entries.delete(key);
|
|
80
|
+
_entries.set(key, kept); // most recently served last
|
|
81
|
+
_stats.hits++;
|
|
82
|
+
return kept.body;
|
|
83
|
+
}
|
|
84
|
+
if (_inflight.has(key)) return _inflight.get(key);
|
|
85
|
+
_stats.misses++;
|
|
86
|
+
const making = (async () => {
|
|
87
|
+
try {
|
|
88
|
+
const raw = await fs.readFile(file);
|
|
89
|
+
const body = encoding === 'identity' ? raw : await encode(raw, encoding);
|
|
90
|
+
if (kept) { _bytes -= kept.body.length; _entries.delete(key); }
|
|
91
|
+
_entries.set(key, {mtimeMs: st.mtimeMs, size: st.size, body});
|
|
92
|
+
_bytes += body.length;
|
|
93
|
+
while (_bytes > _limits.maxBytes && _entries.size > 1) {
|
|
94
|
+
const [oldestKey, oldest] = _entries.entries().next().value;
|
|
95
|
+
_entries.delete(oldestKey);
|
|
96
|
+
_bytes -= oldest.body.length;
|
|
97
|
+
_stats.evictions++;
|
|
98
|
+
}
|
|
99
|
+
return body;
|
|
100
|
+
} finally {
|
|
101
|
+
_inflight.delete(key);
|
|
102
|
+
}
|
|
103
|
+
})();
|
|
104
|
+
_inflight.set(key, making);
|
|
105
|
+
return making;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Add the onSend hook. Call BEFORE registering @fastify/compress, so this
|
|
110
|
+
* runs first and compress sees `content-encoding` already set and steps aside.
|
|
111
|
+
*
|
|
112
|
+
* @param {import('fastify').FastifyInstance} app
|
|
113
|
+
* @param {{maxBytes?: number, maxFileBytes?: number, minBytes?: number}} [limits]
|
|
114
|
+
*/
|
|
115
|
+
export function registerAssetCache(app, limits = {}) {
|
|
116
|
+
_limits = {...DEFAULTS, ...limits};
|
|
117
|
+
app.addHook('onSend', async (request, reply, payload) => {
|
|
118
|
+
if (request.method !== 'GET' || reply.statusCode !== 200 || !cache.isEnabled()) return payload;
|
|
119
|
+
const file = fileOf(payload);
|
|
120
|
+
if (!file || request.headers.range) return payload;
|
|
121
|
+
if (!COMPRESSIBLE.test(String(reply.getHeader('content-type') || ''))) return payload;
|
|
122
|
+
if (reply.getHeader('content-encoding')) return payload; // a precompressed file @fastify/static served itself
|
|
123
|
+
|
|
124
|
+
let st;
|
|
125
|
+
try { st = await fs.stat(file); } catch { return payload; }
|
|
126
|
+
if (!st.isFile() || st.size < _limits.minBytes || st.size > _limits.maxFileBytes) return payload;
|
|
127
|
+
|
|
128
|
+
const encoding = pickEncoding(request.headers['accept-encoding']) || 'identity';
|
|
129
|
+
let body;
|
|
130
|
+
try { body = await bodyFor(file, st, encoding); } catch { return payload; }
|
|
131
|
+
|
|
132
|
+
payload.destroy(); // the stream @fastify/static opened: release its file descriptor
|
|
133
|
+
reply.removeHeader('accept-ranges');
|
|
134
|
+
reply.header('content-length', body.length);
|
|
135
|
+
if (encoding !== 'identity') {
|
|
136
|
+
reply.header('content-encoding', encoding);
|
|
137
|
+
reply.header('vary', 'accept-encoding');
|
|
138
|
+
}
|
|
139
|
+
return body;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -72,12 +72,12 @@ export function pickEncoding(header, offered = ENCODINGS) {
|
|
|
72
72
|
/**
|
|
73
73
|
* The compressed body, the way @fastify/compress would have made it.
|
|
74
74
|
*
|
|
75
|
-
* @param {string}
|
|
75
|
+
* @param {string|Buffer} input
|
|
76
76
|
* @param {string} encoding - 'br' | 'gzip'
|
|
77
77
|
* @returns {Promise<Buffer>}
|
|
78
78
|
*/
|
|
79
|
-
export function
|
|
80
|
-
|
|
79
|
+
export function encode(input, encoding) {
|
|
80
|
+
if (!Buffer.isBuffer(input)) input = Buffer.from(String(input), 'utf8');
|
|
81
81
|
if (encoding === 'br') {
|
|
82
82
|
return brotli(input, {params: {
|
|
83
83
|
[zlib.constants.BROTLI_PARAM_QUALITY]: 4,
|
|
@@ -88,6 +88,9 @@ export function encodeHtml(html, encoding) {
|
|
|
88
88
|
throw new Error(`Unknown encoding: ${encoding}`);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/** encode() for a page's HTML. */
|
|
92
|
+
export const encodeHtml = encode;
|
|
93
|
+
|
|
91
94
|
/** The validator's content part: the same hash whatever the encoding. */
|
|
92
95
|
export function htmlHash(html) {
|
|
93
96
|
return createHash('sha1').update(html).digest('base64');
|
|
@@ -687,6 +687,10 @@ function buildSeoMeta({page, site, baseUrl, title, description, image}) {
|
|
|
687
687
|
});
|
|
688
688
|
}
|
|
689
689
|
|
|
690
|
+
// The page's own structured data (frontmatter `seo.schema` - the editor's
|
|
691
|
+
// SEO tab): one object or a list, each emitted as its own block.
|
|
692
|
+
jsonLd.push(...schemaBlocks(page.seo?.schema));
|
|
693
|
+
|
|
690
694
|
return {
|
|
691
695
|
title,
|
|
692
696
|
description,
|
|
@@ -703,6 +707,32 @@ function buildSeoMeta({page, site, baseUrl, title, description, image}) {
|
|
|
703
707
|
};
|
|
704
708
|
}
|
|
705
709
|
|
|
710
|
+
/**
|
|
711
|
+
* The JSON-LD blocks a page declares in `seo.schema`: an object, a list of
|
|
712
|
+
* them, or a JSON string of either (a hand-written frontmatter may hold it as
|
|
713
|
+
* text). Anything that is not a plain object with a string `@type` is dropped
|
|
714
|
+
* rather than emitted broken, and `@context` defaults to schema.org, which is
|
|
715
|
+
* what every example and validator expects. Pure.
|
|
716
|
+
*
|
|
717
|
+
* @param {unknown} value
|
|
718
|
+
* @returns {object[]}
|
|
719
|
+
*/
|
|
720
|
+
export function schemaBlocks(value) {
|
|
721
|
+
let v = value;
|
|
722
|
+
if (typeof v === 'string') {
|
|
723
|
+
if (!v.trim()) return [];
|
|
724
|
+
try { v = JSON.parse(v); } catch { return []; }
|
|
725
|
+
}
|
|
726
|
+
const list = Array.isArray(v) ? v : [v];
|
|
727
|
+
const out = [];
|
|
728
|
+
for (const item of list) {
|
|
729
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
|
|
730
|
+
if (typeof item['@type'] !== 'string' || !item['@type'].trim()) continue;
|
|
731
|
+
out.push(item['@context'] ? item : {'@context': 'https://schema.org', ...item});
|
|
732
|
+
}
|
|
733
|
+
return out;
|
|
734
|
+
}
|
|
735
|
+
|
|
706
736
|
/** An absolute URL for og:image and JSON-LD: crawlers ignore relative ones. */
|
|
707
737
|
function absoluteUrl(url, origin) {
|
|
708
738
|
const u = String(url || '').trim();
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
<link rel="stylesheet" href="/dist/domma/themes/domma-themes.css">
|
|
26
26
|
|
|
27
27
|
<!-- Site CSS -->
|
|
28
|
-
<link rel="stylesheet" href="/public/css/site.css?v=
|
|
28
|
+
<link rel="stylesheet" href="/public/css/site.css?v=20261019-button-defaults">
|
|
29
29
|
<link rel="stylesheet" href="/public/css/forms.css?v=20260927-submit-btn-css">
|
|
30
30
|
<!-- Menu item hover / current-page effects. Loaded after site.css so a
|
|
31
31
|
menu's own highlight wins over the blanket link styling there. -->
|