@zeropress/preview-data-validator 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # @zeropress/preview-data-validator
2
2
 
3
- Shared validation core for ZeroPress preview data v0.2.
3
+ ![npm](https://img.shields.io/npm/v/%40zeropress%2Fpreview-data-validator)
4
+ ![license](https://img.shields.io/npm/l/%40zeropress%2Fpreview-data-validator)
5
+ ![node](https://img.shields.io/node/v/%40zeropress%2Fpreview-data-validator)
6
+
7
+ Shared validation core for ZeroPress preview data v0.3.
4
8
 
5
9
  This package is the canonical runtime contract for preview payloads consumed by:
6
10
 
@@ -27,7 +31,7 @@ import {
27
31
  Schema export:
28
32
 
29
33
  ```js
30
- import schemaUrl from '@zeropress/preview-data-validator/preview-data.v0.2.schema.json';
34
+ import schemaUrl from '@zeropress/preview-data-validator/preview-data.v0.3.schema.json';
31
35
  ```
32
36
 
33
37
  ## API
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@zeropress/preview-data-validator",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Shared ZeroPress preview data validation core",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "exports": {
8
8
  ".": "./src/index.js",
9
- "./preview-data.v0.2.schema.json": "./src/preview-data.v0.2.schema.json"
9
+ "./preview-data.v0.2.schema.json": "./src/preview-data.v0.2.schema.json",
10
+ "./preview-data.v0.3.schema.json": "./src/preview-data.v0.3.schema.json"
10
11
  },
11
12
  "types": "./src/index.d.ts",
12
13
  "files": [
package/src/index.d.ts CHANGED
@@ -61,24 +61,28 @@ export interface PreviewTagData {
61
61
  postCount: number;
62
62
  }
63
63
 
64
- export interface PreviewRouteBlocks {
64
+ export interface PreviewPaginatedRouteData {
65
+ path: string;
66
+ page: number;
67
+ totalPages: number;
65
68
  posts: string;
66
69
  pagination: string;
67
- categories?: string;
68
- tags?: string;
69
70
  }
70
71
 
71
- export interface PreviewCategoryRouteData {
72
+ export interface PreviewIndexRouteData extends PreviewPaginatedRouteData {
73
+ categories: string;
74
+ tags: string;
75
+ }
76
+
77
+ export interface PreviewArchiveRouteData extends PreviewPaginatedRouteData {}
78
+
79
+ export interface PreviewCategoryRouteData extends PreviewPaginatedRouteData {
72
80
  slug: string;
73
- posts: string;
74
- pagination: string;
75
81
  categories?: string;
76
82
  }
77
83
 
78
- export interface PreviewTagRouteData {
84
+ export interface PreviewTagRouteData extends PreviewPaginatedRouteData {
79
85
  slug: string;
80
- posts: string;
81
- pagination: string;
82
86
  tags?: string;
83
87
  }
84
88
 
@@ -90,17 +94,14 @@ export interface PreviewContentData {
90
94
  }
91
95
 
92
96
  export interface PreviewRoutesData {
93
- index: PreviewRouteBlocks & {
94
- categories: string;
95
- tags: string;
96
- };
97
- archive: PreviewRouteBlocks;
97
+ index: PreviewIndexRouteData[];
98
+ archive: PreviewArchiveRouteData[];
98
99
  categories: PreviewCategoryRouteData[];
99
100
  tags: PreviewTagRouteData[];
100
101
  }
101
102
 
102
- export interface PreviewDataV02 {
103
- version: '0.2';
103
+ export interface PreviewDataV03 {
104
+ version: '0.3';
104
105
  generator: string;
105
106
  generated_at: string;
106
107
  site: PreviewSiteData;
@@ -114,8 +115,8 @@ export interface PreviewDataValidationResult {
114
115
  warnings: ValidationIssue[];
115
116
  }
116
117
 
117
- export const PREVIEW_DATA_VERSION: '0.2';
118
+ export const PREVIEW_DATA_VERSION: '0.3';
118
119
 
119
120
  export function validatePreviewData(data: unknown): PreviewDataValidationResult;
120
121
  export function assertPreviewData<T>(data: T): T;
121
- export function isPreviewData(data: unknown): data is PreviewDataV02;
122
+ export function isPreviewData(data: unknown): data is PreviewDataV03;
package/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export const PREVIEW_DATA_VERSION = '0.2';
1
+ export const PREVIEW_DATA_VERSION = '0.3';
2
2
 
3
3
  export function validatePreviewData(data) {
4
4
  const errors = [];
@@ -6,7 +6,7 @@ export function validatePreviewData(data) {
6
6
  validateClosedObject(data, '', errors, ['version', 'generator', 'generated_at', 'site', 'content', 'routes']);
7
7
 
8
8
  if (!errors.length) {
9
- validateLiteral(data.version, '0.2', 'version', 'INVALID_VERSION', errors);
9
+ validateLiteral(data.version, PREVIEW_DATA_VERSION, 'version', 'INVALID_VERSION', errors);
10
10
  validateNonEmptyString(data.generator, 'generator', 'INVALID_GENERATOR', errors);
11
11
  validateDateTimeString(data.generated_at, 'generated_at', 'INVALID_GENERATED_AT', errors);
12
12
 
@@ -86,13 +86,11 @@ function validateRoutes(routes, path, errors) {
86
86
  return;
87
87
  }
88
88
 
89
- validateRouteBlocks(routes.index, `${path}.index`, errors, {
90
- requiredKeys: ['posts', 'pagination', 'categories', 'tags'],
91
- allowKeys: ['posts', 'pagination', 'categories', 'tags'],
89
+ validateArray(routes.index, `${path}.index`, 'INVALID_INDEX_ROUTES', errors, (entry, index) => {
90
+ validateIndexRoute(entry, `${path}.index[${index}]`, errors);
92
91
  });
93
- validateRouteBlocks(routes.archive, `${path}.archive`, errors, {
94
- requiredKeys: ['posts', 'pagination'],
95
- allowKeys: ['posts', 'pagination', 'categories', 'tags'],
92
+ validateArray(routes.archive, `${path}.archive`, 'INVALID_ARCHIVE_ROUTES', errors, (entry, index) => {
93
+ validateArchiveRoute(entry, `${path}.archive[${index}]`, errors);
96
94
  });
97
95
  validateArray(routes.categories, `${path}.categories`, 'INVALID_CATEGORY_ROUTES', errors, (entry, index) => {
98
96
  validateCategoryRoute(entry, `${path}.categories[${index}]`, errors);
@@ -192,48 +190,61 @@ function validatePreviewTag(tag, path, errors) {
192
190
  validateInteger(tag.postCount, `${path}.postCount`, 'INVALID_TAG_POST_COUNT', errors, { minimum: 0 });
193
191
  }
194
192
 
195
- function validateCategoryRoute(route, path, errors) {
196
- validateClosedObject(route, path, errors, ['slug', 'posts', 'pagination', 'categories']);
197
- if (!isObject(route)) {
198
- return;
199
- }
193
+ function validateIndexRoute(route, path, errors) {
194
+ validatePaginatedRoute(route, path, errors, {
195
+ allowedKeys: ['path', 'page', 'totalPages', 'posts', 'pagination', 'categories', 'tags'],
196
+ requiredKeys: ['path', 'page', 'totalPages', 'posts', 'pagination', 'categories', 'tags'],
197
+ routeCodePrefix: 'INDEX_ROUTE',
198
+ });
199
+ }
200
200
 
201
- validateNonEmptyString(route.slug, `${path}.slug`, 'INVALID_CATEGORY_ROUTE_SLUG', errors);
202
- validateString(route.posts, `${path}.posts`, 'INVALID_CATEGORY_ROUTE_POSTS', errors);
203
- validateString(route.pagination, `${path}.pagination`, 'INVALID_CATEGORY_ROUTE_PAGINATION', errors);
204
- if (route.categories !== undefined) {
205
- validateString(route.categories, `${path}.categories`, 'INVALID_CATEGORY_ROUTE_CATEGORIES', errors);
206
- }
201
+ function validateArchiveRoute(route, path, errors) {
202
+ validatePaginatedRoute(route, path, errors, {
203
+ allowedKeys: ['path', 'page', 'totalPages', 'posts', 'pagination'],
204
+ requiredKeys: ['path', 'page', 'totalPages', 'posts', 'pagination'],
205
+ routeCodePrefix: 'ARCHIVE_ROUTE',
206
+ });
207
+ }
208
+
209
+ function validateCategoryRoute(route, path, errors) {
210
+ validatePaginatedRoute(route, path, errors, {
211
+ allowedKeys: ['path', 'page', 'totalPages', 'slug', 'posts', 'pagination', 'categories'],
212
+ requiredKeys: ['path', 'page', 'totalPages', 'slug', 'posts', 'pagination'],
213
+ routeCodePrefix: 'CATEGORY_ROUTE',
214
+ });
207
215
  }
208
216
 
209
217
  function validateTagRoute(route, path, errors) {
210
- validateClosedObject(route, path, errors, ['slug', 'posts', 'pagination', 'tags']);
218
+ validatePaginatedRoute(route, path, errors, {
219
+ allowedKeys: ['path', 'page', 'totalPages', 'slug', 'posts', 'pagination', 'tags'],
220
+ requiredKeys: ['path', 'page', 'totalPages', 'slug', 'posts', 'pagination'],
221
+ routeCodePrefix: 'TAG_ROUTE',
222
+ });
223
+ }
224
+
225
+ function validatePaginatedRoute(route, path, errors, options) {
226
+ validateClosedObject(route, path, errors, options.allowedKeys);
211
227
  if (!isObject(route)) {
212
228
  return;
213
229
  }
214
230
 
215
- validateNonEmptyString(route.slug, `${path}.slug`, 'INVALID_TAG_ROUTE_SLUG', errors);
216
- validateString(route.posts, `${path}.posts`, 'INVALID_TAG_ROUTE_POSTS', errors);
217
- validateString(route.pagination, `${path}.pagination`, 'INVALID_TAG_ROUTE_PAGINATION', errors);
218
- if (route.tags !== undefined) {
219
- validateString(route.tags, `${path}.tags`, 'INVALID_TAG_ROUTE_TAGS', errors);
220
- }
221
- }
231
+ validateNonEmptyString(route.path, `${path}.path`, `INVALID_${options.routeCodePrefix}_PATH`, errors);
232
+ validateInteger(route.page, `${path}.page`, `INVALID_${options.routeCodePrefix}_PAGE`, errors, { minimum: 1 });
233
+ validateInteger(route.totalPages, `${path}.totalPages`, `INVALID_${options.routeCodePrefix}_TOTAL_PAGES`, errors, { minimum: 1 });
234
+ validateString(route.posts, `${path}.posts`, `INVALID_${options.routeCodePrefix}_POSTS`, errors);
235
+ validateString(route.pagination, `${path}.pagination`, `INVALID_${options.routeCodePrefix}_PAGINATION`, errors);
222
236
 
223
- function validateRouteBlocks(blocks, path, errors, options) {
224
- validateClosedObject(blocks, path, errors, options.allowKeys);
225
- if (!isObject(blocks)) {
226
- return;
237
+ if ('slug' in route) {
238
+ validateNonEmptyString(route.slug, `${path}.slug`, `INVALID_${options.routeCodePrefix}_SLUG`, errors);
227
239
  }
228
-
229
- for (const key of options.requiredKeys) {
230
- validateString(blocks[key], `${path}.${key}`, `INVALID_ROUTE_${key.toUpperCase()}`, errors);
240
+ if (route.categories !== undefined) {
241
+ validateString(route.categories, `${path}.categories`, `INVALID_${options.routeCodePrefix}_CATEGORIES`, errors);
231
242
  }
232
-
233
- for (const key of options.allowKeys) {
234
- if (blocks[key] !== undefined) {
235
- validateString(blocks[key], `${path}.${key}`, `INVALID_ROUTE_${key.toUpperCase()}`, errors);
236
- }
243
+ if (route.tags !== undefined) {
244
+ validateString(route.tags, `${path}.tags`, `INVALID_${options.routeCodePrefix}_TAGS`, errors);
245
+ }
246
+ if (Number.isInteger(route.page) && Number.isInteger(route.totalPages) && route.page > route.totalPages) {
247
+ errors.push(issue(`INVALID_${options.routeCodePrefix}_PAGE`, `${path}.page`, 'Page cannot exceed totalPages'));
237
248
  }
238
249
  }
239
250
 
@@ -267,12 +278,10 @@ function validateClosedObject(value, path, errors, allowedKeys) {
267
278
 
268
279
  function isOptionalKey(path, key) {
269
280
  if (path === 'site') return key === 'logo' || key === 'social';
270
- if (path.endsWith('.index')) return false;
271
- if (path.endsWith('.archive')) return key === 'categories' || key === 'tags';
272
- if (path.includes('categories[')) return key === 'categories';
273
- if (path.includes('tags[')) return key === 'tags';
274
- if (path.includes('.posts[')) return key === 'author_avatar' || key === 'featured_image';
275
- if (path.includes('.categories[')) return key === 'description';
281
+ if (path.startsWith('content.posts[')) return key === 'author_avatar' || key === 'featured_image';
282
+ if (path.startsWith('content.categories[')) return key === 'description';
283
+ if (path.startsWith('routes.categories[')) return key === 'categories';
284
+ if (path.startsWith('routes.tags[')) return key === 'tags';
276
285
  return false;
277
286
  }
278
287
 
@@ -282,9 +291,9 @@ function validateObject(value, path, code, errors) {
282
291
  }
283
292
  }
284
293
 
285
- function validateLiteral(value, expected, path, code, errors) {
286
- if (value !== expected) {
287
- errors.push(issue(code, path, `Expected '${expected}'`));
294
+ function validateLiteral(value, literal, path, code, errors) {
295
+ if (value !== literal) {
296
+ errors.push(issue(code, path, `Expected literal value "${literal}"`));
288
297
  }
289
298
  }
290
299
 
@@ -305,8 +314,9 @@ function validateInteger(value, path, code, errors, options = {}) {
305
314
  errors.push(issue(code, path, 'Expected an integer'));
306
315
  return;
307
316
  }
317
+
308
318
  if (options.minimum !== undefined && value < options.minimum) {
309
- errors.push(issue(code, path, `Expected an integer >= ${options.minimum}`));
319
+ errors.push(issue(code, path, `Expected integer >= ${options.minimum}`));
310
320
  }
311
321
  }
312
322
 
@@ -316,35 +326,31 @@ function validateEnum(value, path, code, errors, allowedValues) {
316
326
  }
317
327
  }
318
328
 
319
- function validateUri(value, path, code, errors) {
329
+ function validateDateTimeString(value, path, code, errors) {
320
330
  if (typeof value !== 'string' || value.trim() === '') {
321
- errors.push(issue(code, path, 'Expected a URI string'));
331
+ errors.push(issue(code, path, 'Expected a date-time string'));
322
332
  return;
323
333
  }
324
334
 
325
- try {
326
- new URL(value);
327
- } catch {
328
- errors.push(issue(code, path, 'Expected a valid URI'));
335
+ const date = new Date(value);
336
+ if (Number.isNaN(date.getTime())) {
337
+ errors.push(issue(code, path, 'Expected a valid date-time string'));
329
338
  }
330
339
  }
331
340
 
332
- function validateDateTimeString(value, path, code, errors) {
341
+ function validateUri(value, path, code, errors) {
333
342
  if (typeof value !== 'string' || value.trim() === '') {
334
- errors.push(issue(code, path, 'Expected an ISO date-time string'));
343
+ errors.push(issue(code, path, 'Expected a URI string'));
335
344
  return;
336
345
  }
337
346
 
338
- const parsed = Date.parse(value);
339
- if (Number.isNaN(parsed)) {
340
- errors.push(issue(code, path, 'Expected a valid ISO date-time string'));
347
+ try {
348
+ new URL(value);
349
+ } catch {
350
+ errors.push(issue(code, path, 'Expected a valid URI'));
341
351
  }
342
352
  }
343
353
 
344
- function isObject(value) {
345
- return value !== null && typeof value === 'object' && !Array.isArray(value);
346
- }
347
-
348
354
  function issue(code, path, message) {
349
355
  return {
350
356
  code,
@@ -353,3 +359,7 @@ function issue(code, path, message) {
353
359
  severity: 'error',
354
360
  };
355
361
  }
362
+
363
+ function isObject(value) {
364
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
365
+ }
@@ -0,0 +1,218 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://zeropress.dev/schemas/preview-data.v0.3.schema.json",
4
+ "title": "ZeroPress Theme Preview Data v0.3",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["version", "generator", "generated_at", "site", "content", "routes"],
8
+ "properties": {
9
+ "version": {
10
+ "const": "0.3"
11
+ },
12
+ "generator": {
13
+ "type": "string",
14
+ "minLength": 1
15
+ },
16
+ "generated_at": {
17
+ "type": "string",
18
+ "format": "date-time"
19
+ },
20
+ "site": {
21
+ "$ref": "#/$defs/site"
22
+ },
23
+ "content": {
24
+ "$ref": "#/$defs/content"
25
+ },
26
+ "routes": {
27
+ "$ref": "#/$defs/routes"
28
+ }
29
+ },
30
+ "$defs": {
31
+ "site": {
32
+ "type": "object",
33
+ "required": ["title", "description", "url", "language"],
34
+ "properties": {
35
+ "title": { "type": "string", "minLength": 1 },
36
+ "description": { "type": "string" },
37
+ "url": { "type": "string", "format": "uri" },
38
+ "language": { "type": "string", "minLength": 2 },
39
+ "logo": { "type": "string", "format": "uri" },
40
+ "social": {
41
+ "type": "object",
42
+ "additionalProperties": {
43
+ "type": "string"
44
+ }
45
+ }
46
+ },
47
+ "additionalProperties": true
48
+ },
49
+ "previewPost": {
50
+ "type": "object",
51
+ "additionalProperties": false,
52
+ "required": [
53
+ "id",
54
+ "public_id",
55
+ "title",
56
+ "slug",
57
+ "html",
58
+ "excerpt",
59
+ "published_at",
60
+ "updated_at",
61
+ "published_at_iso",
62
+ "updated_at_iso",
63
+ "reading_time",
64
+ "author_name",
65
+ "categories_html",
66
+ "tags_html",
67
+ "comments_html",
68
+ "status"
69
+ ],
70
+ "properties": {
71
+ "id": { "type": "string", "minLength": 1 },
72
+ "public_id": { "type": "integer", "minimum": 1 },
73
+ "title": { "type": "string", "minLength": 1 },
74
+ "slug": { "type": "string", "minLength": 1 },
75
+ "html": { "type": "string" },
76
+ "excerpt": { "type": "string" },
77
+ "published_at": { "type": "string" },
78
+ "updated_at": { "type": "string" },
79
+ "published_at_iso": { "type": "string", "format": "date-time" },
80
+ "updated_at_iso": { "type": "string", "format": "date-time" },
81
+ "reading_time": { "type": "string", "minLength": 1 },
82
+ "author_name": { "type": "string", "minLength": 1 },
83
+ "author_avatar": { "type": "string", "format": "uri" },
84
+ "featured_image": { "type": "string", "format": "uri" },
85
+ "categories_html": { "type": "string" },
86
+ "tags_html": { "type": "string" },
87
+ "comments_html": { "type": "string" },
88
+ "status": {
89
+ "type": "string",
90
+ "enum": ["published", "draft"]
91
+ }
92
+ }
93
+ },
94
+ "previewPage": {
95
+ "type": "object",
96
+ "additionalProperties": false,
97
+ "required": ["id", "title", "slug", "html", "status"],
98
+ "properties": {
99
+ "id": { "type": "string", "minLength": 1 },
100
+ "title": { "type": "string", "minLength": 1 },
101
+ "slug": { "type": "string", "minLength": 1 },
102
+ "html": { "type": "string" },
103
+ "status": {
104
+ "type": "string",
105
+ "enum": ["published", "draft"]
106
+ }
107
+ }
108
+ },
109
+ "previewCategory": {
110
+ "type": "object",
111
+ "additionalProperties": false,
112
+ "required": ["id", "name", "slug", "postCount"],
113
+ "properties": {
114
+ "id": { "type": "string", "minLength": 1 },
115
+ "name": { "type": "string", "minLength": 1 },
116
+ "slug": { "type": "string", "minLength": 1 },
117
+ "description": { "type": "string" },
118
+ "postCount": { "type": "integer", "minimum": 0 }
119
+ }
120
+ },
121
+ "previewTag": {
122
+ "type": "object",
123
+ "additionalProperties": false,
124
+ "required": ["id", "name", "slug", "postCount"],
125
+ "properties": {
126
+ "id": { "type": "string", "minLength": 1 },
127
+ "name": { "type": "string", "minLength": 1 },
128
+ "slug": { "type": "string", "minLength": 1 },
129
+ "postCount": { "type": "integer", "minimum": 0 }
130
+ }
131
+ },
132
+ "content": {
133
+ "type": "object",
134
+ "additionalProperties": false,
135
+ "required": ["posts", "pages", "categories", "tags"],
136
+ "properties": {
137
+ "posts": {
138
+ "type": "array",
139
+ "items": { "$ref": "#/$defs/previewPost" }
140
+ },
141
+ "pages": {
142
+ "type": "array",
143
+ "items": { "$ref": "#/$defs/previewPage" }
144
+ },
145
+ "categories": {
146
+ "type": "array",
147
+ "items": { "$ref": "#/$defs/previewCategory" }
148
+ },
149
+ "tags": {
150
+ "type": "array",
151
+ "items": { "$ref": "#/$defs/previewTag" }
152
+ }
153
+ }
154
+ },
155
+ "paginatedRouteBase": {
156
+ "type": "object",
157
+ "additionalProperties": false,
158
+ "required": ["path", "page", "totalPages", "posts", "pagination"],
159
+ "properties": {
160
+ "path": { "type": "string", "minLength": 1 },
161
+ "page": { "type": "integer", "minimum": 1 },
162
+ "totalPages": { "type": "integer", "minimum": 1 },
163
+ "posts": { "type": "string" },
164
+ "pagination": { "type": "string" },
165
+ "categories": { "type": "string" },
166
+ "tags": { "type": "string" },
167
+ "slug": { "type": "string", "minLength": 1 }
168
+ }
169
+ },
170
+ "indexRoute": {
171
+ "allOf": [
172
+ { "$ref": "#/$defs/paginatedRouteBase" },
173
+ { "required": ["path", "page", "totalPages", "posts", "pagination", "categories", "tags"] }
174
+ ]
175
+ },
176
+ "archiveRoute": {
177
+ "allOf": [
178
+ { "$ref": "#/$defs/paginatedRouteBase" },
179
+ { "required": ["path", "page", "totalPages", "posts", "pagination"] }
180
+ ]
181
+ },
182
+ "categoryRoute": {
183
+ "allOf": [
184
+ { "$ref": "#/$defs/paginatedRouteBase" },
185
+ { "required": ["path", "page", "totalPages", "slug", "posts", "pagination"] }
186
+ ]
187
+ },
188
+ "tagRoute": {
189
+ "allOf": [
190
+ { "$ref": "#/$defs/paginatedRouteBase" },
191
+ { "required": ["path", "page", "totalPages", "slug", "posts", "pagination"] }
192
+ ]
193
+ },
194
+ "routes": {
195
+ "type": "object",
196
+ "additionalProperties": false,
197
+ "required": ["index", "archive", "categories", "tags"],
198
+ "properties": {
199
+ "index": {
200
+ "type": "array",
201
+ "items": { "$ref": "#/$defs/indexRoute" }
202
+ },
203
+ "archive": {
204
+ "type": "array",
205
+ "items": { "$ref": "#/$defs/archiveRoute" }
206
+ },
207
+ "categories": {
208
+ "type": "array",
209
+ "items": { "$ref": "#/$defs/categoryRoute" }
210
+ },
211
+ "tags": {
212
+ "type": "array",
213
+ "items": { "$ref": "#/$defs/tagRoute" }
214
+ }
215
+ }
216
+ }
217
+ }
218
+ }