domma-cms 0.77.2 → 0.78.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.77.2",
3
+ "version": "0.78.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Turn the CHANGELOG's `## [Unreleased]` heading into `## [X.Y.Z] - YYYY-MM-DD`.
3
+ *
4
+ * The release notes are the one part of a release a workflow cannot write, so
5
+ * they are written as the work lands, under `[Unreleased]`, and this is what
6
+ * the "Cut a release" workflow does with them. Refusing to run without them is
7
+ * the point: a release with no entry is a release nobody can read about.
8
+ *
9
+ * With `--print X.Y.Z` it instead prints an already-released section.
10
+ *
11
+ * The same script as domma-reactive/scripts/promote-changelog.mjs; both
12
+ * CHANGELOGs follow Keep a Changelog. Keep the two in step.
13
+ *
14
+ * node scripts/promote-changelog.mjs 1.2.0 2026-10-01
15
+ * node scripts/promote-changelog.mjs --print 1.2.0
16
+ */
17
+
18
+ import {readFileSync, writeFileSync} from 'node:fs';
19
+ import {fileURLToPath} from 'node:url';
20
+ import path from 'node:path';
21
+
22
+ const FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'CHANGELOG.md');
23
+
24
+ const fail = (message) => {
25
+ console.error(`\n promote-changelog: ${message}\n`);
26
+ process.exit(1);
27
+ };
28
+
29
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
30
+
31
+ /** The body under a `## [label]` heading, up to the next `## [` heading. */
32
+ function section(source, label) {
33
+ const heading = new RegExp(`^## \\[${escape(label)}\\][^\\n]*\\n`, 'm');
34
+ const match = heading.exec(source);
35
+ if (!match) return null;
36
+
37
+ const start = match.index + match[0].length;
38
+ const next = source.slice(start).search(/^## \[/m);
39
+ const end = next === -1 ? source.length : start + next;
40
+ return {heading: match, start, end, body: source.slice(start, end).trim()};
41
+ }
42
+
43
+ const source = readFileSync(FILE, 'utf8');
44
+
45
+ if (process.argv[2] === '--print') {
46
+ const version = process.argv[3];
47
+ const found = version && section(source, version);
48
+ if (!found) fail(`no '## [${version}]' section in CHANGELOG.md`);
49
+ console.log(found.body);
50
+ process.exit(0);
51
+ }
52
+
53
+ const [version, date] = process.argv.slice(2);
54
+ if (!/^\d+\.\d+\.\d+$/.test(version || '')) fail('usage: promote-changelog.mjs X.Y.Z YYYY-MM-DD');
55
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date || '')) fail(`"${date}" is not a YYYY-MM-DD date`);
56
+
57
+ if (section(source, version)) fail(`CHANGELOG.md already has a '## [${version}]' section`);
58
+
59
+ const pending = section(source, 'Unreleased');
60
+ if (!pending) {
61
+ fail("no '## [Unreleased]' section - write the release notes there first, then cut the release");
62
+ }
63
+ if (pending.body === '') {
64
+ fail("'## [Unreleased]' is empty - write the release notes there first, then cut the release");
65
+ }
66
+
67
+ writeFileSync(
68
+ FILE,
69
+ source.slice(0, pending.heading.index) + `## [${version}] - ${date}\n` + source.slice(pending.start)
70
+ );
71
+ console.log(`\n CHANGELOG.md: [Unreleased] → [${version}] - ${date}\n`);
@@ -12,6 +12,7 @@ import {v4 as uuidv4} from 'uuid';
12
12
  import {config} from '../config.js';
13
13
  import {getAdapter, invalidate} from './adapterRegistry.js';
14
14
  import * as cache from './cache/index.js';
15
+ import {typedEntryData} from './entryValues.js';
15
16
  import {toCsv} from '../../public/js/collection-export.mjs';
16
17
 
17
18
  const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
@@ -388,6 +389,7 @@ export async function getEntry(slug, entryId) {
388
389
  export async function createEntry(slug, data, { createdBy = null, source = 'admin' } = {}) {
389
390
  const schema = await getCollection(slug);
390
391
  if (!schema) throw new Error(`Collection "${slug}" not found`);
392
+ data = typedEntryData(schema, data);
391
393
 
392
394
  const { valid, errors } = validateEntryData(schema, data);
393
395
  if (!valid) throw new Error(`Validation failed: ${errors.join('; ')}`);
@@ -420,6 +422,7 @@ export async function createEntry(slug, data, { createdBy = null, source = 'admi
420
422
  export async function updateEntry(slug, entryId, data) {
421
423
  const schema = await getCollection(slug);
422
424
  if (!schema) throw new Error(`Collection "${slug}" not found`);
425
+ data = typedEntryData(schema, data);
423
426
 
424
427
  const { valid, errors } = validateEntryData(schema, data);
425
428
  if (!valid) throw new Error(`Validation failed: ${errors.join('; ')}`);
@@ -526,7 +529,7 @@ export async function importEntries(slug, incoming, { createdBy = null } = {}) {
526
529
  const valid = [];
527
530
 
528
531
  for (const item of incoming) {
529
- const data = item.data || item;
532
+ const data = typedEntryData(schema, item.data || item);
530
533
  const { valid: ok, errors: valErrors } = validateEntryData(schema, data);
531
534
  if (!ok) {
532
535
  skipped++;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Entry values as their field types store them.
3
+ *
4
+ * Everything that writes entries through the collections service - the admin,
5
+ * the external /api/v1 API, form submissions, import, Actions - arrives here
6
+ * first, so a `number` field sent as text ("42", as HTML forms and CSV-minded
7
+ * clients send it) is stored as a number and sorts, filters and totals as one.
8
+ *
9
+ * Deliberately a conversion, not a check: text that is not a plain decimal
10
+ * number ("", "abc", "0x10", "Infinity") is stored as sent, exactly as before,
11
+ * so no existing client starts getting errors. Only top-level `number` fields.
12
+ */
13
+
14
+ const DECIMAL = /^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
15
+
16
+ /** "42" -> 42; anything that is not plain decimal text is returned unchanged. */
17
+ export function numberFromText(value) {
18
+ if (typeof value !== 'string') return value;
19
+ const t = value.trim();
20
+ if (!DECIMAL.test(t)) return value;
21
+ const n = Number(t);
22
+ return Number.isFinite(n) ? n : value;
23
+ }
24
+
25
+ /** The schema's number field names. */
26
+ export function numberFieldNames(schema) {
27
+ return (schema?.fields || []).filter(f => f?.type === 'number' && f.name).map(f => f.name);
28
+ }
29
+
30
+ /**
31
+ * `data` with its number fields converted - a new object when anything changed,
32
+ * the same object when nothing did (the caller's object is never modified).
33
+ *
34
+ * @param {object} data
35
+ * @param {string[]} names - from numberFieldNames()
36
+ */
37
+ export function withNumbers(data, names) {
38
+ if (!data || typeof data !== 'object' || Array.isArray(data) || !names?.length) return data;
39
+ let out = data;
40
+ for (const name of names) {
41
+ if (!Object.prototype.hasOwnProperty.call(data, name)) continue;
42
+ const next = numberFromText(data[name]);
43
+ if (next !== data[name]) {
44
+ if (out === data) out = {...data};
45
+ out[name] = next;
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ /** An entry's data typed for its collection's schema. */
52
+ export function typedEntryData(schema, data) {
53
+ return withNumbers(data, numberFieldNames(schema));
54
+ }
@@ -13,25 +13,18 @@
13
13
  * an edit.
14
14
  *
15
15
  * Runs ONCE per install: the marker (content/.migrations.json) is written only
16
- * when every collection was done. It is deliberately not a standing coercion -
17
- * what an API client writes after this is the client's business.
16
+ * when every collection was done. From 0.78.0 the collections service converts
17
+ * on every write (entryValues.js), so nothing new of this kind arrives - this
18
+ * only mends what was stored before.
18
19
  */
19
20
  import fs from 'fs/promises';
20
21
  import path from 'path';
21
22
  import {config} from '../config.js';
23
+ import {numberFieldNames, numberFromText, withNumbers} from './entryValues.js';
22
24
 
23
25
  export const MIGRATION_ID = 'number-field-types';
24
26
 
25
- const DECIMAL = /^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
26
-
27
- /** "42" -> 42; anything that is not plain decimal text is returned unchanged. */
28
- export function numberFromText(value) {
29
- if (typeof value !== 'string') return value;
30
- const t = value.trim();
31
- if (!DECIMAL.test(t)) return value;
32
- const n = Number(t);
33
- return Number.isFinite(n) ? n : value;
34
- }
27
+ export {numberFromText};
35
28
 
36
29
  /**
37
30
  * An entry with its number fields converted, or null when nothing changed.
@@ -42,13 +35,8 @@ export function numberFromText(value) {
42
35
  export function convertEntry(entry, names) {
43
36
  const data = entry?.data;
44
37
  if (!data || typeof data !== 'object') return null;
45
- let changed = null;
46
- for (const name of names) {
47
- if (!(name in data)) continue;
48
- const next = numberFromText(data[name]);
49
- if (next !== data[name]) (changed ||= {...data})[name] = next;
50
- }
51
- return changed ? {...entry, data: changed} : null;
38
+ const next = withNumbers(data, names);
39
+ return next === data ? null : {...entry, data: next};
52
40
  }
53
41
 
54
42
  function defaultMarkerPath() {
@@ -79,7 +67,7 @@ export async function migrateNumberFields({markerPath = defaultMarkerPath(), for
79
67
  const converted = {};
80
68
  const pending = [];
81
69
  for (const schema of await listCollections()) {
82
- const names = (schema.fields || []).filter(f => f?.type === 'number' && f.name).map(f => f.name);
70
+ const names = numberFieldNames(schema);
83
71
  if (!names.length) continue;
84
72
  try {
85
73
  const adapter = await getAdapter(schema.slug);