domma-cms 1.8.46 → 1.9.1

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/admin/js/lib/shortcode-registry.js +1 -1
  3. package/admin/js/templates/docs/usage-shortcodes.html +1 -1
  4. package/admin/js/templates/docs/usage-users-roles.html +4 -0
  5. package/bin/lib/left-free-tier.js +83 -0
  6. package/bin/update.js +24 -0
  7. package/docs/DommaFeatures.md +47 -7
  8. package/docs/markdown-shortcodes.md +4 -0
  9. package/docs/plugin-development.md +7 -0
  10. package/package.json +1 -1
  11. package/plugins/_lib/games/names.js +6 -0
  12. package/plugins/_lib/public/games/motion.css +1 -0
  13. package/plugins/_lib/public/games/motion.js +28 -0
  14. package/plugins/free-tier.lock.json +1 -43
  15. package/server/routes/api/api-endpoints.js +5 -1
  16. package/server/routes/api/collections.js +4 -3
  17. package/server/routes/api/forms.js +22 -0
  18. package/server/routes/api/views.js +18 -0
  19. package/server/services/collectionAccess.js +97 -15
  20. package/server/services/markdown.js +7 -1
  21. package/server/services/permissionRegistry.js +39 -11
  22. package/plugins/crossword/CLAUDE.md +0 -37
  23. package/plugins/crossword/admin/css/index.css +0 -18
  24. package/plugins/crossword/admin/templates/crossword.html +0 -1
  25. package/plugins/crossword/admin/views/crossword.js +0 -449
  26. package/plugins/crossword/collections/crossword-plays.json +0 -20
  27. package/plugins/crossword/collections/crossword-puzzles.json +0 -17
  28. package/plugins/crossword/config.js +0 -6
  29. package/plugins/crossword/docs/guide.md +0 -53
  30. package/plugins/crossword/inject/body.html +0 -1
  31. package/plugins/crossword/lib/crossword.js +0 -170
  32. package/plugins/crossword/lib/store.js +0 -56
  33. package/plugins/crossword/plugin.js +0 -274
  34. package/plugins/crossword/plugin.json +0 -79
  35. package/plugins/crossword/public/crossword.css +0 -36
  36. package/plugins/crossword/public/crossword.js +0 -519
  37. package/plugins/crossword/public/lib/layout.js +0 -199
  38. package/plugins/quiz/CLAUDE.md +0 -43
  39. package/plugins/quiz/admin/css/index.css +0 -13
  40. package/plugins/quiz/admin/templates/quiz.html +0 -1
  41. package/plugins/quiz/admin/views/quiz.js +0 -530
  42. package/plugins/quiz/collections/quiz-plays.json +0 -20
  43. package/plugins/quiz/collections/quiz-quizzes.json +0 -17
  44. package/plugins/quiz/config.js +0 -6
  45. package/plugins/quiz/docs/guide.md +0 -61
  46. package/plugins/quiz/inject/body.html +0 -1
  47. package/plugins/quiz/lib/engine.js +0 -115
  48. package/plugins/quiz/lib/quiz.js +0 -225
  49. package/plugins/quiz/lib/store.js +0 -56
  50. package/plugins/quiz/plugin.js +0 -275
  51. package/plugins/quiz/plugin.json +0 -79
  52. package/plugins/quiz/public/quiz.css +0 -36
  53. package/plugins/quiz/public/quiz.js +0 -372
@@ -1,170 +0,0 @@
1
- /**
2
- * Crossword - the rules. PURE: no I/O, so the routes and the tests share them.
3
- *
4
- * cleanPuzzle(input) -> {puzzle, errors, unplaced} what the admin saved, laid out by the server
5
- * isOpen(puzzle, now) -> {open, reason}
6
- * publicPuzzle(puzzle) -> the grid's shape and the clues - never a letter of the answers
7
- * readCells(raw, puzzle) -> the player's letters as rows, or null when malformed
8
- * compare(cells, solution) -> {wrong: [[r, c]], empty: n}
9
- * catchUp / finish -> the play, by the server's clock
10
- *
11
- * @module crossword/lib/crossword
12
- */
13
-
14
- import {GRACE_MS, leftOf, overdue} from '../../_lib/games/timing.js';
15
- import {MAX_WORDS, generate, shapeOf, solutionGrid} from '../public/lib/layout.js';
16
-
17
- export const STATUSES = ['draft', 'open', 'closed'];
18
- export const LIMITS = {clue: 300, answer: 40, timeLimit: 6 * 3600, penalty: 600};
19
-
20
- const str = (v, max) => String(v ?? '').replace(/\r\n?/g, '\n').trim().slice(0, max);
21
- const int = (v, min, max, dflt = 0) => {
22
- const n = Math.round(Number(v));
23
- return Number.isFinite(n) ? Math.max(min, Math.min(max, n)) : dflt;
24
- };
25
- const iso = (v) => {
26
- const t = Date.parse(String(v || ''));
27
- return Number.isFinite(t) ? new Date(t).toISOString() : '';
28
- };
29
-
30
- export function slugify(s) {
31
- return String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
32
- .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
33
- }
34
-
35
- /**
36
- * The puzzle as the admin saved it. The grid is laid out HERE from the words
37
- * and the seed (the same function the builder previews with), so a layout is
38
- * never taken from the browser.
39
- *
40
- * @returns {{puzzle: object, errors: Object<string, string>, unplaced: object[]}}
41
- */
42
- export function cleanPuzzle(input = {}) {
43
- const errors = {};
44
- const words = (Array.isArray(input.words) ? input.words : []).slice(0, MAX_WORDS)
45
- .map(w => ({answer: str(w?.answer, LIMITS.answer), clue: str(w?.clue, LIMITS.clue)}))
46
- .filter(w => w.answer || w.clue);
47
- const puzzle = {
48
- title: str(input.title, 120),
49
- slug: slugify(input.slug || input.title),
50
- intro: str(input.intro, 2000),
51
- status: STATUSES.includes(input.status) ? input.status : 'draft',
52
- opensAt: iso(input.opensAt),
53
- closesAt: iso(input.closesAt),
54
- timeLimit: int(input.timeLimit, 0, LIMITS.timeLimit),
55
- checks: input.checks !== false,
56
- checkPenalty: int(input.checkPenalty ?? 30, 0, LIMITS.penalty, 30),
57
- wrongPenalty: int(input.wrongPenalty ?? 10, 0, LIMITS.penalty, 10),
58
- leaderboard: input.leaderboard !== false,
59
- leaderboardSize: int(input.leaderboardSize ?? 10, 3, 100, 10),
60
- attemptsPerName: int(input.attemptsPerName, 0, 100),
61
- seed: int(input.seed ?? 1, 1, 2 ** 31 - 1, 1),
62
- words,
63
- layout: {width: 0, height: 0, entries: []}
64
- };
65
- if (!puzzle.title) errors.title = 'Give the crossword a title.';
66
- if (!puzzle.slug) errors.slug = 'Give the crossword a short name for its address (letters and numbers).';
67
- if (puzzle.opensAt && puzzle.closesAt && puzzle.closesAt <= puzzle.opensAt) errors.closesAt = 'It has to close after it opens.';
68
- words.forEach((w, i) => {
69
- if (!w.answer) errors[`w${i + 1}`] ??= `Word ${i + 1} has a clue but no answer.`;
70
- else if (!w.clue) errors[`w${i + 1}`] ??= `"${w.answer}" needs a clue.`;
71
- });
72
- const laid = generate(words.filter(w => w.answer && w.clue), {seed: puzzle.seed});
73
- puzzle.layout = {width: laid.width, height: laid.height, entries: laid.entries};
74
- if (puzzle.status === 'open' && laid.entries.length < 2) errors.words = 'Add at least two answers that cross before opening the crossword.';
75
- return {puzzle, errors, unplaced: laid.unplaced};
76
- }
77
-
78
- export function isOpen(puzzle, now = Date.now()) {
79
- if (!puzzle || puzzle.status === 'draft') return {open: false, reason: 'This crossword is not open yet.', state: 'draft'};
80
- if (puzzle.status === 'closed') return {open: false, reason: 'This crossword has closed.', state: 'closed'};
81
- if (puzzle.opensAt && Date.parse(puzzle.opensAt) > now) return {open: false, reason: 'This crossword opens soon.', state: 'scheduled', opensAt: puzzle.opensAt};
82
- if (puzzle.closesAt && Date.parse(puzzle.closesAt) <= now) return {open: false, reason: 'This crossword has closed.', state: 'ended'};
83
- if ((puzzle.layout?.entries?.length || 0) < 1) return {open: false, reason: 'This crossword is not ready yet.', state: 'empty'};
84
- return {open: true, reason: '', state: 'open'};
85
- }
86
-
87
- /** What anyone may see: the shape, the numbers and the clues - no letters. */
88
- export function publicPuzzle(puzzle) {
89
- const {width, height, entries} = puzzle.layout;
90
- const grid = solutionGrid(puzzle.layout);
91
- const clue = (e) => ({number: e.number, dir: e.dir, clue: e.clue, enumeration: e.enumeration, row: e.row, col: e.col, length: e.letters.length});
92
- return {
93
- width, height, shape: shapeOf(grid),
94
- across: entries.filter(e => e.dir === 'across').map(clue),
95
- down: entries.filter(e => e.dir === 'down').map(clue)
96
- };
97
- }
98
-
99
- /**
100
- * The player's grid: `height` strings of `width` characters, a letter or a
101
- * space. Anything else (a black square they typed into, a wrong size) is null.
102
- */
103
- export function readCells(raw, puzzle) {
104
- const {width, height} = puzzle.layout;
105
- if (!Array.isArray(raw) || raw.length !== height) return null;
106
- const rows = [];
107
- for (const r of raw) {
108
- const row = String(r ?? '').toUpperCase();
109
- if (row.length !== width || !/^[A-Z ]*$/.test(row)) return null;
110
- rows.push(row);
111
- }
112
- return rows;
113
- }
114
-
115
- /** Which squares are wrong (a letter that is not the answer's) and how many are still empty. */
116
- export function compare(cells, solution) {
117
- const wrong = [];
118
- let empty = 0;
119
- solution.forEach((row, r) => row.forEach((ch, c) => {
120
- if (!ch) return;
121
- const given = cells[r][c];
122
- if (given === ' ') empty++;
123
- else if (given !== ch) wrong.push([r, c]);
124
- }));
125
- return {wrong, empty};
126
- }
127
-
128
- const limitMs = (puzzle) => (puzzle.timeLimit || 0) * 1000;
129
-
130
- /** Out of time while nobody asked: the play ends, off the board. */
131
- export function catchUp(puzzle, play, now = Date.now()) {
132
- if (play.status !== 'playing' || !overdue(play.startedAt, limitMs(puzzle), now)) return false;
133
- play.status = 'timeout';
134
- play.finishedAt = new Date(now).toISOString();
135
- play.timeMs = limitMs(puzzle);
136
- return true;
137
- }
138
-
139
- /** Solved: the time is the server's, plus every penalty. */
140
- export function finish(play, now = Date.now()) {
141
- play.status = 'finished';
142
- play.finishedAt = new Date(now).toISOString();
143
- play.score = 1;
144
- play.timeMs = Math.max(0, now - Date.parse(play.startedAt)) + (play.state.penaltyMs || 0);
145
- return play;
146
- }
147
-
148
- /** What the player may know of their play right now. */
149
- export function view(puzzle, play, now = Date.now()) {
150
- const end = play.status === 'playing' ? now : Date.parse(play.finishedAt);
151
- return {
152
- status: play.status,
153
- elapsedMs: Math.max(0, end - Date.parse(play.startedAt)),
154
- penaltyMs: play.state.penaltyMs || 0,
155
- checks: play.state.checks || 0,
156
- totalLeftMs: play.status === 'playing' ? leftOf(play.startedAt, limitMs(puzzle), now) : null,
157
- grace: GRACE_MS
158
- };
159
- }
160
-
161
- /** The puzzle as a list row in the admin. */
162
- export function summary(puzzle, plays = []) {
163
- const finished = plays.filter(p => p.status === 'finished');
164
- return {
165
- id: puzzle.id, title: puzzle.title, slug: puzzle.slug, status: puzzle.status,
166
- words: puzzle.layout?.entries?.length || 0, size: `${puzzle.layout?.width || 0}×${puzzle.layout?.height || 0}`,
167
- plays: plays.length, finished: finished.length,
168
- fastest: finished.length ? Math.min(...finished.map(p => p.timeMs || 0)) : 0, updatedAt: puzzle.updatedAt || ''
169
- };
170
- }
@@ -1,56 +0,0 @@
1
- /**
2
- * Crossword - where puzzles and plays are kept: the collections `crossword-puzzles` and
3
- * `crossword-plays` (made at every boot when missing; API access all off - a puzzle
4
- * holds its answers, a play the player's name).
5
- *
6
- * @module crossword/lib/store
7
- */
8
-
9
- import fs from 'node:fs/promises';
10
- import {createCollection, createEntry, deleteEntry, getCollection, getEntry, listEntries, updateEntry} from '../../../server/services/collections.js';
11
- import {PlayStore} from '../../_lib/games/plays.js';
12
-
13
- export const PUZZLES = 'crossword-puzzles';
14
- export const PLAYS = 'crossword-plays';
15
-
16
- export const plays = new PlayStore(PLAYS, {order: 'time'});
17
-
18
- export async function ensureCollections(log = console) {
19
- for (const slug of [PUZZLES, PLAYS]) {
20
- if (await getCollection(slug)) continue;
21
- const schema = JSON.parse(await fs.readFile(new URL(`../collections/${slug}.json`, import.meta.url), 'utf8'));
22
- await createCollection(schema);
23
- log.info?.(`[crossword] Created collection: ${slug}`);
24
- }
25
- }
26
-
27
- const flat = (e) => (e ? {id: e.id, ...e.data, createdAt: e.meta?.createdAt, updatedAt: e.meta?.updatedAt} : null);
28
-
29
- let cache = null;
30
- export async function allPuzzles() {
31
- cache ??= listEntries(PUZZLES, {limit: 100000}).then(r => (r.entries || []).map(flat)).catch(() => []);
32
- return cache;
33
- }
34
- const forget = () => { cache = null; };
35
-
36
- export const getPuzzle = async (id) => flat(await getEntry(PUZZLES, String(id)).catch(() => null));
37
- export const puzzleBySlug = async (slug) => (await allPuzzles()).find(q => q.slug === String(slug)) || null;
38
-
39
- /** A short name no other crossword has. */
40
- export async function uniqueSlug(slug, selfId = '') {
41
- const taken = new Set((await allPuzzles()).filter(q => q.id !== selfId).map(q => q.slug));
42
- if (!taken.has(slug)) return slug;
43
- for (let n = 2; ; n++) if (!taken.has(`${slug}-${n}`)) return `${slug}-${n}`;
44
- }
45
-
46
- export async function savePuzzle(id, data) {
47
- const saved = id ? await updateEntry(PUZZLES, id, data) : await createEntry(PUZZLES, data);
48
- forget();
49
- return flat(saved);
50
- }
51
-
52
- export async function removePuzzle(id) {
53
- await deleteEntry(PUZZLES, String(id));
54
- forget();
55
- return plays.reset(String(id));
56
- }
@@ -1,274 +0,0 @@
1
- /**
2
- * Crossword - the API (mounted at /api/plugins/crossword).
3
- *
4
- * Admin (the `crossword` permission; read to look, manage to change):
5
- * GET /count sidebar badge: crosswords finished today
6
- * GET /puzzles every crossword as a list row
7
- * GET /puzzles/:id · POST /puzzles · PUT /puzzles/:id · DELETE /puzzles/:id
8
- * POST /puzzles/:id/copy a copy, as a draft
9
- * GET /puzzles/:id/test what the player's start screen shows, as if it were open
10
- * POST /puzzles/:id/test a TEST play of it as saved (draft or closed too) -> {playId, token, name, info};
11
- * played through the /public/play routes, never on a board (see _lib/games/plays.js)
12
- * GET /puzzles/:id/plays · DELETE /puzzles/:id/plays · PUT /plays/:id · DELETE /plays/:id
13
- * The grid is laid out on the server from the words and the seed (the builder previews with
14
- * the same function, public/lib/layout.js); a save answers `unplaced` for words that did not fit.
15
- *
16
- * Players (no sign-in), under /public - the answers never leave the server:
17
- * GET /public/crossword/:slug title, intro, the grid's size (the clues come with a play)
18
- * GET /public/crossword/:slug/leaderboard ?period=&limit=
19
- * POST /public/crossword/:slug/start {name} -> {playId, token}
20
- * GET /public/play/:id the clock (elapsed, penalties, time left) and the grid's shape, numbers and clues
21
- * POST /public/play/:id/check {cells} -> the squares that are wrong (costs the check penalty)
22
- * POST /public/play/:id/submit {cells} -> solved (finished, timed) or not yet (wrong ones cost a penalty)
23
- * GET /public/play/:id/result time, place; the solution
24
- * `cells` is the grid as rows of letters, a space for an empty square. The token goes in `X-Play-Token`.
25
- *
26
- * Shortcodes: [crossword id="<short name>" /] and [crossword-leaderboard id="<short name>" period="week" /].
27
- */
28
-
29
- import {cleanPlayerName} from '../_lib/games/names.js';
30
- import {boardQuery, fail, isBot, limited, mountPlayAdmin, testerName, todayBadge, tokenOf} from '../_lib/games/http.js';
31
- import {solutionGrid} from './public/lib/layout.js';
32
- import {catchUp, cleanPuzzle, compare, finish, isOpen, publicPuzzle, readCells, summary, view} from './lib/crossword.js';
33
- import {allPuzzles, ensureCollections, getPuzzle, plays, puzzleBySlug, removePuzzle, savePuzzle, uniqueSlug} from './lib/store.js';
34
-
35
- const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
36
- const slugAttr = (v) => String(v || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 60);
37
- const secs = (ms) => Math.round(ms / 1000);
38
-
39
- function registerShortcodes(hooks) {
40
- if (typeof hooks.registerShortcode !== 'function') return;
41
- hooks.registerShortcode('crossword', (attrStr, _body, ctx) => {
42
- const a = ctx.parseShortcodeAttrs(attrStr || '');
43
- const slug = slugAttr(a.id || a.slug);
44
- if (!slug) return '<p class="gm-muted">[crossword] needs id="the crossword\'s short name".</p>';
45
- return `<div class="gm-app cw-app" data-crossword='${esc(JSON.stringify({slug}))}'><p class="gm-muted">Loading the crossword…</p></div>`;
46
- });
47
- // First: the engine matches [crossword…/] by prefix, so [crossword] would otherwise take this one too.
48
- hooks.registerShortcode('crossword-leaderboard', (attrStr, _body, ctx) => {
49
- const a = ctx.parseShortcodeAttrs(attrStr || '');
50
- const id = slugAttr(a.id || a.slug);
51
- if (!id) return '<p class="gm-muted">[crossword-leaderboard] needs id="the crossword\'s short name".</p>';
52
- const cfg = {id, period: String(a.period || 'all'), limit: Math.max(1, Math.min(100, Number(a.limit) || 10)), title: String(a.title || 'Fastest solvers').slice(0, 80)};
53
- return `<div class="cw-board" data-crossword-board='${esc(JSON.stringify(cfg))}'></div>`;
54
- }, {priority: 5});
55
- }
56
-
57
- function publicInfo(puzzle) {
58
- const state = isOpen(puzzle);
59
- return {
60
- slug: puzzle.slug, title: puzzle.title, intro: puzzle.intro, open: state.open, reason: state.reason, opensAt: state.opensAt || '',
61
- timeLimit: puzzle.timeLimit, checks: puzzle.checks, checkPenalty: puzzle.checkPenalty, wrongPenalty: puzzle.wrongPenalty,
62
- leaderboard: puzzle.leaderboard, leaderboardSize: puzzle.leaderboardSize, attemptsPerName: puzzle.attemptsPerName,
63
- // Only the size before a start: the clues come with the play, so nobody studies them off the clock.
64
- width: puzzle.layout.width, height: puzzle.layout.height, answers: puzzle.layout.entries.length
65
- };
66
- }
67
-
68
- export default async function crosswordPlugin(fastify, options) {
69
- const {authenticate, requirePermission} = options.auth;
70
- const hooks = options.hooks || {};
71
- const can = (action) => ({preHandler: [authenticate, requirePermission('crossword', action)]});
72
-
73
- await ensureCollections(fastify.log);
74
- registerShortcodes(hooks);
75
-
76
- if (typeof hooks.registerNotificationSource === 'function') {
77
- try {
78
- hooks.registerNotificationSource({id: 'fastest', label: 'Crossword fastest solves', severity: 'info', defaultOn: true,
79
- description: 'Someone has solved a crossword faster than anyone before.'});
80
- } catch { /* older engine */ }
81
- }
82
-
83
- // ---- Admin ------------------------------------------------------------------
84
-
85
- fastify.get('/count', can('read'), async () => todayBadge(plays, 'crossword'));
86
-
87
- fastify.get('/puzzles', can('read'), async () => {
88
- const everyPlay = await plays.all();
89
- return {puzzles: (await allPuzzles()).map(p => summary(p, everyPlay.filter(x => x.gameId === p.id && x.test !== true)))
90
- .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))};
91
- });
92
-
93
- fastify.get('/puzzles/:id', can('read'), async (request, reply) => {
94
- const puzzle = await getPuzzle(request.params.id);
95
- return puzzle || fail(reply, 404, 'That crossword is not here any more.');
96
- });
97
-
98
- async function save(request, reply, id) {
99
- if (id && !await getPuzzle(id)) return fail(reply, 404, 'That crossword is not here any more.');
100
- const {puzzle, errors, unplaced} = cleanPuzzle(request.body || {});
101
- if (Object.keys(errors).length) return fail(reply, 400, Object.values(errors)[0], {errors});
102
- puzzle.slug = await uniqueSlug(puzzle.slug, id || '');
103
- const saved = await savePuzzle(id, puzzle);
104
- return reply.code(id ? 200 : 201).send({...saved, unplaced});
105
- }
106
-
107
- fastify.post('/puzzles', can('manage'), (request, reply) => save(request, reply, ''));
108
- fastify.put('/puzzles/:id', can('manage'), (request, reply) => save(request, reply, request.params.id));
109
-
110
- fastify.post('/puzzles/:id/copy', can('manage'), async (request, reply) => {
111
- const puzzle = await getPuzzle(request.params.id);
112
- if (!puzzle) return fail(reply, 404, 'That crossword is not here any more.');
113
- const {id, createdAt, updatedAt, ...rest} = puzzle;
114
- const copy = {...rest, title: `${puzzle.title} (copy)`.slice(0, 120), status: 'draft', slug: await uniqueSlug(puzzle.slug)};
115
- return reply.code(201).send(await savePuzzle('', copy));
116
- });
117
-
118
- fastify.delete('/puzzles/:id', can('manage'), async (request, reply) => {
119
- if (!await getPuzzle(request.params.id)) return fail(reply, 404, 'That crossword is not here any more.');
120
- return {ok: true, playsRemoved: await removePuzzle(request.params.id)};
121
- });
122
-
123
- mountPlayAdmin(fastify, {store: plays, base: '/puzzles', can, exists: async (id) => Boolean(await getPuzzle(id))});
124
-
125
- // The admin tries the crossword from the Tool: the saved grid, whatever its status or dates, through
126
- // the same player routes as anyone. The play is marked `test`, so it never counts anywhere.
127
- const testInfo = (puzzle) => ({...publicInfo(puzzle), open: true, reason: ''});
128
-
129
- fastify.get('/puzzles/:id/test', can('manage'), async (request, reply) => {
130
- const puzzle = await getPuzzle(request.params.id);
131
- if (!puzzle) return fail(reply, 404, 'That crossword is not here any more.');
132
- return {info: testInfo(puzzle)};
133
- });
134
-
135
- fastify.post('/puzzles/:id/test', can('manage'), async (request, reply) => {
136
- const puzzle = await getPuzzle(request.params.id);
137
- if (!puzzle) return fail(reply, 404, 'That crossword is not here any more.');
138
- if (!puzzle.layout?.entries?.length) return fail(reply, 409, 'Add answers and clues and save, then test it.', {code: 'empty'});
139
- await plays.clearTests(puzzle.id);
140
- const name = testerName(request);
141
- const play = await plays.start({gameId: puzzle.id, name, maxScore: 1, state: {checks: 0, submits: 0, penaltyMs: 0},
142
- userId: String(request.user?.id || ''), test: true});
143
- return reply.code(201).send({playId: play.id, token: play.token, name, info: testInfo(puzzle)});
144
- });
145
-
146
- // ---- Players ----------------------------------------------------------------
147
-
148
- const shownPuzzle = async (slug) => {
149
- const puzzle = await puzzleBySlug(slug);
150
- return puzzle && puzzle.status !== 'draft' ? puzzle : null;
151
- };
152
-
153
- fastify.get('/public/crossword/:slug', limited(120), async (request, reply) => {
154
- const puzzle = await shownPuzzle(request.params.slug);
155
- if (!puzzle) return fail(reply, 404, 'There is no crossword here.');
156
- return publicInfo(puzzle);
157
- });
158
-
159
- fastify.get('/public/crossword/:slug/leaderboard', limited(120), async (request, reply) => {
160
- const puzzle = await shownPuzzle(request.params.slug);
161
- if (!puzzle || !puzzle.leaderboard) return fail(reply, 404, 'There is no leaderboard here.');
162
- const {period, limit} = boardQuery(request.query, puzzle.leaderboardSize);
163
- return {title: puzzle.title, period, rows: await plays.board(puzzle.id, {period, limit})};
164
- });
165
-
166
- fastify.post('/public/crossword/:slug/start', limited(20, 10), async (request, reply) => {
167
- const puzzle = await shownPuzzle(request.params.slug);
168
- if (!puzzle) return fail(reply, 404, 'There is no crossword here.');
169
- const state = isOpen(puzzle);
170
- if (!state.open) return fail(reply, 409, state.reason, {code: 'closed'});
171
- if (isBot(request.body)) return fail(reply, 400, 'Something went wrong. Please try again.');
172
- const {name, error} = cleanPlayerName(request.body?.name);
173
- if (error) return fail(reply, 400, error, {code: 'name'});
174
- if (puzzle.attemptsPerName && await plays.attemptsBy(puzzle.id, name) >= puzzle.attemptsPerName) {
175
- return fail(reply, 409, puzzle.attemptsPerName === 1 ? `${name} has already had a go at this crossword.` : `${name} has already had ${puzzle.attemptsPerName} goes at this crossword.`, {code: 'attempts'});
176
- }
177
- const play = await plays.start({gameId: puzzle.id, name, maxScore: 1, state: {checks: 0, submits: 0, penaltyMs: 0}});
178
- return reply.code(201).send({playId: play.id, token: play.token, name});
179
- });
180
-
181
- /** The play behind a request, up to date with the clock. Null after answering with the reason. */
182
- /** The play went while this request was on its way (a reset, a delete, or the next test clearing it). */
183
- const gone = (reply) => fail(reply, 404, 'That game is not here any more. Start again.', {code: 'gone'});
184
-
185
- async function livePlay(request, reply) {
186
- const opened = await plays.open(request.params.id, tokenOf(request));
187
- if (!opened) { fail(reply, 404, 'That game is not here any more. Start again.', {code: 'gone'}); return null; }
188
- const puzzle = await getPuzzle(opened.gameId);
189
- if (!puzzle) { fail(reply, 404, 'That crossword is not here any more.', {code: 'gone'}); return null; }
190
- const play = await plays.change(opened.id, (p) => (catchUp(puzzle, p) ? p : null));
191
- if (!play) { gone(reply); return null; }
192
- return {puzzle, play};
193
- }
194
-
195
- async function afterFinish(puzzle, play) {
196
- try {
197
- if (play.test || !puzzle.leaderboard || await plays.placeOf(play, 'all') !== 1) return;
198
- hooks.notify?.({
199
- title: `Fastest solve of "${puzzle.title}"`,
200
- body: `${play.name} solved it in ${secs(play.timeMs)} seconds`,
201
- severity: 'info', link: `#/plugins/crossword/plays/${puzzle.id}`, source: 'fastest', dedupeKey: `crossword-fastest:${play.id}`
202
- });
203
- } catch { /* a notice never breaks a play */ }
204
- }
205
-
206
- fastify.get('/public/play/:id', limited(240), async (request, reply) => {
207
- const live = await livePlay(request, reply);
208
- if (!live) return reply;
209
- return {...view(live.puzzle, live.play), puzzle: publicPuzzle(live.puzzle)};
210
- });
211
-
212
- fastify.post('/public/play/:id/check', limited(60), async (request, reply) => {
213
- const live = await livePlay(request, reply);
214
- if (!live) return reply;
215
- const {puzzle} = live;
216
- if (!puzzle.checks) return fail(reply, 403, 'Checking is switched off for this crossword.', {code: 'no-checks'});
217
- if (live.play.status !== 'playing') return fail(reply, 409, 'This game has finished.', {code: 'finished', play: view(puzzle, live.play)});
218
- const cells = readCells(request.body?.cells, puzzle);
219
- if (!cells) return fail(reply, 400, 'That grid does not fit this crossword.');
220
- const {wrong, empty} = compare(cells, solutionGrid(puzzle.layout));
221
- const play = await plays.change(live.play.id, (p) => {
222
- p.state.checks = (p.state.checks || 0) + 1;
223
- p.state.penaltyMs = (p.state.penaltyMs || 0) + puzzle.checkPenalty * 1000;
224
- return p;
225
- });
226
- if (!play) return gone(reply);
227
- return {wrong, empty, play: view(puzzle, play)};
228
- });
229
-
230
- fastify.post('/public/play/:id/submit', limited(30), async (request, reply) => {
231
- const live = await livePlay(request, reply);
232
- if (!live) return reply;
233
- const {puzzle} = live;
234
- if (live.play.status !== 'playing') return fail(reply, 409, live.play.status === 'timeout' ? 'Time ran out.' : 'This game has finished.', {code: 'finished', play: view(puzzle, live.play)});
235
- const cells = readCells(request.body?.cells, puzzle);
236
- if (!cells) return fail(reply, 400, 'That grid does not fit this crossword.');
237
- const {wrong, empty} = compare(cells, solutionGrid(puzzle.layout));
238
- const solved = !wrong.length && !empty;
239
- const play = await plays.change(live.play.id, (p) => {
240
- if (p.status !== 'playing') return null;
241
- p.state.submits = (p.state.submits || 0) + 1;
242
- // Guessing costs time: a full grid with mistakes adds the penalty. Empty squares are only pointed out.
243
- if (!solved && !empty) p.state.penaltyMs = (p.state.penaltyMs || 0) + puzzle.wrongPenalty * 1000;
244
- return solved ? finish(p) : p;
245
- });
246
- if (!play) return gone(reply);
247
- if (solved) await afterFinish(puzzle, play);
248
- return {
249
- solved, empty, wrongCount: solved || empty ? 0 : wrong.length,
250
- message: solved ? 'Solved!' : empty ? `${empty} square${empty === 1 ? ' is' : 's are'} still empty.`
251
- : `Not quite: something is wrong.${puzzle.wrongPenalty ? ` That cost ${puzzle.wrongPenalty} seconds.` : ''}`,
252
- play: view(puzzle, play)
253
- };
254
- });
255
-
256
- fastify.get('/public/play/:id/result', limited(120), async (request, reply) => {
257
- const live = await livePlay(request, reply);
258
- if (!live) return reply;
259
- const {puzzle, play} = live;
260
- if (play.status === 'playing') return fail(reply, 409, 'This game has not finished yet.', {code: 'playing'});
261
- const solved = play.status === 'finished';
262
- return {
263
- name: play.name, solved, timeMs: play.timeMs, penaltyMs: play.state.penaltyMs || 0, checks: play.state.checks || 0,
264
- test: play.test === true,
265
- place: solved && puzzle.leaderboard && !play.hidden && !play.test ? await plays.placeOf(play, 'all') : 0,
266
- placeToday: solved && puzzle.leaderboard && !play.hidden && !play.test ? await plays.placeOf(play, 'today') : 0,
267
- // A test is on no board; this is where it would have come.
268
- wouldPlace: solved && puzzle.leaderboard && play.test ? await plays.placeOf(play, 'all') : 0,
269
- leaderboard: puzzle.leaderboard,
270
- // The answers only to someone who solved it: a timed-out play under another name must not be a way to read them.
271
- solution: solved ? solutionGrid(puzzle.layout).map(row => row.map(ch => ch || ' ').join('')) : null
272
- };
273
- });
274
- }
@@ -1,79 +0,0 @@
1
- {
2
- "name": "crossword",
3
- "displayName": "Crossword",
4
- "version": "1.1.0",
5
- "tier": "free",
6
- "minCmsVersion": "1.8.33",
7
- "description": "Type answers and clues in the admin and the grid lays itself out. Put the crossword on any page: players type a name, solve against the clock, and the fastest go top of the leaderboard. The answers stay on the server until someone has solved it.",
8
- "author": "Domma CMS",
9
- "date": "2026-10-21",
10
- "icon": "grid",
11
- "permissions": [
12
- {
13
- "key": "crossword",
14
- "label": "Crossword",
15
- "group": "Plugins",
16
- "icon": "grid",
17
- "description": "See crosswords and who solved them (read), or make, change and remove crosswords and plays (manage).",
18
- "actions": [
19
- {
20
- "key": "read",
21
- "label": "See",
22
- "description": "Open Crossword and see the crosswords, plays and leaderboards"
23
- },
24
- {
25
- "key": "manage",
26
- "label": "Manage",
27
- "description": "Make, change and delete crosswords; hide or remove plays; reset a leaderboard"
28
- }
29
- ],
30
- "grant": [
31
- "admin"
32
- ]
33
- }
34
- ],
35
- "inject": {
36
- "bodyEnd": "inject/body.html"
37
- },
38
- "admin": {
39
- "css": [
40
- "../_lib/admin/games/games.css",
41
- "admin/css/index.css"
42
- ],
43
- "sidebar": [
44
- {
45
- "id": "crossword",
46
- "text": "Crossword",
47
- "icon": "grid",
48
- "url": "#/plugins/crossword",
49
- "section": "#/plugins/crossword",
50
- "permission": "crossword",
51
- "group": "Games",
52
- "countUrl": "/api/plugins/crossword/count"
53
- }
54
- ],
55
- "routes": [
56
- {
57
- "path": "/plugins/crossword",
58
- "view": "plugin-crossword",
59
- "title": "Crossword - Domma CMS"
60
- },
61
- {
62
- "path": "/plugins/crossword/:a",
63
- "view": "plugin-crossword",
64
- "title": "Crossword - Domma CMS"
65
- },
66
- {
67
- "path": "/plugins/crossword/:a/:b",
68
- "view": "plugin-crossword",
69
- "title": "Crossword - Domma CMS"
70
- }
71
- ],
72
- "views": {
73
- "plugin-crossword": {
74
- "entry": "crossword/admin/views/crossword.js?v=1.1.0",
75
- "exportName": "crosswordView"
76
- }
77
- }
78
- }
79
- }
@@ -1,36 +0,0 @@
1
- /* Crossword - the player. Shared pieces (cards, clocks, leaderboard) are in /plugins/_lib/public/games/games.css. */
2
- .cw-app { max-width: 1040px; }
3
- .cw-live { position: absolute; left: -9999px; }
4
- .cw-clocks { display: inline-flex; align-items: center; gap: .5rem; }
5
- .cw-current { margin: 0 0 .75rem; padding: .5rem .75rem; border-radius: 8px; font-weight: 600;
6
- background: color-mix(in srgb, var(--gm-accent) 14%, transparent); }
7
-
8
- .cw-layout { display: grid; grid-template-columns: auto minmax(14rem, 1fr); gap: 1.25rem; align-items: start; }
9
- @media (max-width: 760px) { .cw-layout { grid-template-columns: 1fr; } }
10
- .cw-grid-wrap { overflow-x: auto; max-width: 100%; }
11
- /* A crossword is black and white whatever the theme: light squares, dark blocks, the theme's accent for the cursor. */
12
- .cw-grid { --cw-size: clamp(1.6rem, calc((100vw - 4rem) / var(--cw-cols, 15)), 2.4rem); --cw-ink: #111827; --cw-paper: #ffffff;
13
- display: inline-flex; flex-direction: column; border: 2px solid var(--cw-ink); background: var(--cw-ink); }
14
- .cw-row { display: flex; }
15
- .cw-cell { position: relative; width: var(--cw-size); height: var(--cw-size); box-sizing: border-box;
16
- border: 1px solid #9ca3af; background: var(--cw-paper); }
17
- .cw-cell.is-block { background: var(--cw-ink); border-color: var(--cw-ink); }
18
- .cw-cell.is-word { background: color-mix(in srgb, var(--gm-accent) 22%, var(--cw-paper)); }
19
- .cw-cell.is-cursor { background: color-mix(in srgb, var(--gm-accent) 55%, var(--cw-paper)); }
20
- .cw-cell.is-wrong .cw-input { color: #dc2626; text-decoration: line-through; }
21
- .cw-num { position: absolute; top: 1px; left: 2px; font-size: calc(var(--cw-size) * .28); line-height: 1; pointer-events: none; color: #4b5563; }
22
- .cw-input { width: 100%; height: 100%; border: 0; padding: 0; margin: 0; background: transparent; text-align: center; text-transform: uppercase;
23
- font: 700 calc(var(--cw-size) * .55)/1 var(--dm-font-sans, inherit); color: var(--cw-ink); caret-color: transparent; cursor: pointer; }
24
- .cw-input:focus { outline: none; }
25
- .cw-input[readonly] { cursor: default; }
26
-
27
- .cw-clues { display: grid; grid-template-columns: 1fr; gap: 1rem; max-height: 34rem; overflow: auto; }
28
- @media (min-width: 1000px) { .cw-clues { grid-template-columns: 1fr 1fr; } }
29
- .cw-clues h3 { margin: 0 0 .35rem; font-size: 1rem; text-transform: uppercase; letter-spacing: .05em; }
30
- .cw-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .1rem; }
31
- .cw-clue { display: block; width: 100%; text-align: left; font: inherit; color: inherit; background: none; border: 0; padding: .3rem .45rem; border-radius: 6px; cursor: pointer; }
32
- .cw-clue:hover { background: var(--gm-soft); }
33
- .cw-list li.is-on .cw-clue { background: color-mix(in srgb, var(--gm-accent) 20%, transparent); }
34
- .cw-message.is-bad { color: var(--gm-bad); font-weight: 600; }
35
- .cw-message.is-good { color: var(--gm-good); font-weight: 600; }
36
- .cw-result { margin-bottom: 1rem; }