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.
@@ -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} html
75
+ * @param {string|Buffer} input
76
76
  * @param {string} encoding - 'br' | 'gzip'
77
77
  * @returns {Promise<Buffer>}
78
78
  */
79
- export function encodeHtml(html, encoding) {
80
- const input = Buffer.from(html, 'utf8');
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=20261016-footer-blocks">
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. -->