@terminalfour/terminalfour-js 1.1.0 → 1.1.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 (35) hide show
  1. package/dist/cjs/handlebars.js +1 -0
  2. package/dist/cjs/models/content-item.js +1 -0
  3. package/dist/cjs/models/media-category-item.js +2 -0
  4. package/dist/cjs/models/media-item.js +1 -0
  5. package/dist/cjs/models/section-item.js +1 -0
  6. package/dist/cjs/resources/content-resource.js +2 -0
  7. package/dist/cjs/resources/content-type-resource.js +3 -0
  8. package/dist/cjs/resources/group-resource.js +1 -0
  9. package/dist/cjs/resources/list-resource.js +1 -0
  10. package/dist/cjs/resources/media-resource.js +9 -2
  11. package/dist/cjs/resources/media-type-resource.js +1 -0
  12. package/dist/cjs/resources/navigation-resource.js +1 -0
  13. package/dist/cjs/resources/page-layout-resource.js +1 -0
  14. package/dist/cjs/section-ref.js +5 -0
  15. package/dist/cjs/utils.d.ts +17 -0
  16. package/dist/cjs/utils.js +29 -0
  17. package/dist/esm/handlebars.js +2 -1
  18. package/dist/esm/models/content-item.js +2 -1
  19. package/dist/esm/models/media-category-item.js +2 -0
  20. package/dist/esm/models/media-item.js +2 -1
  21. package/dist/esm/models/section-item.js +2 -1
  22. package/dist/esm/resources/content-resource.js +3 -1
  23. package/dist/esm/resources/content-type-resource.js +4 -1
  24. package/dist/esm/resources/group-resource.js +2 -1
  25. package/dist/esm/resources/list-resource.js +2 -1
  26. package/dist/esm/resources/media-resource.js +10 -3
  27. package/dist/esm/resources/media-type-resource.js +2 -1
  28. package/dist/esm/resources/navigation-resource.js +2 -1
  29. package/dist/esm/resources/page-layout-resource.js +2 -1
  30. package/dist/esm/section-ref.js +6 -1
  31. package/dist/esm/utils.d.ts +17 -0
  32. package/dist/esm/utils.js +27 -0
  33. package/docs/content-types.md +1 -1
  34. package/docs/error-handling.md +26 -0
  35. package/package.json +1 -1
@@ -25,6 +25,7 @@ class HandlebarsItem {
25
25
  * Always saves with approved status.
26
26
  */
27
27
  async save() {
28
+ (0, utils_js_1.assertRequired)(this.name, 'Name');
28
29
  // Update elements with current name and code
29
30
  const elements = { ...this._rawDTO.elements };
30
31
  for (const key of Object.keys(elements)) {
@@ -502,6 +502,7 @@ class ContentItem {
502
502
  * save and approve in one step.
503
503
  */
504
504
  async save() {
505
+ (0, utils_js_1.assertRequired)(this.name, 'Content name');
505
506
  // Start from the original raw elements (correct API format)
506
507
  const rawElements = { ...this._rawDTO.elements };
507
508
  // Re-resolve dirty fields through the ElementResolver so friendly values
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MediaCategoryItem = void 0;
4
+ const utils_js_1 = require("../utils.js");
4
5
  /**
5
6
  * A mutable media category object. Modify properties and call save() to persist.
6
7
  */
@@ -17,6 +18,7 @@ class MediaCategoryItem {
17
18
  }
18
19
  /** Persists current property values to the server via PUT. */
19
20
  async save() {
21
+ (0, utils_js_1.assertRequired)(this.name, 'Media category name');
20
22
  const updated = {
21
23
  ...this._rawData,
22
24
  name: this.name,
@@ -80,6 +80,7 @@ class MediaItem {
80
80
  * If `content` has changed on non-binary media, the version is bumped.
81
81
  */
82
82
  async save() {
83
+ (0, utils_js_1.assertRequired)(this.name, 'Media name');
83
84
  const categoryId = this.categories[0];
84
85
  if (!categoryId) {
85
86
  throw new Error('Cannot save media item — no category assigned.');
@@ -34,6 +34,7 @@ class SectionItem {
34
34
  }
35
35
  /** Persists current property values to the server via PUT. */
36
36
  async save() {
37
+ (0, utils_js_1.assertRequired)(this.name, 'Section name');
37
38
  const statusCode = utils_js_1.STATUS_CODES[this.status] ?? Number(this._rawData.status) ?? 0;
38
39
  const updated = {
39
40
  ...this._rawData,
@@ -100,6 +100,7 @@ class ContentResource {
100
100
  }
101
101
  /** Creates a new content item in this section. */
102
102
  async create(data, options) {
103
+ (0, utils_js_1.assertRequired)(data.name, 'Content name');
103
104
  const language = (0, utils_js_1.resolveLanguage)(options?.language, this.defaultLanguage);
104
105
  const template = await this.getTemplate(data.type);
105
106
  const contentId = -Math.floor(Math.random() * 1000000);
@@ -135,6 +136,7 @@ class ContentResource {
135
136
  }
136
137
  /** Updates an existing content item's fields. */
137
138
  async update(id, data, options) {
139
+ (0, utils_js_1.assertNotEmptyIfPresent)(data.name, 'Content name');
138
140
  const language = (0, utils_js_1.resolveLanguage)(options?.language, this.defaultLanguage);
139
141
  // 1. Fetch the existing content (full body needed for the POST)
140
142
  const existing = await this.httpClient.request({
@@ -249,6 +249,7 @@ class ContentType {
249
249
  return new Layout(raw, httpClient, contentTypeId, layoutElements, fetchLayouts, resolveSyntaxId, resolveProcessorId, resolveExtension);
250
250
  },
251
251
  update: async (layoutName, updateData) => {
252
+ (0, utils_js_1.assertNotEmptyIfPresent)(updateData.name, 'Layout name');
252
253
  const all = await fetchLayouts();
253
254
  const match = all.find((l) => l.name === layoutName);
254
255
  if (!match)
@@ -289,6 +290,7 @@ class ContentType {
289
290
  return new Layout(response, httpClient, contentTypeId, layoutElements, fetchLayouts, resolveSyntaxId, resolveProcessorId, resolveExtension);
290
291
  },
291
292
  create: async (createData) => {
293
+ (0, utils_js_1.assertRequired)(createData.name, 'Layout name');
292
294
  // Check name uniqueness
293
295
  const existing = await fetchLayouts();
294
296
  if (existing.some((l) => l.name === createData.name)) {
@@ -590,6 +592,7 @@ class ContentType {
590
592
  }
591
593
  /** Persists current property values to the server via PUT. */
592
594
  async save() {
595
+ (0, utils_js_1.assertRequired)(this.name, 'Content type name');
593
596
  (0, utils_js_1.assertGroupsValid)(this.primaryGroup, this.sharedGroups);
594
597
  const authLevel = String(utils_js_1.AUTH_LEVEL_REVERSE[this.minUserLevel] ?? this._rawData.minAuthLevel ?? 2);
595
598
  // Sync field changes back to raw contentTypeElements
@@ -43,6 +43,7 @@ class Group {
43
43
  }
44
44
  /** Persists current property values to the server via PUT. */
45
45
  async save() {
46
+ (0, utils_js_1.assertRequired)(this.name, 'Group name');
46
47
  // Start from existing raw members
47
48
  let rawMembers = [...(this._rawData.members ?? [])];
48
49
  // Remove members
@@ -56,6 +56,7 @@ class List {
56
56
  }
57
57
  /** Persists current property values to the server via PUT. */
58
58
  async save() {
59
+ (0, utils_js_2.assertRequired)(this.name, 'List name');
59
60
  if (this.isForcedLanguage && this.isDefaultLanguage) {
60
61
  throw new Error('isForcedLanguage and isDefaultLanguage cannot both be true');
61
62
  }
@@ -53,10 +53,17 @@ class MediaResource {
53
53
  * Returns the new media item.
54
54
  */
55
55
  async create(data) {
56
- if (!data.name?.trim())
57
- throw new Error('Media name is required');
56
+ (0, utils_js_1.assertRequired)(data.name, 'Media name');
58
57
  if (!data.category)
59
58
  throw new Error('Media category is required');
59
+ // A media item is meaningless without its file/binary. Guard the common
60
+ // empty cases: missing, or an empty { file } wrapper.
61
+ const fileValue = data.file && typeof data.file === 'object' && !(data.file instanceof Blob) && 'file' in data.file
62
+ ? data.file.file
63
+ : data.file;
64
+ if (fileValue === undefined || fileValue === null || fileValue === '') {
65
+ throw new Error('Media file is required');
66
+ }
60
67
  const language = data.language ?? 'smxx';
61
68
  const filename = (0, utils_js_1.deriveFilename)(data.file);
62
69
  const ext = getExtension(filename);
@@ -68,6 +68,7 @@ class MediaType {
68
68
  }
69
69
  /** Persists current property values to the server via PUT. */
70
70
  async save() {
71
+ (0, utils_js_1.assertRequired)(this.name, 'Media type name');
71
72
  // Sync defaultLayout into the layouts array
72
73
  if (this.defaultLayout) {
73
74
  for (const layout of this.layouts) {
@@ -1006,6 +1006,7 @@ class NavigationObject {
1006
1006
  }
1007
1007
  /** Persists current property values to the server via PUT. */
1008
1008
  async save() {
1009
+ (0, utils_js_1.assertRequired)(this.name, 'Navigation object name');
1009
1010
  (0, utils_js_1.assertGroupsValid)(this.primaryGroup, this.sharedGroups);
1010
1011
  // Apply type-aware write transformation (coerce back to strings, derive hidden fields)
1011
1012
  const camelStringProps = transformPropertiesWrite(this.type, this.properties);
@@ -31,6 +31,7 @@ class PageLayout {
31
31
  }
32
32
  /** Persists current property values to the server via PUT. */
33
33
  async save() {
34
+ (0, utils_js_1.assertRequired)(this.name, 'Page layout name');
34
35
  (0, utils_js_1.assertGroupsValid)(this.primaryGroup, this.sharedGroups);
35
36
  // Resolve syntax name to ID
36
37
  let syntaxId = this._rawData.syntaxType;
@@ -568,6 +568,7 @@ class SectionRef {
568
568
  * If `data.customFields` is provided, creates and saves section metadata content.
569
569
  */
570
570
  async addSection(data, options) {
571
+ (0, utils_js_1.assertRequired)(data.name, 'Section name');
571
572
  const language = (0, utils_js_1.resolveLanguage)(options?.language, this.defaultLanguage);
572
573
  // Fetch this section's details to inherit config
573
574
  const parentSection = await this.httpClient.request({
@@ -712,6 +713,10 @@ class SectionRef {
712
713
  * Returns the updated SectionItem.
713
714
  */
714
715
  async update(data, options) {
716
+ // This path PUTs directly (it does not go through SectionItem.save()), so
717
+ // guard the name here. Present-only: omitting name is valid (e.g. delete()
718
+ // calls update({ status: 'inactive' })), but setting it blank is not.
719
+ (0, utils_js_1.assertNotEmptyIfPresent)(data.name, 'Section name');
715
720
  const language = (0, utils_js_1.resolveLanguage)(options?.language, this.defaultLanguage);
716
721
  const section = await this.httpClient.request({
717
722
  method: 'GET',
@@ -107,6 +107,23 @@ export declare function writeSharedGroups(ids?: number[]): Array<{
107
107
  * group" and is never a conflict.
108
108
  */
109
109
  export declare function assertGroupsValid(primaryGroup: number, sharedGroups?: number[]): void;
110
+ /**
111
+ * Asserts that a required string value is present and not blank.
112
+ *
113
+ * Use on create paths where the field must always be provided. Throws
114
+ * `"<Label> is required"` when the value is missing, empty, or whitespace-only.
115
+ * Prevents opaque API 500s from malformed requests missing a required field.
116
+ */
117
+ export declare function assertRequired(value: string | null | undefined, label: string): void;
118
+ /**
119
+ * Asserts that a value, *if provided*, is not blank.
120
+ *
121
+ * Use on update paths where a field is optional (omitting it leaves the
122
+ * current value unchanged) but explicitly setting it to an empty or
123
+ * whitespace-only string is invalid. `undefined` passes (means "no change");
124
+ * `''` or whitespace throws `"<Label> cannot be empty"`.
125
+ */
126
+ export declare function assertNotEmptyIfPresent(value: string | null | undefined, label: string): void;
110
127
  /** Accepted file input: a file path, URL, Blob, ReadableStream, or { file, filename } object */
111
128
  export type FileInput = string | Blob | NodeJS.ReadableStream | {
112
129
  file: string | Blob | NodeJS.ReadableStream;
package/dist/cjs/utils.js CHANGED
@@ -47,6 +47,8 @@ exports.readSharedGroups = readSharedGroups;
47
47
  exports.writePrimaryGroup = writePrimaryGroup;
48
48
  exports.writeSharedGroups = writeSharedGroups;
49
49
  exports.assertGroupsValid = assertGroupsValid;
50
+ exports.assertRequired = assertRequired;
51
+ exports.assertNotEmptyIfPresent = assertNotEmptyIfPresent;
50
52
  exports.resolveFileToBlob = resolveFileToBlob;
51
53
  exports.deriveFilename = deriveFilename;
52
54
  exports.debugWarn = debugWarn;
@@ -258,6 +260,33 @@ function assertGroupsValid(primaryGroup, sharedGroups) {
258
260
  'Remove it from sharedGroups or choose a different primaryGroup.');
259
261
  }
260
262
  }
263
+ /**
264
+ * Asserts that a required string value is present and not blank.
265
+ *
266
+ * Use on create paths where the field must always be provided. Throws
267
+ * `"<Label> is required"` when the value is missing, empty, or whitespace-only.
268
+ * Prevents opaque API 500s from malformed requests missing a required field.
269
+ */
270
+ function assertRequired(value, label) {
271
+ if (!value || !value.trim()) {
272
+ throw new Error(`${label} is required`);
273
+ }
274
+ }
275
+ /**
276
+ * Asserts that a value, *if provided*, is not blank.
277
+ *
278
+ * Use on update paths where a field is optional (omitting it leaves the
279
+ * current value unchanged) but explicitly setting it to an empty or
280
+ * whitespace-only string is invalid. `undefined` passes (means "no change");
281
+ * `''` or whitespace throws `"<Label> cannot be empty"`.
282
+ */
283
+ function assertNotEmptyIfPresent(value, label) {
284
+ if (value === undefined || value === null)
285
+ return;
286
+ if (!value.trim()) {
287
+ throw new Error(`${label} cannot be empty`);
288
+ }
289
+ }
261
290
  /**
262
291
  * Resolves a file input (path, URL, Blob, or ReadableStream) to a Blob.
263
292
  */
@@ -1,4 +1,4 @@
1
- import { DEFAULT_CACHE_TTL, getCacheEpoch } from './utils.js';
1
+ import { DEFAULT_CACHE_TTL, getCacheEpoch, assertRequired } from './utils.js';
2
2
  /**
3
3
  * A mutable Handlebars item (helper or partial).
4
4
  * Modify `name` and/or `code`, then call `save()` to persist.
@@ -22,6 +22,7 @@ export class HandlebarsItem {
22
22
  * Always saves with approved status.
23
23
  */
24
24
  async save() {
25
+ assertRequired(this.name, 'Name');
25
26
  // Update elements with current name and code
26
27
  const elements = { ...this._rawDTO.elements };
27
28
  for (const key of Object.keys(elements)) {
@@ -1,4 +1,4 @@
1
- import { formatFileSize, parseElementKey, mapStatus, flattenGroups, STATUS_CODES, AUTH_LEVEL_MAP, debugWarn, DEFAULT_CACHE_TTL, getCacheEpoch } from '../utils.js';
1
+ import { formatFileSize, parseElementKey, mapStatus, flattenGroups, STATUS_CODES, AUTH_LEVEL_MAP, debugWarn, DEFAULT_CACHE_TTL, getCacheEpoch, assertRequired } from '../utils.js';
2
2
  /** Symbol used to restrict _init() access to the factory function in this module */
3
3
  const INIT = Symbol('ContentItem.init');
4
4
  /**
@@ -497,6 +497,7 @@ export class ContentItem {
497
497
  * save and approve in one step.
498
498
  */
499
499
  async save() {
500
+ assertRequired(this.name, 'Content name');
500
501
  // Start from the original raw elements (correct API format)
501
502
  const rawElements = { ...this._rawDTO.elements };
502
503
  // Re-resolve dirty fields through the ElementResolver so friendly values
@@ -1,3 +1,4 @@
1
+ import { assertRequired } from '../utils.js';
1
2
  /**
2
3
  * A mutable media category object. Modify properties and call save() to persist.
3
4
  */
@@ -14,6 +15,7 @@ export class MediaCategoryItem {
14
15
  }
15
16
  /** Persists current property values to the server via PUT. */
16
17
  async save() {
18
+ assertRequired(this.name, 'Media category name');
17
19
  const updated = {
18
20
  ...this._rawData,
19
21
  name: this.name,
@@ -1,4 +1,4 @@
1
- import { formatFileSize, parseElementKey, STATUS_MAP, resolveFileToBlob, deriveFilename } from '../utils.js';
1
+ import { formatFileSize, parseElementKey, STATUS_MAP, resolveFileToBlob, deriveFilename, assertRequired } from '../utils.js';
2
2
  /** Extension → syntax type mapping for non-binary media */
3
3
  const EXTENSION_SYNTAX_MAP = {
4
4
  js: 1,
@@ -77,6 +77,7 @@ export class MediaItem {
77
77
  * If `content` has changed on non-binary media, the version is bumped.
78
78
  */
79
79
  async save() {
80
+ assertRequired(this.name, 'Media name');
80
81
  const categoryId = this.categories[0];
81
82
  if (!categoryId) {
82
83
  throw new Error('Cannot save media item — no category assigned.');
@@ -1,4 +1,4 @@
1
- import { mapStatus, STATUS_CODES } from '../utils.js';
1
+ import { mapStatus, STATUS_CODES, assertRequired } from '../utils.js';
2
2
  import { ContentResource } from '../resources/content-resource.js';
3
3
  /**
4
4
  * A mutable section object. Modify properties and call save() to persist.
@@ -31,6 +31,7 @@ export class SectionItem {
31
31
  }
32
32
  /** Persists current property values to the server via PUT. */
33
33
  async save() {
34
+ assertRequired(this.name, 'Section name');
34
35
  const statusCode = STATUS_CODES[this.status] ?? Number(this._rawData.status) ?? 0;
35
36
  const updated = {
36
37
  ...this._rawData,
@@ -1,4 +1,4 @@
1
- import { resolveLanguage, toTimestamp, STATUS_CODES } from '../utils.js';
1
+ import { resolveLanguage, toTimestamp, STATUS_CODES, assertRequired, assertNotEmptyIfPresent } from '../utils.js';
2
2
  import { createContentItem } from '../models/content-item.js';
3
3
  import { ContentCache } from '../content-cache.js';
4
4
  /**
@@ -97,6 +97,7 @@ export class ContentResource {
97
97
  }
98
98
  /** Creates a new content item in this section. */
99
99
  async create(data, options) {
100
+ assertRequired(data.name, 'Content name');
100
101
  const language = resolveLanguage(options?.language, this.defaultLanguage);
101
102
  const template = await this.getTemplate(data.type);
102
103
  const contentId = -Math.floor(Math.random() * 1000000);
@@ -132,6 +133,7 @@ export class ContentResource {
132
133
  }
133
134
  /** Updates an existing content item's fields. */
134
135
  async update(id, data, options) {
136
+ assertNotEmptyIfPresent(data.name, 'Content name');
135
137
  const language = resolveLanguage(options?.language, this.defaultLanguage);
136
138
  // 1. Fetch the existing content (full body needed for the POST)
137
139
  const existing = await this.httpClient.request({
@@ -1,4 +1,4 @@
1
- import { decodeHtmlEntities, AUTH_LEVEL_MAP, AUTH_LEVEL_REVERSE, debugWarn, DEFAULT_CACHE_TTL, getCacheEpoch, invalidateAllCaches, readPrimaryGroup, readSharedGroups, writePrimaryGroup, writeSharedGroups, assertGroupsValid } from '../utils.js';
1
+ import { decodeHtmlEntities, AUTH_LEVEL_MAP, AUTH_LEVEL_REVERSE, debugWarn, DEFAULT_CACHE_TTL, getCacheEpoch, invalidateAllCaches, readPrimaryGroup, readSharedGroups, writePrimaryGroup, writeSharedGroups, assertGroupsValid, assertRequired, assertNotEmptyIfPresent } from '../utils.js';
2
2
  /** The content type category ID that marks a system content type. */
3
3
  const SYSTEM_CONTENT_TYPE = 30;
4
4
  function resolveTypeName(type, typeMap) {
@@ -245,6 +245,7 @@ export class ContentType {
245
245
  return new Layout(raw, httpClient, contentTypeId, layoutElements, fetchLayouts, resolveSyntaxId, resolveProcessorId, resolveExtension);
246
246
  },
247
247
  update: async (layoutName, updateData) => {
248
+ assertNotEmptyIfPresent(updateData.name, 'Layout name');
248
249
  const all = await fetchLayouts();
249
250
  const match = all.find((l) => l.name === layoutName);
250
251
  if (!match)
@@ -285,6 +286,7 @@ export class ContentType {
285
286
  return new Layout(response, httpClient, contentTypeId, layoutElements, fetchLayouts, resolveSyntaxId, resolveProcessorId, resolveExtension);
286
287
  },
287
288
  create: async (createData) => {
289
+ assertRequired(createData.name, 'Layout name');
288
290
  // Check name uniqueness
289
291
  const existing = await fetchLayouts();
290
292
  if (existing.some((l) => l.name === createData.name)) {
@@ -586,6 +588,7 @@ export class ContentType {
586
588
  }
587
589
  /** Persists current property values to the server via PUT. */
588
590
  async save() {
591
+ assertRequired(this.name, 'Content type name');
589
592
  assertGroupsValid(this.primaryGroup, this.sharedGroups);
590
593
  const authLevel = String(AUTH_LEVEL_REVERSE[this.minUserLevel] ?? this._rawData.minAuthLevel ?? 2);
591
594
  // Sync field changes back to raw contentTypeElements
@@ -1,4 +1,4 @@
1
- import { AUTH_LEVEL_MAP } from '../utils.js';
1
+ import { AUTH_LEVEL_MAP, assertRequired } from '../utils.js';
2
2
  /** A mutable group object. Modify properties and call save() to persist. */
3
3
  export class Group {
4
4
  constructor(raw, httpClient) {
@@ -40,6 +40,7 @@ export class Group {
40
40
  }
41
41
  /** Persists current property values to the server via PUT. */
42
42
  async save() {
43
+ assertRequired(this.name, 'Group name');
43
44
  // Start from existing raw members
44
45
  let rawMembers = [...(this._rawData.members ?? [])];
45
46
  // Remove members
@@ -1,5 +1,5 @@
1
1
  import { resolveLanguage } from '../utils.js';
2
- import { decodeHtmlEntities, readPrimaryGroup, readSharedGroups, writePrimaryGroup, writeSharedGroups, assertGroupsValid } from '../utils.js';
2
+ import { decodeHtmlEntities, readPrimaryGroup, readSharedGroups, writePrimaryGroup, writeSharedGroups, assertGroupsValid, assertRequired } from '../utils.js';
3
3
  /** A mutable list object. Modify properties and call save() to persist. */
4
4
  export class List {
5
5
  constructor(raw, httpClient, language) {
@@ -53,6 +53,7 @@ export class List {
53
53
  }
54
54
  /** Persists current property values to the server via PUT. */
55
55
  async save() {
56
+ assertRequired(this.name, 'List name');
56
57
  if (this.isForcedLanguage && this.isDefaultLanguage) {
57
58
  throw new Error('isForcedLanguage and isDefaultLanguage cannot both be true');
58
59
  }
@@ -1,5 +1,5 @@
1
1
  import { MediaItem } from '../models/media-item.js';
2
- import { resolveLanguage, resolveFileToBlob, deriveFilename, DEFAULT_CACHE_TTL, getCacheEpoch } from '../utils.js';
2
+ import { resolveLanguage, resolveFileToBlob, deriveFilename, DEFAULT_CACHE_TTL, getCacheEpoch, assertRequired } from '../utils.js';
3
3
  /** Extension → syntax type mapping for non-binary media */
4
4
  const EXTENSION_SYNTAX_MAP = {
5
5
  js: 1,
@@ -50,10 +50,17 @@ export class MediaResource {
50
50
  * Returns the new media item.
51
51
  */
52
52
  async create(data) {
53
- if (!data.name?.trim())
54
- throw new Error('Media name is required');
53
+ assertRequired(data.name, 'Media name');
55
54
  if (!data.category)
56
55
  throw new Error('Media category is required');
56
+ // A media item is meaningless without its file/binary. Guard the common
57
+ // empty cases: missing, or an empty { file } wrapper.
58
+ const fileValue = data.file && typeof data.file === 'object' && !(data.file instanceof Blob) && 'file' in data.file
59
+ ? data.file.file
60
+ : data.file;
61
+ if (fileValue === undefined || fileValue === null || fileValue === '') {
62
+ throw new Error('Media file is required');
63
+ }
57
64
  const language = data.language ?? 'smxx';
58
65
  const filename = deriveFilename(data.file);
59
66
  const ext = getExtension(filename);
@@ -1,4 +1,4 @@
1
- import { formatFileSize, parseFileSize } from '../utils.js';
1
+ import { formatFileSize, parseFileSize, assertRequired } from '../utils.js';
2
2
  function mapFromDetail(raw) {
3
3
  const layouts = (raw.formatters ?? []).map((f) => ({
4
4
  name: f.mediaLayout,
@@ -65,6 +65,7 @@ export class MediaType {
65
65
  }
66
66
  /** Persists current property values to the server via PUT. */
67
67
  async save() {
68
+ assertRequired(this.name, 'Media type name');
68
69
  // Sync defaultLayout into the layouts array
69
70
  if (this.defaultLayout) {
70
71
  for (const layout of this.layouts) {
@@ -1,4 +1,4 @@
1
- import { readPrimaryGroup, readSharedGroups, writePrimaryGroup, writeSharedGroups, assertGroupsValid } from '../utils.js';
1
+ import { readPrimaryGroup, readSharedGroups, writePrimaryGroup, writeSharedGroups, assertGroupsValid, assertRequired } from '../utils.js';
2
2
  /** Maps SDK type codes to API type codes */
3
3
  const SDK_TO_API = {
4
4
  'a-to-z': 'a2z',
@@ -1003,6 +1003,7 @@ export class NavigationObject {
1003
1003
  }
1004
1004
  /** Persists current property values to the server via PUT. */
1005
1005
  async save() {
1006
+ assertRequired(this.name, 'Navigation object name');
1006
1007
  assertGroupsValid(this.primaryGroup, this.sharedGroups);
1007
1008
  // Apply type-aware write transformation (coerce back to strings, derive hidden fields)
1008
1009
  const camelStringProps = transformPropertiesWrite(this.type, this.properties);
@@ -1,4 +1,4 @@
1
- import { decodeHtmlEntities, DEFAULT_CACHE_TTL, getCacheEpoch, readPrimaryGroup, readSharedGroups, writePrimaryGroup, writeSharedGroups, assertGroupsValid } from '../utils.js';
1
+ import { decodeHtmlEntities, DEFAULT_CACHE_TTL, getCacheEpoch, readPrimaryGroup, readSharedGroups, writePrimaryGroup, writeSharedGroups, assertGroupsValid, assertRequired } from '../utils.js';
2
2
  /** Friendly processor keys mapped to API names for page layouts */
3
3
  const PAGE_PROCESSOR_MAP = {
4
4
  't4-tags': 'T4 Tag Page',
@@ -28,6 +28,7 @@ export class PageLayout {
28
28
  }
29
29
  /** Persists current property values to the server via PUT. */
30
30
  async save() {
31
+ assertRequired(this.name, 'Page layout name');
31
32
  assertGroupsValid(this.primaryGroup, this.sharedGroups);
32
33
  // Resolve syntax name to ID
33
34
  let syntaxId = this._rawData.syntaxType;
@@ -1,4 +1,4 @@
1
- import { resolveLanguage, mapStatus, flattenGroups, STATUS_CODES, AUTH_LEVEL_MAP, debugWarn, DEFAULT_CACHE_TTL, getCacheEpoch } from './utils.js';
1
+ import { resolveLanguage, mapStatus, flattenGroups, STATUS_CODES, AUTH_LEVEL_MAP, debugWarn, DEFAULT_CACHE_TTL, getCacheEpoch, assertRequired, assertNotEmptyIfPresent } from './utils.js';
2
2
  import { ContentResource } from './resources/content-resource.js';
3
3
  import { SectionItem } from './models/section-item.js';
4
4
  /** Per-client cache for meta tag definitions, keyed by HttpClient instance */
@@ -564,6 +564,7 @@ export class SectionRef {
564
564
  * If `data.customFields` is provided, creates and saves section metadata content.
565
565
  */
566
566
  async addSection(data, options) {
567
+ assertRequired(data.name, 'Section name');
567
568
  const language = resolveLanguage(options?.language, this.defaultLanguage);
568
569
  // Fetch this section's details to inherit config
569
570
  const parentSection = await this.httpClient.request({
@@ -708,6 +709,10 @@ export class SectionRef {
708
709
  * Returns the updated SectionItem.
709
710
  */
710
711
  async update(data, options) {
712
+ // This path PUTs directly (it does not go through SectionItem.save()), so
713
+ // guard the name here. Present-only: omitting name is valid (e.g. delete()
714
+ // calls update({ status: 'inactive' })), but setting it blank is not.
715
+ assertNotEmptyIfPresent(data.name, 'Section name');
711
716
  const language = resolveLanguage(options?.language, this.defaultLanguage);
712
717
  const section = await this.httpClient.request({
713
718
  method: 'GET',
@@ -107,6 +107,23 @@ export declare function writeSharedGroups(ids?: number[]): Array<{
107
107
  * group" and is never a conflict.
108
108
  */
109
109
  export declare function assertGroupsValid(primaryGroup: number, sharedGroups?: number[]): void;
110
+ /**
111
+ * Asserts that a required string value is present and not blank.
112
+ *
113
+ * Use on create paths where the field must always be provided. Throws
114
+ * `"<Label> is required"` when the value is missing, empty, or whitespace-only.
115
+ * Prevents opaque API 500s from malformed requests missing a required field.
116
+ */
117
+ export declare function assertRequired(value: string | null | undefined, label: string): void;
118
+ /**
119
+ * Asserts that a value, *if provided*, is not blank.
120
+ *
121
+ * Use on update paths where a field is optional (omitting it leaves the
122
+ * current value unchanged) but explicitly setting it to an empty or
123
+ * whitespace-only string is invalid. `undefined` passes (means "no change");
124
+ * `''` or whitespace throws `"<Label> cannot be empty"`.
125
+ */
126
+ export declare function assertNotEmptyIfPresent(value: string | null | undefined, label: string): void;
110
127
  /** Accepted file input: a file path, URL, Blob, ReadableStream, or { file, filename } object */
111
128
  export type FileInput = string | Blob | NodeJS.ReadableStream | {
112
129
  file: string | Blob | NodeJS.ReadableStream;
package/dist/esm/utils.js CHANGED
@@ -202,6 +202,33 @@ export function assertGroupsValid(primaryGroup, sharedGroups) {
202
202
  'Remove it from sharedGroups or choose a different primaryGroup.');
203
203
  }
204
204
  }
205
+ /**
206
+ * Asserts that a required string value is present and not blank.
207
+ *
208
+ * Use on create paths where the field must always be provided. Throws
209
+ * `"<Label> is required"` when the value is missing, empty, or whitespace-only.
210
+ * Prevents opaque API 500s from malformed requests missing a required field.
211
+ */
212
+ export function assertRequired(value, label) {
213
+ if (!value || !value.trim()) {
214
+ throw new Error(`${label} is required`);
215
+ }
216
+ }
217
+ /**
218
+ * Asserts that a value, *if provided*, is not blank.
219
+ *
220
+ * Use on update paths where a field is optional (omitting it leaves the
221
+ * current value unchanged) but explicitly setting it to an empty or
222
+ * whitespace-only string is invalid. `undefined` passes (means "no change");
223
+ * `''` or whitespace throws `"<Label> cannot be empty"`.
224
+ */
225
+ export function assertNotEmptyIfPresent(value, label) {
226
+ if (value === undefined || value === null)
227
+ return;
228
+ if (!value.trim()) {
229
+ throw new Error(`${label} cannot be empty`);
230
+ }
231
+ }
205
232
  /**
206
233
  * Resolves a file input (path, URL, Blob, or ReadableStream) to a Blob.
207
234
  */
@@ -328,7 +328,7 @@ await contentType.layouts.create({
328
328
  });
329
329
  ```
330
330
 
331
- Processor options are `'handlebars'`, `'t4-tags'`, and `'programmable-layouts'`. The default is `'handlebars'`. The SDK enforces unique layout names.
331
+ Processor options are `'handlebars'`, `'t4-tags'`, and `'programmable-layouts'`. The default is `'handlebars'`. The SDK enforces unique layout names. `name` is required (throws `Layout name is required` if empty); `code` is optional and may be empty. Renaming a layout to an empty string on update is rejected.
332
332
 
333
333
  ### Direct update
334
334
 
@@ -61,6 +61,32 @@ The final error can result from a call such as:
61
61
  await t4.users.create({ username: '', ... });
62
62
  ```
63
63
 
64
+ ### Required fields
65
+
66
+ Creating an asset without its required `name` (or other required input) throws before any request is sent, so you get a clear message instead of an opaque API error:
67
+
68
+ ```text
69
+ Error: Section name is required
70
+ Error: Content name is required
71
+ Error: Layout name is required
72
+ Error: Media name is required
73
+ Error: Media file is required
74
+ ```
75
+
76
+ The same protection applies on update: you cannot blank out a name that is already set. Omitting `name` leaves it unchanged, but setting it to an empty or whitespace-only string is rejected — through both the resource `update()` method and the mutable `get()` → modify → `save()` pattern:
77
+
78
+ ```typescript
79
+ const section = await t4.section(482).get();
80
+ section.name = '';
81
+ await section.save();
82
+ // Error: Section name is required
83
+
84
+ await t4.section(482).update({ name: ' ' });
85
+ // Error: Section name cannot be empty
86
+ ```
87
+
88
+ This covers content types, content items, sections, lists, groups, page layouts, media, media types, media categories, navigation objects, and Handlebars helpers/partials.
89
+
64
90
  ## Enable debug logging
65
91
 
66
92
  Set `T4_DEBUG=1` to print HTTP requests and internal warnings:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terminalfour/terminalfour-js",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "JavaScript / TypeScript SDK for the Terminalfour Web Services REST API",
5
5
  "keywords": [
6
6
  "terminalfour",