domma-cms 1.6.48 → 1.6.50

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,220 @@
1
+ /**
2
+ * List tags - admin-only labels that link pages (or blocks) together so the
3
+ * Pages and Blocks lists can be narrowed to one tag. Nothing on the public
4
+ * site reads them.
5
+ *
6
+ * An item may carry several. They live on the item itself: a page's
7
+ * frontmatter `adminTags`, a block's sidecar `meta.adminTags`. A page's own
8
+ * `tags` are a different thing - editorial, public (search, the Content API) -
9
+ * and are never touched here.
10
+ *
11
+ * config/list-tags.json keeps the names per kind (`{pages: [], blocks: []}`)
12
+ * so a tag can exist before anything wears it; a tag found on an item but not
13
+ * in the file is still listed. Absence of the file = no names yet.
14
+ *
15
+ * Tagging is metadata, not an edit: a page's tags are written without a
16
+ * revision and without moving its updatedAt.
17
+ */
18
+ import {getConfig, saveConfig} from '../config.js';
19
+ import {getPage, listPages, setPageFrontmatter} from './content.js';
20
+ import {getBlock, listBlocks, setBlockMeta} from './blocks.js';
21
+
22
+ export const KINDS = ['pages', 'blocks'];
23
+ export const MAX_TAG = 40;
24
+ const CONFIG_NAME = 'list-tags';
25
+
26
+ /** One tag name as stored: trimmed, inner whitespace collapsed, capped. '' when unusable. */
27
+ export function normaliseTag(name) {
28
+ if (typeof name !== 'string') return '';
29
+ // eslint-disable-next-line no-control-regex
30
+ return name.replace(/[\u0000-\u001f\u007f]/g, '').replace(/\s+/g, ' ').trim().slice(0, MAX_TAG);
31
+ }
32
+
33
+ /** A tag list: normalised, no blanks, no case-insensitive duplicates (the first spelling wins). */
34
+ export function cleanTagList(list) {
35
+ if (!Array.isArray(list)) return [];
36
+ const seen = new Set();
37
+ const out = [];
38
+ for (const raw of list) {
39
+ const t = normaliseTag(raw);
40
+ if (!t || seen.has(t.toLowerCase())) continue;
41
+ seen.add(t.toLowerCase());
42
+ out.push(t);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ const same = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
48
+
49
+ function assertKind(kind) {
50
+ if (KINDS.includes(kind)) return;
51
+ const err = new Error(`Unknown kind "${kind}"`);
52
+ err.code = 'BAD_KIND';
53
+ throw err;
54
+ }
55
+
56
+ function readRegistry() {
57
+ let raw = {};
58
+ try {
59
+ raw = getConfig(CONFIG_NAME);
60
+ } catch { /* absent or unreadable = no names yet */ }
61
+ return Object.fromEntries(KINDS.map(k => [k, cleanTagList(raw?.[k])]));
62
+ }
63
+
64
+ function writeRegistry(reg) {
65
+ saveConfig(CONFIG_NAME, Object.fromEntries(KINDS.map(k => [k, cleanTagList(reg[k])])));
66
+ }
67
+
68
+ /** The items of a kind as `{id, tags}`, through the same listers the routes use. */
69
+ async function items(kind) {
70
+ if (kind === 'pages') return (await listPages()).map(p => ({id: p.urlPath, tags: cleanTagList(p.adminTags), raw: p}));
71
+ return (await listBlocks()).map(b => ({id: b.name, tags: cleanTagList(b.meta?.adminTags), raw: b}));
72
+ }
73
+
74
+ async function writeItemTags(kind, id, tags) {
75
+ const clean = cleanTagList(tags);
76
+ if (kind === 'pages') await setPageFrontmatter(id, {adminTags: clean.length ? clean : undefined});
77
+ else await setBlockMeta(id, {adminTags: clean.length ? clean : undefined});
78
+ return clean;
79
+ }
80
+
81
+ /**
82
+ * Every tag of a kind with how many items wear it: the registry's names in
83
+ * alphabetical order, plus any name only found on items.
84
+ *
85
+ * @param {string} kind
86
+ * @param {(item: object) => boolean} [visible] - the caller's scope filter
87
+ * @returns {Promise<{name: string, count: number}[]>}
88
+ */
89
+ export async function listTags(kind, visible = () => true) {
90
+ assertKind(kind);
91
+ const names = [...readRegistry()[kind]];
92
+ const counts = new Map();
93
+ for (const it of await items(kind)) {
94
+ if (!visible(it.raw)) continue;
95
+ for (const t of it.tags) {
96
+ const known = names.find(n => same(n, t));
97
+ if (!known) names.push(t);
98
+ const key = (known || t).toLowerCase();
99
+ counts.set(key, (counts.get(key) || 0) + 1);
100
+ }
101
+ }
102
+ return cleanTagList(names)
103
+ .sort((a, b) => a.localeCompare(b, undefined, {sensitivity: 'base'}))
104
+ .map(name => ({name, count: counts.get(name.toLowerCase()) || 0}));
105
+ }
106
+
107
+ /** Add a name to the registry (no item changes). Returns the stored spelling. */
108
+ async function addTagNow(kind, name) {
109
+ assertKind(kind);
110
+ const tag = normaliseTag(name);
111
+ if (!tag) {
112
+ const err = new Error('A tag needs a name.');
113
+ err.code = 'BAD_TAG';
114
+ throw err;
115
+ }
116
+ const reg = readRegistry();
117
+ const existing = reg[kind].find(n => same(n, tag));
118
+ if (existing) return existing;
119
+ reg[kind].push(tag);
120
+ writeRegistry(reg);
121
+ return tag;
122
+ }
123
+
124
+ /**
125
+ * Change one item's tags: `tags` replaces the list, `add` / `remove` patch the
126
+ * stored one (read afresh here, so two quick changes from the menu both land).
127
+ * Names it brings that the registry lacks are added to it.
128
+ *
129
+ * @param {string} kind
130
+ * @param {string} id - urlPath or block name
131
+ * @param {{tags?: string[], add?: string[], remove?: string[]}} change
132
+ * @returns {Promise<string[]>} the stored list
133
+ */
134
+ async function changeItemTagsNow(kind, id, {tags, add = [], remove = []} = {}) {
135
+ assertKind(kind);
136
+ let next;
137
+ if (Array.isArray(tags)) next = tags;
138
+ else {
139
+ const item = kind === 'pages' ? await getPage(id) : await getBlock(id).catch(() => null);
140
+ if (!item) {
141
+ const err = new Error(`Not found: ${id}`);
142
+ err.code = 'ENOENT';
143
+ throw err;
144
+ }
145
+ const stored = cleanTagList(kind === 'pages' ? item.adminTags : item.meta?.adminTags);
146
+ next = [...stored, ...(Array.isArray(add) ? add : [])]
147
+ .filter(t => !(Array.isArray(remove) ? remove : []).some(r => same(r, t)));
148
+ }
149
+ const clean = await writeItemTags(kind, id, next);
150
+ const reg = readRegistry();
151
+ const fresh = clean.filter(t => !reg[kind].some(n => same(n, t)));
152
+ if (fresh.length) {
153
+ reg[kind].push(...fresh);
154
+ writeRegistry(reg);
155
+ }
156
+ return clean;
157
+ }
158
+
159
+ /**
160
+ * Rename a tag on every item the caller may see. Renaming onto an existing
161
+ * tag merges the two. An item outside the caller's projects keeps the old
162
+ * name, which then still lists (found on an item).
163
+ *
164
+ * @returns {Promise<{name: string, changed: number}>}
165
+ */
166
+ async function renameTagNow(kind, from, to, visible = () => true) {
167
+ assertKind(kind);
168
+ const target = normaliseTag(to);
169
+ if (!target) {
170
+ const err = new Error('A tag needs a name.');
171
+ err.code = 'BAD_TAG';
172
+ throw err;
173
+ }
174
+ let changed = 0;
175
+ for (const it of await items(kind)) {
176
+ if (!visible(it.raw) || !it.tags.some(t => same(t, from))) continue;
177
+ await writeItemTags(kind, it.id, it.tags.map(t => (same(t, from) ? target : t)));
178
+ changed++;
179
+ }
180
+ const reg = readRegistry();
181
+ reg[kind] = reg[kind].filter(n => !same(n, from) && !same(n, target)).concat(target);
182
+ writeRegistry(reg);
183
+ return {name: target, changed};
184
+ }
185
+
186
+ /**
187
+ * Delete a tag: it comes off every item the caller may see. The items stay.
188
+ *
189
+ * @returns {Promise<{changed: number}>}
190
+ */
191
+ async function deleteTagNow(kind, name, visible = () => true) {
192
+ assertKind(kind);
193
+ let changed = 0;
194
+ for (const it of await items(kind)) {
195
+ if (!visible(it.raw) || !it.tags.some(t => same(t, name))) continue;
196
+ await writeItemTags(kind, it.id, it.tags.filter(t => !same(t, name)));
197
+ changed++;
198
+ }
199
+ const reg = readRegistry();
200
+ reg[kind] = reg[kind].filter(n => !same(n, name));
201
+ writeRegistry(reg);
202
+ return {changed};
203
+ }
204
+
205
+ // Every change runs one after another: each is a read-modify-write of item
206
+ // files and the registry, and two interleaved (a rename while someone tags a
207
+ // page) would lose one of them.
208
+ let queue = Promise.resolve();
209
+ function serial(fn) {
210
+ const run = queue.then(fn, fn);
211
+ queue = run.catch(() => {});
212
+ return run;
213
+ }
214
+
215
+ export const addTag = (kind, name) => serial(() => addTagNow(kind, name));
216
+ export const changeItemTags = (kind, id, change) => serial(() => changeItemTagsNow(kind, id, change));
217
+ /** Replace one item's tags. */
218
+ export const setItemTags = (kind, id, tags) => changeItemTags(kind, id, {tags});
219
+ export const renameTag = (kind, from, to, visible) => serial(() => renameTagNow(kind, from, to, visible));
220
+ export const deleteTag = (kind, name, visible) => serial(() => deleteTagNow(kind, name, visible));
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import fs from 'fs/promises';
7
7
  import path from 'path';
8
+ import matter from 'gray-matter';
8
9
  import {config} from '../config.js';
9
10
  import {hooks} from './hooks.js';
10
11
  import {getUserById} from './users.js';
@@ -243,6 +244,27 @@ export async function deleteVersion(urlPath, filename) {
243
244
  await writeMeta(dir, meta.filter(m => m.filename !== filename));
244
245
  }
245
246
 
247
+ /**
248
+ * A restore brings back the page, not its list tags: tagging adds no
249
+ * revision (services/listTags.js), so no snapshot holds the current tags,
250
+ * and restoring one would quietly drop or un-rename them. The live
251
+ * `adminTags` are carried onto the restored source.
252
+ *
253
+ * @param {string} livePath
254
+ * @param {string} restored - the version's file content
255
+ * @returns {Promise<string>}
256
+ */
257
+ async function keepLiveAdminTags(livePath, restored) {
258
+ let live;
259
+ try { live = matter(await fs.readFile(livePath, 'utf8')).data?.adminTags; } catch { return restored; }
260
+ const old = matter(restored);
261
+ if (JSON.stringify(old.data?.adminTags ?? null) === JSON.stringify(live ?? null)) return restored;
262
+ const data = {...old.data};
263
+ if (Array.isArray(live) && live.length) data.adminTags = live;
264
+ else delete data.adminTags;
265
+ return matter.stringify(old.content, data);
266
+ }
267
+
246
268
  /**
247
269
  * Restore a version: snapshot current state first, then overwrite the live page.
248
270
  *
@@ -260,8 +282,9 @@ export async function restoreVersion(urlPath, filename, author) {
260
282
  await createVersion(urlPath, {author, type: 'auto', label: 'Pre-restore snapshot'});
261
283
 
262
284
  // Overwrite live page with version content
263
- const content = await fs.readFile(filePath, 'utf8');
285
+ let content = await fs.readFile(filePath, 'utf8');
264
286
  const livePath = pageFilePath(urlPath);
287
+ content = await keepLiveAdminTags(livePath, content);
265
288
  await fs.writeFile(livePath, content, 'utf8');
266
289
 
267
290
  hooks.emit('content:pageUpdated', {urlPath});