-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvalidate.ts
More file actions
127 lines (110 loc) · 4.56 KB
/
Copy pathvalidate.ts
File metadata and controls
127 lines (110 loc) · 4.56 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
/**
* The CMS data contract (runtime side).
*
* Given a record that claims to be CMS content, assert that the SEO-relevant
* fields the frontend depends on are present and correctly typed. This is the
* boundary check: run a live CMS response through it (in the adapter) so a
* renamed/removed/retyped field fails LOUDLY here instead of silently
* degrading the rendered SEO downstream.
*
* `npm run check:contract` runs this over the fixtures as a stand-in for
* validating a real CMS payload in CI.
*
* Hand-written (no schema library) on purpose: the checks ARE the
* documentation of what the frontend requires, readable top to bottom.
*/
export interface ValidationError {
/** Dotted path to the offending field, e.g. `seo.metaTitle`. */
path: string;
message: string;
}
export interface ValidationResult {
valid: boolean;
errors: ValidationError[];
}
const STATUSES = ['published', 'draft', 'archived'];
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): boolean {
return typeof value === 'string' && value.trim().length > 0;
}
/** Reject anything that is not a valid ISO-8601 timestamp string. */
function isIsoTimestamp(value: unknown): boolean {
if (typeof value !== 'string' || value.trim() === '') return false;
const time = Date.parse(value);
return Number.isFinite(time);
}
/**
* Validate a single content record against the contract.
*
* The identifier field differs per type (`slug` for posts/categories, `handle`
* for products), so we accept whichever is present.
*/
export function validateSeoRecord(record: unknown): ValidationResult {
const errors: ValidationError[] = [];
const push = (path: string, message: string) => errors.push({ path, message });
if (!isObject(record)) {
return { valid: false, errors: [{ path: '', message: 'record must be an object' }] };
}
// Identifier: slug or handle (non-empty string).
if (!isNonEmptyString(record.slug) && !isNonEmptyString(record.handle)) {
push('slug|handle', 'a non-empty `slug` or `handle` is required');
}
// Title source: title or name (non-empty string).
if (!isNonEmptyString(record.title) && !isNonEmptyString(record.name)) {
push('title|name', 'a non-empty `title` or `name` is required');
}
// Status enum.
if (typeof record.status !== 'string' || !STATUSES.includes(record.status)) {
push('status', `must be one of ${STATUSES.join(', ')}`);
}
// Timestamps. `contentUpdatedAt` is what the sitemap's <lastmod> depends on,
// so it is required and must be a valid timestamp.
if (!isIsoTimestamp(record.contentUpdatedAt)) {
push('contentUpdatedAt', 'required and must be a valid ISO-8601 timestamp');
}
if (record.createdAt !== undefined && !isIsoTimestamp(record.createdAt)) {
push('createdAt', 'must be a valid ISO-8601 timestamp when present');
}
if (record.updatedAt !== undefined && !isIsoTimestamp(record.updatedAt)) {
push('updatedAt', 'must be a valid ISO-8601 timestamp when present');
}
// The SEO block must exist and be an object.
const seo = record.seo;
if (!isObject(seo)) {
push('seo', 'an `seo` object is required (it may be empty `{}`)');
} else {
validateSeoBlock(seo, push);
}
return { valid: errors.length === 0, errors };
}
function validateSeoBlock(
seo: Record<string, unknown>,
push: (path: string, message: string) => void,
): void {
// Optional string fields: if present, must be a string (null is allowed as
// "explicitly absent", but other types are a contract violation).
for (const key of ['metaTitle', 'metaDescription', 'canonicalOverride', 'ogImage'] as const) {
const value = seo[key];
if (value !== undefined && value !== null && typeof value !== 'string') {
push(`seo.${key}`, 'must be a string, null, or omitted');
}
}
// noindex, if present, must be a boolean (the classic "wrong type" trap:
// a CMS sending the string "false" would be truthy and silently noindex).
if (seo.noindex !== undefined && typeof seo.noindex !== 'boolean') {
push('seo.noindex', 'must be a boolean when present');
}
}
/**
* Throwing variant for use inside CMS adapters: validate at the boundary and
* fail loudly with every problem at once.
*/
export function assertValidRecord(record: unknown): void {
const result = validateSeoRecord(record);
if (!result.valid) {
const detail = result.errors.map((e) => ` - ${e.path || '(root)'}: ${e.message}`).join('\n');
throw new Error(`CMS record failed the SEO contract:\n${detail}`);
}
}