domma-cms 1.6.49 → 1.6.51
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/admin/js/api.js +1 -1
- package/admin/js/lib/block-editor-arrange.js +1 -1
- package/admin/js/lib/blocks-arrange.js +1 -1
- package/admin/js/lib/list-tags-arrange.js +1 -0
- package/admin/js/lib/list-tags.js +37 -0
- package/admin/js/lib/pages-arrange.js +1 -1
- package/admin/js/templates/blocks.html +9 -1
- package/admin/js/templates/page-editor.html +7 -0
- package/admin/js/templates/pages.html +7 -1
- package/admin/js/views/blocks.js +5 -4
- package/admin/js/views/page-editor.js +7 -7
- package/admin/js/views/pages.js +4 -3
- package/package.json +1 -1
- package/server/routes/api/list-tags.js +85 -0
- package/server/routes/api/pages.js +2 -0
- package/server/server.js +2 -0
- package/server/services/blocks.js +33 -0
- package/server/services/content.js +49 -0
- package/server/services/listTags.js +220 -0
- package/server/services/versions.js +24 -1
|
@@ -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
|
-
|
|
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});
|