@nfcard/validation 0.23.0 → 0.25.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/dist/index.d.ts +1860 -2
- package/dist/index.js +416 -227
- package/package.json +2 -2
- package/src/index.ts +0 -0
- package/src/profileLayout.test.ts +259 -0
- package/src/profileLayout.ts +297 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nfcard/validation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Shared Zod validation schemas for the NFCard product family.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"zod": "^3.24.0",
|
|
20
|
-
"@nfcard/types": "0.
|
|
20
|
+
"@nfcard/types": "0.19.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"tsup": "^8.0.0"
|
package/src/index.ts
CHANGED
|
Binary file
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NFCARD-656 — write-side guards for `DigitalConfig.layout`.
|
|
3
|
+
*
|
|
4
|
+
* The abuse cases matter more than the happy path here: every style value
|
|
5
|
+
* eventually composes into a CSS rule block, so the assertion of record is
|
|
6
|
+
* "nothing non-primitive survives the schema", with the injection-shaped
|
|
7
|
+
* strings spelled out.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from 'vitest'
|
|
10
|
+
import { CANONICAL_OBJECT_ORDER, PROFILE_OBJECT_KINDS, ctaObjectIndex, type ProfileLayout } from '@nfcard/types'
|
|
11
|
+
import { MAX_CTA_BUTTONS, digitalConfigSchema } from './index'
|
|
12
|
+
import { STYLE_SCHEMA_FOR_KIND, profileLayoutSchema, safeProfileLayout } from './profileLayout'
|
|
13
|
+
|
|
14
|
+
const okLayout: ProfileLayout = {
|
|
15
|
+
v: 1,
|
|
16
|
+
sectionOrder: ['identity', 'social', 'contact', 'bio', 'actions', 'cta', 'footer'],
|
|
17
|
+
objectOrder: {
|
|
18
|
+
social: ['social-x', 'social-linkedin'],
|
|
19
|
+
actions: ['act-share', 'act-save', 'act-exchange'],
|
|
20
|
+
},
|
|
21
|
+
objects: {
|
|
22
|
+
name: { style: { color: '#112233', sizeStep: 'lg', weight: 'bold' } },
|
|
23
|
+
avatar: { style: { crop: { x: 0.5, y: 0.25, zoom: 1.6 }, borderColor: '#ffffff', borderPx: 3 } },
|
|
24
|
+
'cta-0': { style: { bg: '#16a34a', color: '#ffffff' } },
|
|
25
|
+
booking: { style: { weight: 'bold' } },
|
|
26
|
+
},
|
|
27
|
+
ownedObjects: [{ id: 'ft-abcd', kind: 'freeText', section: 'bio', text: 'Hello there' }],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('profileLayoutSchema — accepts real intent', () => {
|
|
31
|
+
it('parses a full document and the result is a ProfileLayout', () => {
|
|
32
|
+
const parsed = profileLayoutSchema.parse(okLayout)
|
|
33
|
+
const typed: ProfileLayout = parsed
|
|
34
|
+
expect(typed.objectOrder?.social).toEqual(['social-x', 'social-linkedin'])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('parses the boring derived document and the empty-intent forms', () => {
|
|
38
|
+
expect(profileLayoutSchema.parse({ v: 1 })).toEqual({ v: 1 })
|
|
39
|
+
expect(profileLayoutSchema.safeParse({ v: 1, objectOrder: {} }).success).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('order entries tolerate well-formed ids this vintage does not know (skew safety)', () => {
|
|
43
|
+
const r = profileLayoutSchema.safeParse({
|
|
44
|
+
v: 1,
|
|
45
|
+
objectOrder: { social: ['social-threads', 'social-x'] },
|
|
46
|
+
})
|
|
47
|
+
expect(r.success).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('profileLayoutSchema — rejects abuse', () => {
|
|
52
|
+
const bad = (layout: unknown) => profileLayoutSchema.safeParse(layout).success
|
|
53
|
+
|
|
54
|
+
it('version and unknown keys', () => {
|
|
55
|
+
expect(bad({ v: 2 })).toBe(false)
|
|
56
|
+
expect(bad({ v: 1, extra: true })).toBe(false)
|
|
57
|
+
expect(bad({ v: 1, objects: { name: { style: {}, accent: {} } } })).toBe(false)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('sectionOrder: duplicates and unknown sections', () => {
|
|
61
|
+
expect(bad({ v: 1, sectionOrder: ['social', 'social'] })).toBe(false)
|
|
62
|
+
expect(bad({ v: 1, sectionOrder: ['hero'] })).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('objectOrder: malformed ids and oversize lists', () => {
|
|
66
|
+
expect(bad({ v: 1, objectOrder: { social: ['a.b'] } })).toBe(false)
|
|
67
|
+
expect(bad({ v: 1, objectOrder: { social: ['x'.repeat(33)] } })).toBe(false)
|
|
68
|
+
expect(bad({ v: 1, objectOrder: { social: Array.from({ length: 25 }, (_, i) => `id-${i}`) } })).toBe(false)
|
|
69
|
+
expect(bad({ v: 1, objectOrder: { unknownSection: ['social-x'] } })).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('objects: unknown ids, off-vocabulary props, injection-shaped values', () => {
|
|
73
|
+
expect(bad({ v: 1, objects: { 'not-a-thing': { style: {} } } })).toBe(false)
|
|
74
|
+
// contactRow is TEXT-stylable since the any-text wave, but `bg` never
|
|
75
|
+
// joined its vocabulary — a row has no fill of its own.
|
|
76
|
+
expect(bad({ v: 1, objects: { 'contact-email': { style: { bg: '#112233' } } } })).toBe(false)
|
|
77
|
+
expect(bad({ v: 1, objects: { name: { style: { color: 'red;}{' } } } })).toBe(false)
|
|
78
|
+
expect(bad({ v: 1, objects: { name: { style: { color: '#12345' } } } })).toBe(false)
|
|
79
|
+
expect(bad({ v: 1, objects: { avatar: { style: { borderPx: 99 } } } })).toBe(false)
|
|
80
|
+
expect(bad({ v: 1, objects: { avatar: { style: { crop: { x: 0.5, y: 0.5, zoom: 9 } } } } })).toBe(false)
|
|
81
|
+
expect(bad({ v: 1, objects: { 'cta-0': { style: { bg: 'url(javascript:1)' } } } })).toBe(false)
|
|
82
|
+
expect(bad({ v: 1, objects: { name: { style: { fontKey: '../evil' } } } })).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('objects: the override cap and the distinct-font cap', () => {
|
|
86
|
+
const many = Object.fromEntries(
|
|
87
|
+
Array.from({ length: 65 }, (_, i) => [`ft-over${String(i).padStart(4, '0')}`, { style: {} }]),
|
|
88
|
+
)
|
|
89
|
+
expect(bad({ v: 1, objects: many })).toBe(false)
|
|
90
|
+
|
|
91
|
+
const fonts = Object.fromEntries(
|
|
92
|
+
['name', 'role', 'bio', 'ft-aaaa', 'ft-bbbb'].map((id, i) => [
|
|
93
|
+
id,
|
|
94
|
+
{ style: { fontKey: `font-${i}` } },
|
|
95
|
+
]),
|
|
96
|
+
)
|
|
97
|
+
expect(bad({ v: 1, objects: fonts })).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('ownedObjects: caps, duplicate ids, non-hosting sections, dissolving text', () => {
|
|
101
|
+
const ft = (id: string, section = 'bio', text = 'ok') => ({ id, kind: 'freeText', section, text })
|
|
102
|
+
expect(bad({ v: 1, ownedObjects: Array.from({ length: 6 }, (_, i) => ft(`ft-num${i}x`)) })).toBe(false)
|
|
103
|
+
expect(bad({ v: 1, ownedObjects: [ft('ft-abcd'), ft('ft-abcd')] })).toBe(false)
|
|
104
|
+
expect(bad({ v: 1, ownedObjects: [ft('ft-abcd', 'contact')] })).toBe(false)
|
|
105
|
+
expect(bad({ v: 1, ownedObjects: [ft('ft-abcd', 'bio', '<b></b>')] })).toBe(false)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('freeText text is sanitised, not just bounded', () => {
|
|
109
|
+
const r = profileLayoutSchema.parse({
|
|
110
|
+
v: 1,
|
|
111
|
+
ownedObjects: [
|
|
112
|
+
{ id: 'ft-abcd', kind: 'freeText', section: 'bio', text: ' <b>Szia</b> világ ' },
|
|
113
|
+
],
|
|
114
|
+
})
|
|
115
|
+
expect(r.ownedObjects?.[0].text).toBe('Szia világ')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('the any-text wave: per-kind style vocabulary (kind × prop matrix)', () => {
|
|
120
|
+
const style = (id: string, s: Record<string, unknown>) =>
|
|
121
|
+
profileLayoutSchema.safeParse({ v: 1, objects: { [id]: { style: s } } }).success
|
|
122
|
+
|
|
123
|
+
it('rows (contactRow / socialLink) take the text vocabulary minus align/sizeStep', () => {
|
|
124
|
+
const full = {
|
|
125
|
+
color: '#112233',
|
|
126
|
+
weight: 'bold',
|
|
127
|
+
italic: true,
|
|
128
|
+
underline: true,
|
|
129
|
+
fontKey: 'inter',
|
|
130
|
+
}
|
|
131
|
+
expect(style('contact-email', full)).toBe(true)
|
|
132
|
+
expect(style('social-instagram', full)).toBe(true)
|
|
133
|
+
// Row alignment is the template's grid — align stays refused. And a row
|
|
134
|
+
// is a compound (label/value spans with own px sizes), so sizeStep has
|
|
135
|
+
// nothing honest to render — refused rather than stored dead.
|
|
136
|
+
expect(style('contact-email', { align: 'center' })).toBe(false)
|
|
137
|
+
expect(style('social-instagram', { align: 'start' })).toBe(false)
|
|
138
|
+
expect(style('contact-email', { sizeStep: 'sm' })).toBe(false)
|
|
139
|
+
expect(style('social-instagram', { sizeStep: 'lg' })).toBe(false)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('buttons take label styling; geometry props stay out', () => {
|
|
143
|
+
const label = { color: '#112233', weight: 'bold', italic: true, fontKey: 'inter' }
|
|
144
|
+
expect(style('cta-0', { ...label, bg: '#16a34a' })).toBe(true)
|
|
145
|
+
expect(style('booking', { ...label, bg: '#16a34a' })).toBe(true)
|
|
146
|
+
// No sizeStep (button geometry is the template's), no underline, no align.
|
|
147
|
+
expect(style('cta-0', { sizeStep: 'lg' })).toBe(false)
|
|
148
|
+
expect(style('cta-0', { underline: true })).toBe(false)
|
|
149
|
+
expect(style('booking', { align: 'center' })).toBe(false)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('🔴 variant is GONE for every button kind — no surface could ever write it', () => {
|
|
153
|
+
expect(style('act-share', { color: '#112233', bg: '#16a34a', weight: 'bold' })).toBe(true)
|
|
154
|
+
for (const id of ['cta-0', 'booking', 'act-share']) {
|
|
155
|
+
expect(style(id, { variant: 'solid' })).toBe(false)
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('🔴 the vocabulary is TOTAL: every kind dispatches to a schema', () => {
|
|
160
|
+
// Driven off PROFILE_OBJECT_KINDS, not a hardcoded list — a NINTH kind
|
|
161
|
+
// added to types without a row here fails THIS line, not a production
|
|
162
|
+
// render. (tsc would catch it too, but tsc does not run on the publish
|
|
163
|
+
// path — validation 0.24.0 shipped while tsc was red.)
|
|
164
|
+
for (const kind of PROFILE_OBJECT_KINDS) {
|
|
165
|
+
expect(STYLE_SCHEMA_FOR_KIND[kind], `no style schema for kind '${kind}'`).toBeDefined()
|
|
166
|
+
}
|
|
167
|
+
// And through the real dispatch: one id per kind; `{}` is the least
|
|
168
|
+
// style. (The empty-style accept is also the additive guarantee: old
|
|
169
|
+
// documents keep parsing as vocabularies widen.)
|
|
170
|
+
const representative: Record<string, string> = {
|
|
171
|
+
text: 'name',
|
|
172
|
+
freeText: 'ft-abcd',
|
|
173
|
+
contactRow: 'contact-email',
|
|
174
|
+
socialLink: 'social-instagram',
|
|
175
|
+
image: 'avatar',
|
|
176
|
+
ctaButton: 'cta-0',
|
|
177
|
+
linkOut: 'booking',
|
|
178
|
+
action: 'act-share',
|
|
179
|
+
}
|
|
180
|
+
for (const kind of PROFILE_OBJECT_KINDS) {
|
|
181
|
+
const id = representative[kind]
|
|
182
|
+
expect(id, `no representative id for kind '${kind}' — extend this test`).toBeDefined()
|
|
183
|
+
expect(style(id, {})).toBe(true)
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('🔴 a kind the table misses is a REFUSAL, never a throw', () => {
|
|
188
|
+
// The cross-package skew case: a types vintage that knows a kind this
|
|
189
|
+
// validation build does not. The guard must add an issue — an exception
|
|
190
|
+
// in superRefine ESCAPES safeParse (zod 3.25), and safeProfileLayout's
|
|
191
|
+
// "the public page never 500s over decoration" contract dies with it.
|
|
192
|
+
const stolen = STYLE_SCHEMA_FOR_KIND.action
|
|
193
|
+
delete (STYLE_SCHEMA_FOR_KIND as Partial<typeof STYLE_SCHEMA_FOR_KIND>).action
|
|
194
|
+
try {
|
|
195
|
+
const r = profileLayoutSchema.safeParse({
|
|
196
|
+
v: 1,
|
|
197
|
+
objects: { 'act-share': { style: { weight: 'bold' } } },
|
|
198
|
+
})
|
|
199
|
+
expect(r.success).toBe(false)
|
|
200
|
+
// A style-LESS override on the unknown kind keeps the old accept.
|
|
201
|
+
expect(
|
|
202
|
+
profileLayoutSchema.safeParse({ v: 1, objects: { 'act-share': {} } }).success,
|
|
203
|
+
).toBe(true)
|
|
204
|
+
} finally {
|
|
205
|
+
STYLE_SCHEMA_FOR_KIND.action = stolen
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('the distinct-font cap counts row and button fontKeys too', () => {
|
|
210
|
+
const fonts = Object.fromEntries(
|
|
211
|
+
['name', 'contact-email', 'social-instagram', 'cta-0', 'booking'].map((id, i) => [
|
|
212
|
+
id,
|
|
213
|
+
{ style: { fontKey: `font-${i}` } },
|
|
214
|
+
]),
|
|
215
|
+
)
|
|
216
|
+
expect(profileLayoutSchema.safeParse({ v: 1, objects: fonts }).success).toBe(false)
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
describe('digitalConfigSchema integration', () => {
|
|
221
|
+
const baseConfig = {
|
|
222
|
+
templateId: 'classic',
|
|
223
|
+
accentColor: '#16a34a',
|
|
224
|
+
fontKey: 'inter',
|
|
225
|
+
showAvatar: true,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
it('validates existing profiles unchanged (no layout key)', () => {
|
|
229
|
+
expect(digitalConfigSchema.safeParse(baseConfig).success).toBe(true)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('accepts and KEEPS a valid layout — the strip-mode guard the web store needs', () => {
|
|
233
|
+
const r = digitalConfigSchema.parse({ ...baseConfig, layout: okLayout })
|
|
234
|
+
expect(r.layout?.objectOrder?.social).toEqual(['social-x', 'social-linkedin'])
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('rejects a config whose layout is malformed rather than silently dropping it', () => {
|
|
238
|
+
expect(digitalConfigSchema.safeParse({ ...baseConfig, layout: { v: 7 } }).success).toBe(false)
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
describe('safeProfileLayout (render-side gate)', () => {
|
|
243
|
+
it('null/undefined/garbage → null; valid → data', () => {
|
|
244
|
+
expect(safeProfileLayout(undefined)).toBeNull()
|
|
245
|
+
expect(safeProfileLayout(null)).toBeNull()
|
|
246
|
+
expect(safeProfileLayout('layout')).toBeNull()
|
|
247
|
+
expect(safeProfileLayout({ v: 2 })).toBeNull()
|
|
248
|
+
expect(safeProfileLayout({ v: 1 })).toEqual({ v: 1 })
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
describe('cross-package constants stay in lockstep', () => {
|
|
253
|
+
it('CANONICAL_OBJECT_ORDER.cta carries exactly MAX_CTA_BUTTONS cta slots', () => {
|
|
254
|
+
// types cannot import validation, so the canonical array hard-codes five
|
|
255
|
+
// slots; this is the test that keeps the two declarations honest.
|
|
256
|
+
const ctaSlots = CANONICAL_OBJECT_ORDER.cta.filter((id) => ctaObjectIndex(id) !== null)
|
|
257
|
+
expect(ctaSlots.length).toBe(MAX_CTA_BUTTONS)
|
|
258
|
+
})
|
|
259
|
+
})
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NFCARD-656 — the write-side schema for `DigitalConfig.layout`, the stored
|
|
3
|
+
* object/section document. The vocabulary (section ids, object ids, kinds,
|
|
4
|
+
* caps) lives in @nfcard/types; this file only decides what a WRITE may
|
|
5
|
+
* contain.
|
|
6
|
+
*
|
|
7
|
+
* Posture, and why it differs between the two order surfaces:
|
|
8
|
+
* · `objects` (style overrides) is STRICT — every key must name an object
|
|
9
|
+
* this schema's vintage knows, because styles are rendered into CSS and
|
|
10
|
+
* an un-dispatchable style cannot be validated per kind. Version skew is
|
|
11
|
+
* covered by the deploy order (api ships before web, so the accepting
|
|
12
|
+
* schema is never older than the writing client).
|
|
13
|
+
* · `sectionOrder` / `objectOrder` entries are validated by SHAPE only
|
|
14
|
+
* (charset + length). An order entry naming nothing is harmless by
|
|
15
|
+
* construction — `resolveObjectOrder` filters to what is present at
|
|
16
|
+
* render — and rejecting it would turn "the user once ordered a thing
|
|
17
|
+
* that later disappeared" into a 400 on an unrelated save.
|
|
18
|
+
*
|
|
19
|
+
* ⚠ NOT the XSS boundary. FreeText is rendered through Nunjucks with
|
|
20
|
+
* autoescape ON (FOXHOLE-587); the hygiene here (tag/control strip, caps)
|
|
21
|
+
* is defense-in-depth, same standing as the rest of @nfcard/validation.
|
|
22
|
+
*/
|
|
23
|
+
import { z } from 'zod'
|
|
24
|
+
import {
|
|
25
|
+
MAX_DISTINCT_OBJECT_FONTS,
|
|
26
|
+
MAX_FREE_TEXT_LENGTH,
|
|
27
|
+
MAX_LAYOUT_OBJECT_OVERRIDES,
|
|
28
|
+
MAX_OBJECT_ORDER_LENGTH,
|
|
29
|
+
MAX_OWNED_OBJECTS,
|
|
30
|
+
FREE_TEXT_ID_RE,
|
|
31
|
+
PROFILE_SECTIONS,
|
|
32
|
+
PROFILE_SECTION_IDS,
|
|
33
|
+
objectKindOf,
|
|
34
|
+
type ProfileLayout,
|
|
35
|
+
type ProfileObjectKind,
|
|
36
|
+
type ProfileSectionId,
|
|
37
|
+
} from '@nfcard/types'
|
|
38
|
+
// ⚠ Deliberate import cycle (index.ts imports this file for
|
|
39
|
+
// `digitalConfigSchema.layout`). It is init-safe: `sanitizeText` is a
|
|
40
|
+
// hoisted function DECLARATION referenced only inside parse-time closures,
|
|
41
|
+
// never called while modules initialise. Importing it beats re-implementing
|
|
42
|
+
// it — a second, weaker copy of a sanitizer is the recurring 624/626/627
|
|
43
|
+
// failure mode.
|
|
44
|
+
import { sanitizeText } from './index'
|
|
45
|
+
|
|
46
|
+
const HEX_RE = /^#[0-9A-Fa-f]{6}$/
|
|
47
|
+
|
|
48
|
+
/** Order entries and override keys share the token grammar's id budget. */
|
|
49
|
+
const OBJECT_ID_SHAPE_RE = /^[A-Za-z0-9_-]{1,32}$/
|
|
50
|
+
|
|
51
|
+
export const textObjectStyleSchema = z
|
|
52
|
+
.object({
|
|
53
|
+
color: z.string().regex(HEX_RE).optional(),
|
|
54
|
+
sizeStep: z.enum(['sm', 'md', 'lg']).optional(),
|
|
55
|
+
align: z.enum(['start', 'center']).optional(),
|
|
56
|
+
weight: z.enum(['normal', 'bold']).optional(),
|
|
57
|
+
italic: z.boolean().optional(),
|
|
58
|
+
underline: z.boolean().optional(),
|
|
59
|
+
// Resolved through the api's font map at render; an unknown key falls
|
|
60
|
+
// back to the template pairing, so membership is not validated here.
|
|
61
|
+
fontKey: z
|
|
62
|
+
.string()
|
|
63
|
+
.min(1)
|
|
64
|
+
.max(64)
|
|
65
|
+
.regex(/^[A-Za-z0-9-]+$/)
|
|
66
|
+
.optional(),
|
|
67
|
+
})
|
|
68
|
+
.strict()
|
|
69
|
+
|
|
70
|
+
export const imageObjectStyleSchema = z
|
|
71
|
+
.object({
|
|
72
|
+
crop: z
|
|
73
|
+
.object({
|
|
74
|
+
x: z.number().min(0).max(1),
|
|
75
|
+
y: z.number().min(0).max(1),
|
|
76
|
+
zoom: z.number().min(1).max(3),
|
|
77
|
+
})
|
|
78
|
+
.strict()
|
|
79
|
+
.optional(),
|
|
80
|
+
borderColor: z.string().regex(HEX_RE).optional(),
|
|
81
|
+
borderPx: z.number().int().min(0).max(8).optional(),
|
|
82
|
+
shape: z.enum(['circle', 'rounded', 'square']).optional(),
|
|
83
|
+
})
|
|
84
|
+
.strict()
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Contact rows and social chips: text minus `align` and `sizeStep`. Row
|
|
88
|
+
* alignment is the template's grid; and a row is a COMPOUND (label/value
|
|
89
|
+
* spans with their own px sizes), so no wrapper-level em can scale it
|
|
90
|
+
* without destroying the internal hierarchy — either would be a
|
|
91
|
+
* stored-but-dead field.
|
|
92
|
+
*/
|
|
93
|
+
export const rowTextStyleSchema = textObjectStyleSchema.omit({
|
|
94
|
+
align: true,
|
|
95
|
+
sizeStep: true,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
/** Label styling (the text vocabulary's `color`/`weight`/`italic`/`fontKey`,
|
|
99
|
+
* DERIVED so the validators stay in lockstep) + `bg` fill. No `sizeStep`
|
|
100
|
+
* in v1 — button geometry is the template's. `variant` is GONE (0.24.0
|
|
101
|
+
* accepted it and the render did style it): NO SURFACE COULD EVER WRITE IT
|
|
102
|
+
* — the toolbar exposes no variant control and none is planned — and a
|
|
103
|
+
* stored-but-unwritable field is dead weight on every future vintage. No
|
|
104
|
+
* deployed tag ever accepted one, so nothing stored carries it. */
|
|
105
|
+
export const buttonObjectStyleSchema = textObjectStyleSchema
|
|
106
|
+
.pick({ color: true, weight: true, italic: true, fontKey: true })
|
|
107
|
+
.extend({ bg: z.string().regex(HEX_RE).optional() })
|
|
108
|
+
.strict()
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Which style vocabulary each kind accepts — TOTAL over the kind vocabulary
|
|
112
|
+
* since the any-text wave, so "unknown kind" can only mean an id this
|
|
113
|
+
* schema's vintage cannot dispatch (already refused by `objectKindOf`).
|
|
114
|
+
*
|
|
115
|
+
* EXPORTED for the web toolbar: `toolbarControlsFor` derives its control
|
|
116
|
+
* matrix from these shapes instead of hand-mirroring them, so a vocabulary
|
|
117
|
+
* change here reshapes the UI by construction.
|
|
118
|
+
*/
|
|
119
|
+
export const STYLE_SCHEMA_FOR_KIND: Record<
|
|
120
|
+
ProfileObjectKind,
|
|
121
|
+
z.ZodObject<z.ZodRawShape>
|
|
122
|
+
> = {
|
|
123
|
+
text: textObjectStyleSchema,
|
|
124
|
+
freeText: textObjectStyleSchema,
|
|
125
|
+
contactRow: rowTextStyleSchema,
|
|
126
|
+
socialLink: rowTextStyleSchema,
|
|
127
|
+
image: imageObjectStyleSchema,
|
|
128
|
+
ctaButton: buttonObjectStyleSchema,
|
|
129
|
+
linkOut: buttonObjectStyleSchema,
|
|
130
|
+
action: buttonObjectStyleSchema,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const orderListSchema = z
|
|
134
|
+
.array(z.string().regex(OBJECT_ID_SHAPE_RE))
|
|
135
|
+
.max(MAX_OBJECT_ORDER_LENGTH)
|
|
136
|
+
|
|
137
|
+
const freeTextObjectSchema = z
|
|
138
|
+
.object({
|
|
139
|
+
id: z.string().regex(FREE_TEXT_ID_RE),
|
|
140
|
+
kind: z.literal('freeText'),
|
|
141
|
+
section: z.enum(PROFILE_SECTION_IDS),
|
|
142
|
+
text: z
|
|
143
|
+
.string()
|
|
144
|
+
.max(2000) // raw guard; the transform caps to the real bound below
|
|
145
|
+
.transform((v) => sanitizeText(v, MAX_FREE_TEXT_LENGTH)),
|
|
146
|
+
})
|
|
147
|
+
.strict()
|
|
148
|
+
|
|
149
|
+
export const profileLayoutSchema = z
|
|
150
|
+
.object({
|
|
151
|
+
v: z.literal(1),
|
|
152
|
+
sectionOrder: z
|
|
153
|
+
.array(z.enum(PROFILE_SECTION_IDS))
|
|
154
|
+
.max(PROFILE_SECTION_IDS.length)
|
|
155
|
+
.optional(),
|
|
156
|
+
objectOrder: z
|
|
157
|
+
.object(
|
|
158
|
+
Object.fromEntries(
|
|
159
|
+
PROFILE_SECTION_IDS.map((id) => [id, orderListSchema.optional()]),
|
|
160
|
+
) as Record<ProfileSectionId, z.ZodOptional<typeof orderListSchema>>,
|
|
161
|
+
)
|
|
162
|
+
.strict()
|
|
163
|
+
.partial()
|
|
164
|
+
.optional(),
|
|
165
|
+
objects: z.record(z.string().regex(OBJECT_ID_SHAPE_RE), z.unknown()).optional(),
|
|
166
|
+
ownedObjects: z.array(freeTextObjectSchema).max(MAX_OWNED_OBJECTS).optional(),
|
|
167
|
+
})
|
|
168
|
+
.strict()
|
|
169
|
+
.superRefine((layout, ctx) => {
|
|
170
|
+
// A duplicated section is a client bug — loud beats silently reordered.
|
|
171
|
+
if (layout.sectionOrder) {
|
|
172
|
+
const seen = new Set(layout.sectionOrder)
|
|
173
|
+
if (seen.size !== layout.sectionOrder.length) {
|
|
174
|
+
ctx.addIssue({
|
|
175
|
+
code: z.ZodIssueCode.custom,
|
|
176
|
+
path: ['sectionOrder'],
|
|
177
|
+
message: 'duplicate section id',
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (layout.objects) {
|
|
183
|
+
const keys = Object.keys(layout.objects)
|
|
184
|
+
if (keys.length > MAX_LAYOUT_OBJECT_OVERRIDES) {
|
|
185
|
+
ctx.addIssue({
|
|
186
|
+
code: z.ZodIssueCode.custom,
|
|
187
|
+
path: ['objects'],
|
|
188
|
+
message: `more than ${MAX_LAYOUT_OBJECT_OVERRIDES} overrides`,
|
|
189
|
+
})
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
const fonts = new Set<string>()
|
|
193
|
+
for (const key of keys) {
|
|
194
|
+
const kind = objectKindOf(key)
|
|
195
|
+
if (!kind) {
|
|
196
|
+
ctx.addIssue({
|
|
197
|
+
code: z.ZodIssueCode.custom,
|
|
198
|
+
path: ['objects', key],
|
|
199
|
+
message: 'unknown object id',
|
|
200
|
+
})
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
const styleSchema = STYLE_SCHEMA_FOR_KIND[kind]
|
|
204
|
+
const override = layout.objects[key]
|
|
205
|
+
const overrideParsed = z
|
|
206
|
+
.object({ style: z.unknown().optional() })
|
|
207
|
+
.strict()
|
|
208
|
+
.safeParse(override)
|
|
209
|
+
if (!overrideParsed.success) {
|
|
210
|
+
ctx.addIssue({
|
|
211
|
+
code: z.ZodIssueCode.custom,
|
|
212
|
+
path: ['objects', key],
|
|
213
|
+
message: 'override must be { style? }',
|
|
214
|
+
})
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
const style = overrideParsed.data.style
|
|
218
|
+
if (style === undefined) continue
|
|
219
|
+
// The table is TOTAL by type, but tsc does not run on the publish
|
|
220
|
+
// path and `objectKindOf` lives in @nfcard/types behind a ^range —
|
|
221
|
+
// a types vintage that knows a NINTH kind can resolve under a
|
|
222
|
+
// validation build whose table stops at eight. Without this guard
|
|
223
|
+
// that skew is a TypeError that ESCAPES safeParse (verified against
|
|
224
|
+
// zod 3.25), turning `safeProfileLayout`'s promised null into a 500
|
|
225
|
+
// on the public render. Refusal, never a throw.
|
|
226
|
+
if (!styleSchema) {
|
|
227
|
+
ctx.addIssue({
|
|
228
|
+
code: z.ZodIssueCode.custom,
|
|
229
|
+
path: ['objects', key, 'style'],
|
|
230
|
+
message: `kind '${kind}' has no style vocabulary in this build`,
|
|
231
|
+
})
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
const parsed = styleSchema.safeParse(style)
|
|
235
|
+
if (!parsed.success) {
|
|
236
|
+
for (const issue of parsed.error.issues) {
|
|
237
|
+
ctx.addIssue({
|
|
238
|
+
code: z.ZodIssueCode.custom,
|
|
239
|
+
path: ['objects', key, 'style', ...issue.path],
|
|
240
|
+
message: issue.message,
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
const fontKey = (parsed.data as { fontKey?: string }).fontKey
|
|
246
|
+
if (fontKey) fonts.add(fontKey)
|
|
247
|
+
}
|
|
248
|
+
if (fonts.size > MAX_DISTINCT_OBJECT_FONTS) {
|
|
249
|
+
ctx.addIssue({
|
|
250
|
+
code: z.ZodIssueCode.custom,
|
|
251
|
+
path: ['objects'],
|
|
252
|
+
message: `more than ${MAX_DISTINCT_OBJECT_FONTS} distinct fonts`,
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (layout.ownedObjects) {
|
|
258
|
+
const ids = new Set<string>()
|
|
259
|
+
layout.ownedObjects.forEach((obj, i) => {
|
|
260
|
+
if (ids.has(obj.id)) {
|
|
261
|
+
ctx.addIssue({
|
|
262
|
+
code: z.ZodIssueCode.custom,
|
|
263
|
+
path: ['ownedObjects', i, 'id'],
|
|
264
|
+
message: 'duplicate freeText id',
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
ids.add(obj.id)
|
|
268
|
+
if (!PROFILE_SECTIONS[obj.section].hosts.includes('freeText')) {
|
|
269
|
+
ctx.addIssue({
|
|
270
|
+
code: z.ZodIssueCode.custom,
|
|
271
|
+
path: ['ownedObjects', i, 'section'],
|
|
272
|
+
message: `section '${obj.section}' does not host freeText`,
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
if (obj.text.length === 0) {
|
|
276
|
+
// Post-sanitise emptiness: a tags-only submission dissolves here.
|
|
277
|
+
ctx.addIssue({
|
|
278
|
+
code: z.ZodIssueCode.custom,
|
|
279
|
+
path: ['ownedObjects', i, 'text'],
|
|
280
|
+
message: 'empty after sanitisation',
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Render-side gate: a stored layout that no longer parses (a vintage this
|
|
289
|
+
* build predates, a hand-corrupted blob) renders as CANONICAL rather than
|
|
290
|
+
* failing the whole page. Mirrors the lenient posture of
|
|
291
|
+
* `resolveRenderBackground` — the public page never 500s over decoration.
|
|
292
|
+
*/
|
|
293
|
+
export function safeProfileLayout(value: unknown): ProfileLayout | null {
|
|
294
|
+
if (value === undefined || value === null) return null
|
|
295
|
+
const parsed = profileLayoutSchema.safeParse(value)
|
|
296
|
+
return parsed.success ? (parsed.data as ProfileLayout) : null
|
|
297
|
+
}
|