@unitninety/site-kit 0.0.0-stage → 1.6.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 (204) hide show
  1. package/README.md +12 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  5. package/src/bridge/U90Bridge.tsx +54 -0
  6. package/src/bridge/caret.ts +317 -0
  7. package/src/bridge/client.tsx +66 -0
  8. package/src/bridge/createBridge.ts +1010 -0
  9. package/src/bridge/hooks.tsx +56 -0
  10. package/src/bridge/index.ts +9 -0
  11. package/src/bridge/lexical.ts +177 -0
  12. package/src/bridge/lexicalDom.ts +212 -0
  13. package/src/bridge/preserveBlockMoves.ts +88 -0
  14. package/src/bridge/preview.ts +179 -0
  15. package/src/bridge/protocol.ts +271 -0
  16. package/src/bridge/registry.ts +27 -0
  17. package/src/bridge/styles.ts +137 -0
  18. package/src/components/DateText.tsx +9 -0
  19. package/src/components/File.tsx +9 -0
  20. package/src/components/Image.tsx +30 -0
  21. package/src/components/Link.tsx +24 -0
  22. package/src/components/NumberText.tsx +9 -0
  23. package/src/components/RichText.tsx +47 -0
  24. package/src/components/Text.tsx +15 -0
  25. package/src/components/Video.tsx +12 -0
  26. package/src/components/VideoPlayback.tsx +19 -0
  27. package/src/components/draft.ts +9 -0
  28. package/src/components/imageProps.ts +26 -0
  29. package/src/components/index.ts +12 -0
  30. package/src/components/videoProps.ts +6 -0
  31. package/src/elements/containers/group.ts +33 -0
  32. package/src/elements/containers/list.ts +31 -0
  33. package/src/elements/containers/zone.ts +41 -0
  34. package/src/elements/declaration.ts +118 -0
  35. package/src/elements/define.ts +195 -0
  36. package/src/elements/ids.ts +50 -0
  37. package/src/elements/index.ts +35 -0
  38. package/src/elements/jsonSchema.ts +36 -0
  39. package/src/elements/leaf.ts +98 -0
  40. package/src/elements/leaves/boolean.ts +30 -0
  41. package/src/elements/leaves/choice.ts +53 -0
  42. package/src/elements/leaves/date.ts +39 -0
  43. package/src/elements/leaves/file.ts +56 -0
  44. package/src/elements/leaves/image.ts +160 -0
  45. package/src/elements/leaves/link.ts +149 -0
  46. package/src/elements/leaves/number.ts +52 -0
  47. package/src/elements/leaves/query.ts +101 -0
  48. package/src/elements/leaves/relation.ts +69 -0
  49. package/src/elements/leaves/rich-text.ts +155 -0
  50. package/src/elements/leaves/selection.ts +40 -0
  51. package/src/elements/leaves/single.ts +29 -0
  52. package/src/elements/leaves/text.ts +103 -0
  53. package/src/elements/leaves/video.ts +108 -0
  54. package/src/elements/ops/expand.ts +94 -0
  55. package/src/elements/ops/overlay.ts +59 -0
  56. package/src/elements/ops/refs.ts +45 -0
  57. package/src/elements/ops/seed.ts +185 -0
  58. package/src/elements/ops/text.ts +20 -0
  59. package/src/elements/ops/translatable.ts +19 -0
  60. package/src/elements/ops/upgrade.ts +43 -0
  61. package/src/elements/ops/validate.ts +56 -0
  62. package/src/elements/order.ts +10 -0
  63. package/src/elements/path.ts +43 -0
  64. package/src/elements/prefix.ts +2 -0
  65. package/src/elements/registry.ts +48 -0
  66. package/src/elements/tokens.ts +14 -0
  67. package/src/elements/walk.ts +42 -0
  68. package/src/features/client/AskAiProvider.tsx +551 -0
  69. package/src/features/client/AskAiWidget.tsx +222 -0
  70. package/src/features/client/ConsentRuntime.tsx +95 -0
  71. package/src/features/client/FeedbackProvider.tsx +231 -0
  72. package/src/features/client/FeedbackWidget.tsx +101 -0
  73. package/src/features/client/PageReporter.tsx +26 -0
  74. package/src/features/client/README.md +131 -0
  75. package/src/features/client/SiteFeatures.tsx +250 -0
  76. package/src/features/client/askAiRecovery.ts +92 -0
  77. package/src/features/client/askAiStream.ts +59 -0
  78. package/src/features/client/context.tsx +24 -0
  79. package/src/features/client/index.ts +24 -0
  80. package/src/features/client/overlay.tsx +129 -0
  81. package/src/features/client/styles.css +337 -0
  82. package/src/features/client/transport.ts +310 -0
  83. package/src/features/client/useAskAI.ts +45 -0
  84. package/src/features/client/useConsent.ts +15 -0
  85. package/src/features/client/useFeedback.ts +33 -0
  86. package/src/features/client/useSiteSearch.ts +50 -0
  87. package/src/features/client/verification.ts +141 -0
  88. package/src/features/consent/README.md +70 -0
  89. package/src/features/consent/cookieScript.ts +53 -0
  90. package/src/features/consent/custom.ts +89 -0
  91. package/src/features/consent/defaults.ts +40 -0
  92. package/src/features/consent/drivers.ts +233 -0
  93. package/src/features/consent/loader.ts +248 -0
  94. package/src/features/consent/registry.ts +398 -0
  95. package/src/features/contracts/askAi.ts +166 -0
  96. package/src/features/contracts/askAiSettings.ts +42 -0
  97. package/src/features/contracts/base.ts +107 -0
  98. package/src/features/contracts/consent.ts +150 -0
  99. package/src/features/contracts/feedback.ts +22 -0
  100. package/src/features/contracts/feedbackSettings.ts +49 -0
  101. package/src/features/contracts/index.ts +61 -0
  102. package/src/features/contracts/knowledge.ts +99 -0
  103. package/src/features/contracts/knowledgePolicy.ts +249 -0
  104. package/src/features/contracts/search.ts +24 -0
  105. package/src/features/contracts/verification.ts +52 -0
  106. package/src/features/contracts/verificationConfig.ts +9 -0
  107. package/src/features/server/ConsentDefaults.tsx +15 -0
  108. package/src/features/server/README.md +185 -0
  109. package/src/features/server/gateway.ts +318 -0
  110. package/src/features/server/http.ts +179 -0
  111. package/src/features/server/index.ts +6 -0
  112. package/src/features/server/session.ts +23 -0
  113. package/src/features/server/verification.ts +39 -0
  114. package/src/features/server/verifyVisitor.ts +69 -0
  115. package/src/languages/config.d.mts +5 -0
  116. package/src/languages/config.mjs +53 -0
  117. package/src/languages/decide.ts +72 -0
  118. package/src/languages/index.ts +4 -0
  119. package/src/languages/proxy.ts +108 -0
  120. package/src/languages/routing.ts +93 -0
  121. package/src/languages/wire.ts +19 -0
  122. package/src/legacy/bridge.tsx +1987 -0
  123. package/src/legacy/caret.ts +246 -0
  124. package/src/legacy/cli.mjs +189 -0
  125. package/src/legacy/content.ts +993 -0
  126. package/src/legacy/contract.ts +664 -0
  127. package/src/legacy/defineBlocks.ts +1602 -0
  128. package/src/legacy/defineSite.ts +623 -0
  129. package/src/legacy/defineSite.types.ts +270 -0
  130. package/src/legacy/draft.tsx +128 -0
  131. package/src/legacy/image.ts +433 -0
  132. package/src/legacy/index.ts +209 -0
  133. package/src/legacy/language-switcher.tsx +58 -0
  134. package/src/legacy/languageHints.ts +153 -0
  135. package/src/legacy/languages/fields.ts +131 -0
  136. package/src/legacy/languages/hash.ts +98 -0
  137. package/src/legacy/languages/health.ts +97 -0
  138. package/src/legacy/languages/identity.ts +247 -0
  139. package/src/legacy/languages/links.ts +81 -0
  140. package/src/legacy/languages/resolution.ts +56 -0
  141. package/src/legacy/languages/routing.ts +169 -0
  142. package/src/legacy/languages/runtime.ts +216 -0
  143. package/src/legacy/languages/seo.ts +95 -0
  144. package/src/legacy/languages/settings.ts +211 -0
  145. package/src/legacy/languages/types.ts +85 -0
  146. package/src/legacy/languages.ts +8 -0
  147. package/src/legacy/lexical.ts +170 -0
  148. package/src/legacy/lexicalDom.ts +166 -0
  149. package/src/legacy/media.ts +272 -0
  150. package/src/legacy/nav.ts +176 -0
  151. package/src/legacy/preview.ts +79 -0
  152. package/src/legacy/redirects.ts +474 -0
  153. package/src/legacy/richText.tsx +104 -0
  154. package/src/legacy/routes.ts +414 -0
  155. package/src/legacy/seo.ts +327 -0
  156. package/src/legacy/shape.ts +412 -0
  157. package/src/legacy/uploads.ts +233 -0
  158. package/src/legacy/verification.ts +42 -0
  159. package/src/preview-lock/contracts.ts +42 -0
  160. package/src/preview-lock/index.ts +3 -0
  161. package/src/preview-lock/screen.tsx +244 -0
  162. package/src/preview-lock/server.ts +416 -0
  163. package/src/redirects/config.d.mts +5 -0
  164. package/src/redirects/config.mjs +22 -0
  165. package/src/redirects/index.ts +3 -0
  166. package/src/redirects/location.ts +31 -0
  167. package/src/redirects/match.ts +199 -0
  168. package/src/redirects/proxy.ts +91 -0
  169. package/src/redirects/wire.ts +30 -0
  170. package/src/seo/jsonLd.ts +70 -0
  171. package/src/seo/metadata.ts +127 -0
  172. package/src/seo/robots.ts +14 -0
  173. package/src/seo/sitemap.ts +22 -0
  174. package/src/seo/title.ts +22 -0
  175. package/src/server/adapters.ts +73 -0
  176. package/src/server/createU90.ts +96 -0
  177. package/src/server/handlers.ts +164 -0
  178. package/src/server/index.ts +6 -0
  179. package/src/server/read.ts +207 -0
  180. package/src/server/render.tsx +46 -0
  181. package/src/server/routes.tsx +171 -0
  182. package/src/site/assemble.ts +109 -0
  183. package/src/site/defineSite.ts +132 -0
  184. package/src/site/documents.ts +18 -0
  185. package/src/site/fields.ts +59 -0
  186. package/src/site/index.ts +14 -0
  187. package/src/site/replies.ts +118 -0
  188. package/src/site/store.ts +45 -0
  189. package/src/site/types.ts +75 -0
  190. package/src/site/version.ts +1 -0
  191. package/src/site/wire.ts +125 -0
  192. package/src/switcher/LanguageSwitcher.tsx +42 -0
  193. package/src/test-preview/README.md +90 -0
  194. package/src/test-preview/binding.ts +54 -0
  195. package/src/test-preview/bridge.ts +16 -0
  196. package/src/test-preview/guard.ts +186 -0
  197. package/src/test-preview/media.ts +23 -0
  198. package/src/test-preview/protocol.d.mts +67 -0
  199. package/src/test-preview/protocol.mjs +222 -0
  200. package/src/test-preview/runtime.ts +168 -0
  201. package/src/test-preview/server.ts +10 -0
  202. package/src/testing/fake.ts +148 -0
  203. package/src/testing/index.ts +2 -0
  204. package/src/testing/seed.ts +2 -0
@@ -0,0 +1,50 @@
1
+ const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
2
+ const DIGITS = '0123456789'
3
+
4
+ /**
5
+ * `n` characters from `alphabet`, unbiased: a byte at or above the largest multiple of the alphabet's length below 256
6
+ * is redrawn, so every symbol is equally likely. Web Crypto, so the same code runs in Node, browsers and edge runtimes. */
7
+ const draw = (n: number, alphabet: string): string => {
8
+ const limit = 256 - (256 % alphabet.length)
9
+ let out = ''
10
+ while (out.length < n) {
11
+ for (const byte of globalThis.crypto.getRandomValues(new Uint8Array(n * 2))) {
12
+ if (byte >= limit) continue
13
+ out += alphabet[byte % alphabet.length]
14
+ if (out.length === n) break
15
+ }
16
+ }
17
+ return out
18
+ }
19
+
20
+ /** A stored `_id`: a list item or a block. 1 to 16 lower-case letters and digits, so ids stored before the 12-character rule still read. */
21
+ export const ID_RE = /^[a-z0-9]{1,16}$/
22
+ /** 12 random characters (about 62 bits): an `_id`, minted by the platform. */
23
+ export const newId = (): string => draw(12, ALPHABET)
24
+
25
+ /**
26
+ * A page's, an entry's, a layout's, a Single's or a code draft's id: 16 characters that start with a digit and hold a
27
+ * letter. A declared key starts with a letter, so the two never look alike; and an id is never all digits, so nothing
28
+ * that reads a numeric string as a number can turn one into a number.
29
+ */
30
+ export const DOCUMENT_ID_RE = /^(?=.*[a-z])[0-9][a-z0-9]{15}$/
31
+ export const isDocumentId = (value: unknown): value is string => typeof value === 'string' && DOCUMENT_ID_RE.test(value)
32
+ export const newDocumentId = (): string => {
33
+ for (;;) {
34
+ const id = draw(1, DIGITS) + draw(15, ALPHABET)
35
+ if (DOCUMENT_ID_RE.test(id)) return id
36
+ }
37
+ }
38
+
39
+ export const ELEMENT_IDS = [
40
+ 'u90/text', 'u90/rich-text', 'u90/image', 'u90/video', 'u90/file', 'u90/link',
41
+ 'u90/date', 'u90/number', 'u90/boolean', 'u90/choice',
42
+ 'u90/relation', 'u90/selection', 'u90/query', 'u90/single',
43
+ ] as const
44
+ export type ElementId = (typeof ELEMENT_IDS)[number]
45
+
46
+ export const CONTAINER_IDS = ['u90/group', 'u90/list', 'u90/zone'] as const
47
+ export type ContainerId = (typeof CONTAINER_IDS)[number]
48
+
49
+ /** `u90/rich-text` → `rich-text`; used for fixture folders and docs anchors. */
50
+ export const shortId = (id: ElementId | ContainerId) => id.slice('u90/'.length)
@@ -0,0 +1,35 @@
1
+ export const MODULE = '@unitninety/site-kit/elements'
2
+ export * from './ids'
3
+ export * from './define'
4
+ export { path, newId, ID_RE, type Path, type DocKind } from './path'
5
+ export { orderedKeys, orderedEntries } from './order'
6
+ export * from './tokens'
7
+ export { validateLeaf, expandLeaf, parseOptions, zodIssues, hasStoredShape } from './leaf'
8
+ export { text, TextOptions, type TextRead, type TextSegment } from './leaves/text'
9
+ export { boolean, BooleanOptions, type BooleanRead } from './leaves/boolean'
10
+ export { number, NumberOptions, type NumberRead } from './leaves/number'
11
+ export { date, DateOptions, type DateRead } from './leaves/date'
12
+ export { choice, ChoiceOptions, type ChoiceRead, type ChoiceReadValue } from './leaves/choice'
13
+ export { link, LinkOptions, LinkTo, linkKind, hrefOf, DRAFT_LOCALE_PARAM, type LinkRead, type LinkKind } from './leaves/link'
14
+ export { single, SingleOptions } from './leaves/single'
15
+ export { richText, RichTextOptions, richTextToPlain, type RichTextRead } from './leaves/rich-text'
16
+ export { image, ImageOptions, ImageConstraints, imageStored, imageSeed, expandImage, imageToStored, type ImageRead, type ImageStored, type ImageSeed, type ImageReadVariant, type ImageAsset } from './leaves/image'
17
+ export { video, VideoOptions, mediaAssetRead, type VideoRead, type MediaAssetRead } from './leaves/video'
18
+ export { file, FileOptions, type FileRead } from './leaves/file'
19
+ export { relation, RelationOptions, isDerived, EntryRef, resolveEntryRef } from './leaves/relation'
20
+ export { selection, SelectionOptions, type EntriesRead } from './leaves/selection'
21
+ export { query, QueryOptions, QueryStored, applyQueryPicks, FILTERABLE, SORTABLE } from './leaves/query'
22
+ export { elements, getElement, CONTRACTS, CURRENT_CONTRACT } from './registry'
23
+ export { validateSchema, FieldDeclSchema, BlockDeclSchema, CollectionDeclSchema, SingleDeclSchema, SchemaSchema } from './declaration'
24
+ export { containers, getContainer } from './registry'
25
+ export { walk, walkDocument, type Visitor, type LeafNode, type ContainerNode } from './walk'
26
+ export { validate, type ValidateOptions } from './ops/validate'
27
+ export { collectRefs } from './ops/refs'
28
+ export { expand, expandField, makeCtx, entryKey, queryKey, inverseKey, type Resolved } from './ops/expand'
29
+ export { prefixedRoute } from './prefix'
30
+ export { seed, seedItem, seedIssues, SeedError, type SeedKnown } from './ops/seed'
31
+ export { textOf } from './ops/text'
32
+ export { translatableParts } from './ops/translatable'
33
+ export { overlayKey, translatableLeaves, applyOverlay, type OverlayValues } from './ops/overlay'
34
+ export { upgrade, UpgradeError, type UpgradeOptions } from './ops/upgrade'
35
+ export { toJSONSchema } from './jsonSchema'
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod'
2
+ import { isLeaf, type FieldDecl, type Schema } from './define'
3
+ import { orderedEntries } from './order'
4
+ import { ID_RE } from './path'
5
+ import { getElement } from './registry'
6
+
7
+ type JsonSchema = Record<string, unknown>
8
+ const id = { type: 'string', pattern: ID_RE.source }
9
+
10
+ /** JSON Schema (draft 2020-12) of a declaration's STORED shape. Used for agent tool schemas and docs. */
11
+ export function toJSONSchema(decl: FieldDecl, schema?: Schema): JsonSchema {
12
+ if (isLeaf(decl)) {
13
+ const entry = getElement(decl.element)
14
+ const { $schema: _s, ...rest } = z.toJSONSchema(entry.stored(entry.options.parse(decl.options ?? {}))) as JsonSchema
15
+ return { ...rest, ...(decl.label ? { title: decl.label } : {}), ...(decl.description ? { description: decl.description } : {}) }
16
+ }
17
+ if (decl.container === 'u90/group')
18
+ return { type: 'object', properties: Object.fromEntries(orderedEntries(decl.fields, decl.order).map(([n, f]) => [n, toJSONSchema(f, schema)])), additionalProperties: false }
19
+ if (decl.container === 'u90/list') {
20
+ // validateSchema rejects a list item that isn't an element or a group, so only those two shapes reach here.
21
+ let item: JsonSchema
22
+ if (isLeaf(decl.item)) {
23
+ item = { type: 'object', properties: { _id: id, _value: toJSONSchema(decl.item, schema) }, required: ['_id', '_value'], additionalProperties: false }
24
+ } else {
25
+ const inner = toJSONSchema(decl.item, schema)
26
+ item = { type: 'object', properties: { _id: id, ...(inner.properties as object) }, required: ['_id'], additionalProperties: false }
27
+ }
28
+ return { type: 'array', items: item, ...(decl.min !== undefined ? { minItems: decl.min } : {}), ...(decl.max !== undefined ? { maxItems: decl.max } : {}) }
29
+ }
30
+ const blocks = decl.allow.map((name) => {
31
+ const block = schema?.blocks[name]
32
+ const fields = block ? (toJSONSchema({ container: 'u90/group', fields: block.fields, order: block.order }, schema).properties as object) : {}
33
+ return { type: 'object', properties: { _id: id, _block: { const: name }, ...fields }, required: ['_id', '_block'], additionalProperties: false }
34
+ })
35
+ return { type: 'array', items: { oneOf: blocks } }
36
+ }
@@ -0,0 +1,98 @@
1
+ import type { ZodError } from 'zod'
2
+ import { overlayKey } from './path'
3
+ import type { ElementEntry, ExpandCtx, FieldMeta, LeafDecl, LeafIssue, Mode } from './define'
4
+
5
+ /** zod issues → LeafIssue. A `params.code` set by an element's own check wins over zod's generic code. */
6
+ export const zodIssues = (error: ZodError): LeafIssue[] =>
7
+ error.issues.map((i) => ({
8
+ path: i.path as (string | number)[],
9
+ code: (i as { params?: { code?: string } }).params?.code ?? i.code,
10
+ message: i.message,
11
+ }))
12
+
13
+ export function parseOptions(entry: ElementEntry, decl: LeafDecl) {
14
+ const r = entry.options.safeParse(decl.options ?? {})
15
+ return r.success
16
+ ? ({ ok: true, options: r.data } as const)
17
+ : ({ ok: false, issues: zodIssues(r.error).map((i) => ({ ...i, path: ['options', ...i.path] })) } as const)
18
+ }
19
+
20
+ /**
21
+ * zod codes meaning a value does not have its element's stored shape; any other failure (a length,
22
+ * a pattern, a step, a custom rule) leaves the shape intact, so the value can still be read. The
23
+ * check parses with the element's `shape` where it declares one, else with `stored`, and a failure
24
+ * with one of these codes counts whichever raised it, an element's own refinement included (a
25
+ * query's value-shape rules raise `invalid_type`). Under `stored`, an option that becomes an enum,
26
+ * a union or an integer rule would turn narrowing it into a shape failure and blank old content, so
27
+ * rich text, number and choice declare a `shape` without those rules: their content written before
28
+ * a declaration narrowed keeps rendering while `validate` reports it. `invalid_format` is a shape
29
+ * code too: a value whose format *is* its type (an ISO date, a link's `url`/`email`/`tel`
30
+ * destination) has no usable shape once the format fails. Text's own format rules (`format: 'email'`
31
+ * etc.) are checked as custom issues, not `invalid_format`, so a bad-format text still reads.
32
+ */
33
+ const SHAPE_CODES = new Set(['invalid_type', 'invalid_union', 'invalid_value', 'invalid_key', 'invalid_element', 'unrecognized_keys', 'invalid_format'])
34
+ /**
35
+ * Whether `value` has the stored shape its element's operations assume. `refs`, `expand`, `toText`
36
+ * and `translatable` only ever see a value that passes, so a malformed stored value is reported by
37
+ * `validate`, reads as the element's empty value, and never throws inside an operation.
38
+ */
39
+ export const hasStoredShape = (entry: ElementEntry, options: unknown, value: unknown): boolean => {
40
+ const r = (entry.shape ? entry.shape(options) : entry.stored(options)).safeParse(value)
41
+ return r.success || r.error.issues.every((i) => !SHAPE_CODES.has(i.code))
42
+ }
43
+
44
+ /**
45
+ * A default stands in only for an absent value (an explicit `null` stays `null`), so only then can it
46
+ * satisfy `required`, and only when the default is itself complete. A blank default (`''`, a rich text
47
+ * with no words, a link with no label) satisfies nothing, so a required field declared with one still
48
+ * asks for a value before publishing.
49
+ */
50
+ const completeDefault = (entry: ElementEntry, options: unknown, value: unknown): boolean => {
51
+ if (value === undefined || value === null) return false
52
+ const r = entry.stored(options).safeParse(value)
53
+ if (r.success) return entry.complete(r.data, options, { required: true, hasDefault: false }).length === 0
54
+ // A seed shape the stored schema does not take (an image's `{ source }`, an entry `$ref`): only a blank string or list is blank.
55
+ return !(typeof value === 'string' && value.trim() === '') && !(Array.isArray(value) && value.length === 0)
56
+ }
57
+ const meta = (entry: ElementEntry, decl: LeafDecl, options: unknown, absent: boolean): FieldMeta => ({
58
+ required: decl.required === true,
59
+ hasDefault: absent && decl.default !== undefined && completeDefault(entry, options, decl.default),
60
+ })
61
+
62
+ /**
63
+ * Strict type check in every mode; `complete` only when publishing.
64
+ * `undefined` means the field is absent from storage (it reads as its default), so only completeness applies.
65
+ */
66
+ export function validateLeaf(entry: ElementEntry, decl: LeafDecl, value: unknown, mode: Mode): LeafIssue[] {
67
+ const o = parseOptions(entry, decl)
68
+ if (!o.ok) return o.issues
69
+ if (value === undefined) return mode === 'publish' ? entry.complete(undefined, o.options, meta(entry, decl, o.options, true)) : []
70
+ const r = entry.stored(o.options).safeParse(value)
71
+ if (!r.success) return zodIssues(r.error)
72
+ return mode === 'publish' ? entry.complete(r.data, o.options, meta(entry, decl, o.options, false)) : []
73
+ }
74
+
75
+ /**
76
+ * Read shape of a stored value. An absent value (`undefined`) reads as the declared default or `empty`.
77
+ * A stored `null` is handed to the element's `expand`, which decides what it means: nullable elements
78
+ * return `null`, and `u90/single` / the derived side of `u90/relation` resolve it. A value without the
79
+ * stored shape, or a declaration whose options or default do not parse, reads as the empty value.
80
+ */
81
+ export function expandLeaf(entry: ElementEntry, decl: LeafDecl, value: unknown, ctx: ExpandCtx): unknown {
82
+ const read = expandLeafValue(entry, decl, value, ctx)
83
+ // In draft, a leaf the reply lists as a fallback is marked beside its `$path`; a published read never is.
84
+ if (ctx.draft && ctx.fallbacks?.size && read && typeof read === 'object' && typeof (read as { $path?: unknown }).$path === 'string' && ctx.fallbacks.has(overlayKey(ctx.at))) return { ...read, $fallback: true }
85
+ return read
86
+ }
87
+ function expandLeafValue(entry: ElementEntry, decl: LeafDecl, value: unknown, ctx: ExpandCtx): unknown {
88
+ const parsed = entry.options.safeParse(decl.options ?? {})
89
+ if (!parsed.success) return null
90
+ const o = parsed.data
91
+ if (value === undefined) {
92
+ if (decl.default === undefined) return entry.expand(entry.empty(o), o, ctx)
93
+ const seeded = entry.seed(o).safeParse(decl.default)
94
+ return seeded.success ? entry.expandSeed(seeded.data, o, ctx) : entry.expand(entry.empty(o), o, ctx)
95
+ }
96
+ if (value !== null && !hasStoredShape(entry, o, value)) return entry.expand(entry.empty(o), o, ctx)
97
+ return entry.expand(value, o, ctx)
98
+ }
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod'
2
+ import { defineElement, type ExpandCtx } from '../define'
3
+
4
+ export const BooleanOptions = z.strictObject({})
5
+ /** How a boolean reads: its value, and `$path` in draft, so the Canvas can select it (R34). */
6
+ export type BooleanRead = { value: boolean; $path?: string }
7
+ const schema = z.boolean()
8
+ const read = z.object({ value: z.boolean(), $path: z.string().optional() })
9
+ const expand = (v: boolean, _o: unknown, ctx: ExpandCtx): BooleanRead => ({ value: v === true, ...(ctx.draft ? { $path: ctx.at } : {}) })
10
+
11
+ export const boolean = defineElement({
12
+ id: 'u90/boolean',
13
+ version: 1,
14
+ label: 'Boolean',
15
+ placement: 'any',
16
+ options: BooleanOptions,
17
+ seed: () => schema,
18
+ stored: () => schema,
19
+ read: () => read,
20
+ complete: () => [],
21
+ refs: () => [],
22
+ toStored: async (seed) => seed,
23
+ expand,
24
+ expandSeed: expand,
25
+ toText: () => '',
26
+ translatable: () => [],
27
+ translation: null,
28
+ empty: () => false,
29
+ upgrade: {},
30
+ })
@@ -0,0 +1,53 @@
1
+ import { z } from 'zod'
2
+ import { defineElement, requiredIssue } from '../define'
3
+
4
+ export const ChoiceOptions = z.strictObject({
5
+ values: z.array(z.strictObject({ value: z.string().min(1), label: z.string().min(1) })).min(1),
6
+ multiple: z.boolean().default(false),
7
+ })
8
+ type O = z.infer<typeof ChoiceOptions>
9
+ /** One chosen value as it reads. */
10
+ export type ChoiceRead = { value: string; label: string; $path?: string }
11
+ /** What a choice field reads as: one item, a list of them when `multiple`, or `null` when cleared. */
12
+ export type ChoiceReadValue = ChoiceRead | ChoiceRead[] | null
13
+
14
+ const one = (o: O) => z.enum(o.values.map((v) => v.value) as [string, ...string[]])
15
+ const stored = (o: O) =>
16
+ o.multiple
17
+ ? z.array(one(o)).superRefine((vs, ctx) => {
18
+ if (new Set(vs).size !== vs.length)
19
+ ctx.addIssue({ code: 'custom', message: 'Each value may be chosen once.', params: { code: 'duplicate' } })
20
+ })
21
+ : one(o).nullable()
22
+ /** Any string, or a list of them: a value no longer among `values` still reads, labelled by itself. */
23
+ const shape = (o: O) => (o.multiple ? z.array(z.string()) : z.string().nullable())
24
+ const readOne = z.object({ value: z.string(), label: z.string(), $path: z.string().optional() })
25
+ const read = (o: O) => (o.multiple ? z.array(readOne) : readOne.nullable())
26
+ const label = (o: O, v: string) => o.values.find((x) => x.value === v)?.label ?? v
27
+ const expand = (v: string | string[] | null, o: O, ctx: { at: string; draft: boolean }): ChoiceReadValue => {
28
+ const tag = ctx.draft ? { $path: ctx.at } : {}
29
+ if (Array.isArray(v)) return v.map((x) => ({ value: x, label: label(o, x), ...tag }))
30
+ return v === null ? null : { value: v, label: label(o, v), ...tag }
31
+ }
32
+
33
+ export const choice = defineElement({
34
+ id: 'u90/choice',
35
+ version: 1,
36
+ label: 'Choice',
37
+ placement: 'any',
38
+ options: ChoiceOptions,
39
+ seed: stored,
40
+ stored,
41
+ shape,
42
+ read,
43
+ complete: (v, _o, field) => requiredIssue(Array.isArray(v) ? v.length > 0 : typeof v === 'string', field),
44
+ refs: () => [],
45
+ toStored: async (seed) => seed,
46
+ expand,
47
+ expandSeed: expand,
48
+ toText: (v, o) => (Array.isArray(v) ? v.map((x) => label(o, x)).join(', ') : v === null ? '' : label(o, v)),
49
+ translatable: () => [],
50
+ translation: null,
51
+ empty: (o) => (o.multiple ? [] : null),
52
+ upgrade: {},
53
+ })
@@ -0,0 +1,39 @@
1
+ import { z } from 'zod'
2
+ import { defineElement, requiredIssue } from '../define'
3
+
4
+ export const DateOptions = z.strictObject({ min: z.iso.date().optional(), max: z.iso.date().optional() })
5
+ type O = z.infer<typeof DateOptions>
6
+ export type DateRead = { iso: string; $path?: string } | null
7
+
8
+ const stored = (o: O) =>
9
+ z.iso
10
+ .date()
11
+ .superRefine((v, ctx) => {
12
+ if ((o.min && v < o.min) || (o.max && v > o.max))
13
+ ctx.addIssue({ code: 'custom', message: `Must be between ${o.min ?? 'any'} and ${o.max ?? 'any'}.`, params: { code: 'range' } })
14
+ })
15
+ .nullable()
16
+ const read = z.object({ iso: z.iso.date(), $path: z.string().optional() }).nullable()
17
+ const expand = (v: string | null, _o: O, ctx: { at: string; draft: boolean }): DateRead =>
18
+ v === null ? null : { iso: v, ...(ctx.draft ? { $path: ctx.at } : {}) }
19
+
20
+ export const date = defineElement({
21
+ id: 'u90/date',
22
+ version: 1,
23
+ label: 'Date',
24
+ placement: 'any',
25
+ options: DateOptions,
26
+ seed: stored,
27
+ stored,
28
+ read: () => read,
29
+ complete: (v, _o, field) => requiredIssue(typeof v === 'string', field),
30
+ refs: () => [],
31
+ toStored: async (seed) => seed,
32
+ expand,
33
+ expandSeed: expand,
34
+ toText: (v) => v ?? '',
35
+ translatable: () => [],
36
+ translation: null,
37
+ empty: () => null,
38
+ upgrade: {},
39
+ })
@@ -0,0 +1,56 @@
1
+ import { z } from 'zod'
2
+ import { Id, MediaAssetSchema, defineElement, requiredIssue, type ExpandCtx, type SeedCtx } from '../define'
3
+ import { mediaAssetRead, type MediaAssetRead } from './video'
4
+
5
+ export const FileOptions = z.strictObject({
6
+ mimeTypes: z.array(z.string().regex(/^[a-z]+\/[a-z0-9.+-]+$/i)).min(1).optional(),
7
+ maxBytes: z.int().positive().optional(),
8
+ })
9
+ type O = z.infer<typeof FileOptions>
10
+ const stored = () => z.strictObject({ asset: Id, label: z.string(), description: z.string().nullable(), downloadName: z.string().nullable() }).nullable()
11
+ type Stored = z.infer<ReturnType<typeof stored>>
12
+ const seed = () =>
13
+ z.strictObject({ asset: z.union([Id, z.strictObject({ source: z.string().trim().min(1) })]), label: z.string().default(''), description: z.string().nullable().default(null), downloadName: z.string().nullable().default(null) }).nullable()
14
+ export type FileRead = { asset: MediaAssetRead | null; status: 'ready' | 'missing'; label: string; description: string | null; downloadName: string | null; $path?: string } | null
15
+ const read = z.object({ asset: MediaAssetSchema.nullable(), status: z.enum(['ready', 'missing']), label: z.string(), description: z.string().nullable(), downloadName: z.string().nullable(), $path: z.string().optional() }).nullable()
16
+ const expand = (v: Stored, _o: O, ctx: ExpandCtx): FileRead =>
17
+ v === null ? null : { ...mediaAssetRead(ctx.media(v.asset)), label: v.label, description: v.description, downloadName: v.downloadName, ...(ctx.draft ? { $path: ctx.at } : {}) }
18
+
19
+ export const file = defineElement({
20
+ id: 'u90/file',
21
+ version: 1,
22
+ label: 'File',
23
+ placement: 'any',
24
+ options: FileOptions,
25
+ seed,
26
+ stored,
27
+ read: () => read,
28
+ complete: (v, _o, field) => {
29
+ if (v == null) return requiredIssue(false, field)
30
+ return v.label.trim() ? [] : [{ path: ['label'], code: 'blank', message: 'Give the download a label.' }]
31
+ },
32
+ refs: (v) => (v ? [{ kind: 'media', id: v.asset }] : []),
33
+ toStored: async (s, _o, ctx: SeedCtx) => (s === null ? null : { ...s, asset: typeof s.asset === 'object' ? await ctx.importAsset(s.asset.source) : s.asset }),
34
+ expand,
35
+ expandSeed: (s, o, ctx) => {
36
+ if (s === null) return null
37
+ if (typeof s.asset !== 'object') return expand({ ...s, asset: s.asset }, o, ctx)
38
+ const src = s.asset.source
39
+ return { asset: { id: src, url: src, mimeType: '', width: null, height: null, filename: src.split('/').pop() ?? src }, status: 'ready', label: s.label, description: s.description, downloadName: s.downloadName, ...(ctx.draft ? { $path: ctx.at } : {}) }
40
+ },
41
+ toText: (v) => (v ? [v.label, v.description ?? ''].filter(Boolean).join(' ') : ''),
42
+ translatable: (v) => (v ? [...(v.label ? [{ path: ['label'], value: v.label }] : []), ...(v.description ? [{ path: ['description'], value: v.description }] : [])] : []),
43
+ translation: {
44
+ applies: () => true,
45
+ value: () => z.object({ label: z.string().optional(), description: z.string().optional() }).refine((t) => t.label !== undefined || t.description !== undefined, 'A file translation names its label or description.'),
46
+ pick: (v) => {
47
+ if (!v) return null
48
+ const label = v.label ? v.label : undefined
49
+ const description = v.description ? v.description : undefined
50
+ return label === undefined && description === undefined ? null : { ...(label !== undefined ? { label } : {}), ...(description !== undefined ? { description } : {}) }
51
+ },
52
+ apply: (base, t) => (base ? { ...base, ...(t.label !== undefined ? { label: t.label } : {}), ...(t.description !== undefined ? { description: t.description } : {}) } : undefined),
53
+ },
54
+ empty: () => null,
55
+ upgrade: {},
56
+ })
@@ -0,0 +1,160 @@
1
+ import { z } from 'zod'
2
+ import { Id, MediaAssetSchema, defineElement, requiredIssue, type ExpandCtx, type ImageCrop, type MediaAsset, type MediaRecord, type Ref, type SeedCtx } from '../define'
3
+
4
+ const pct = z.number().min(0).max(100)
5
+ export const FocalPoint = z.strictObject({ x: pct, y: pct })
6
+ export const Crop = z
7
+ .strictObject({ x: z.number().min(0), y: z.number().min(0), width: z.number().positive(), height: z.number().positive() })
8
+ .refine((c) => c.x + c.width <= 100 && c.y + c.height <= 100, { message: 'Crop must be a finite positive rectangle inside the original image.', params: { code: 'crop' } })
9
+ const insideCrop = (p: { x: number; y: number }, c: ImageCrop) => p.x >= c.x && p.x <= c.x + c.width && p.y >= c.y && p.y <= c.y + c.height
10
+ /**
11
+ * The two cross-field rules shared by every geometry-shaped schema: a stored/seeded `geometry()`, the
12
+ * top-level of `imageStored`/`imageSeed`, and (via nesting) each of their variants. Nesting a schema that
13
+ * calls this from its own `superRefine` auto-prefixes the issue path with that field's key, so a variant
14
+ * built from `geometry(Id)` or `seedGeometry` reports at `variants.<name>.focalPoint` with no extra work here.
15
+ */
16
+ const crossFieldRules = (v: { focalPoint: { x: number; y: number } | null; crop: ImageCrop | null; alt?: string; decorative?: boolean }, ctx: z.RefinementCtx) => {
17
+ if (v.focalPoint && v.crop && !insideCrop(v.focalPoint, v.crop))
18
+ ctx.addIssue({ code: 'custom', path: ['focalPoint'], message: 'Focal point must lie inside the crop.', params: { code: 'focal-outside-crop' } })
19
+ if (v.decorative && v.alt !== '')
20
+ ctx.addIssue({ code: 'custom', path: ['alt'], message: 'Decorative images must have empty alternative text.', params: { code: 'decorative-alt' } })
21
+ }
22
+
23
+ const positiveInt = z.int().positive()
24
+ export const ImageConstraints = z.strictObject({
25
+ mimeTypes: z.array(z.string().regex(/^image\/[a-z0-9.+-]+$/i)).min(1).optional(),
26
+ maxBytes: positiveInt.optional(),
27
+ minWidth: positiveInt.optional(),
28
+ minHeight: positiveInt.optional(),
29
+ cropAspectRatios: z.array(z.number().positive()).min(1).optional(),
30
+ })
31
+ const VariantKey = z.string().regex(/^[a-zA-Z][a-zA-Z0-9_]*$/).refine((k) => !['constructor', 'prototype', '__proto__'].includes(k), 'Variant needs a safe name.')
32
+ export const ImageOptions = z.strictObject({
33
+ constraints: ImageConstraints.optional(),
34
+ variants: z.record(VariantKey, z.strictObject({ media: z.string().trim().min(1), constraints: ImageConstraints.optional() })).default({}),
35
+ })
36
+ type O = z.infer<typeof ImageOptions>
37
+
38
+ const geometry = <A extends z.ZodTypeAny>(asset: A) =>
39
+ z.strictObject({ asset, crop: Crop.nullable(), focalPoint: FocalPoint.nullable() }).superRefine((v, ctx) => crossFieldRules(v, ctx))
40
+ const variantsShape = <V extends z.ZodTypeAny>(o: O, variant: V) =>
41
+ z.strictObject(Object.fromEntries(Object.keys(o.variants).map((k) => [k, variant.optional()])))
42
+
43
+ export const imageStored = (o: O) =>
44
+ z
45
+ .strictObject({
46
+ ...geometry(Id).shape,
47
+ alt: z.string(),
48
+ decorative: z.boolean(),
49
+ caption: z.string().nullable(),
50
+ variants: variantsShape(o, geometry(Id)),
51
+ })
52
+ .superRefine((v, ctx) => crossFieldRules(v, ctx))
53
+ .nullable()
54
+ export type ImageStored = z.infer<ReturnType<typeof imageStored>>
55
+
56
+ const SeedAsset = z.union([Id, z.strictObject({ source: z.string().trim().min(1) })])
57
+ const seedGeometry = z
58
+ .strictObject({ asset: SeedAsset, crop: Crop.nullable().default(null), focalPoint: FocalPoint.nullable().default(null) })
59
+ .superRefine((v, ctx) => crossFieldRules(v, ctx))
60
+ export const imageSeed = (o: O) =>
61
+ z
62
+ .strictObject({
63
+ ...seedGeometry.shape,
64
+ alt: z.string().default(''),
65
+ decorative: z.boolean().default(false),
66
+ caption: z.string().nullable().default(null).transform((c) => (c === '' ? null : c)),
67
+ variants: variantsShape(o, seedGeometry).default({}),
68
+ })
69
+ .superRefine((v, ctx) => crossFieldRules(v, ctx))
70
+ .nullable()
71
+ export type ImageSeed = z.infer<ReturnType<typeof imageSeed>>
72
+
73
+ export type ImageAsset = MediaAsset
74
+ export type ImageReadVariant = { asset: ImageAsset | null; crop: ImageCrop | null; focalPoint: { x: number; y: number } | null; rendition: { url: string; width: number | null; height: number | null; blurDataURL?: string } | null; status: 'ready' | 'missing' | 'processing' | 'error' }
75
+ export type ImageRead = (ImageReadVariant & { alt: string; decorative: boolean; caption: string | null; variants: Record<string, ImageReadVariant>; $path?: string }) | null
76
+ const renditionRead = z.object({ url: z.string(), width: z.number().nullable(), height: z.number().nullable(), blurDataURL: z.string().optional() })
77
+ const variantRead = z.object({ asset: MediaAssetSchema.nullable(), crop: Crop.nullable(), focalPoint: FocalPoint.nullable(), rendition: renditionRead.nullable(), status: z.enum(['ready', 'missing', 'processing', 'error']) })
78
+ const imageRead = z.object({ ...variantRead.shape, alt: z.string(), decorative: z.boolean(), caption: z.string().nullable(), variants: z.record(z.string(), variantRead), $path: z.string().optional() }).nullable()
79
+
80
+ const sameCrop = (a: ImageCrop | null, b: ImageCrop | null) => (a === null && b === null) || (!!a && !!b && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height)
81
+ const pick = (m: MediaRecord): ImageAsset => { const { renditions: _r, ...asset } = m; return asset }
82
+ const readVariant = (v: { asset: Id; crop: ImageCrop | null; focalPoint: { x: number; y: number } | null }, ctx: ExpandCtx): ImageReadVariant => {
83
+ const m = ctx.media(v.asset)
84
+ if (!m) return { asset: null, crop: v.crop, focalPoint: v.focalPoint, rendition: null, status: 'missing' }
85
+ const r = m.renditions?.find((x) => sameCrop(x.crop, v.crop))
86
+ if (r) return { asset: pick(m), crop: v.crop, focalPoint: v.focalPoint, rendition: r.status === 'ready' ? { url: r.url, width: r.width, height: r.height, ...(r.blurDataURL ? { blurDataURL: r.blurDataURL } : {}) } : null, status: r.status }
87
+ if (v.crop === null) return { asset: pick(m), crop: null, focalPoint: v.focalPoint, rendition: { url: m.url, width: m.width, height: m.height, ...(m.blurDataURL ? { blurDataURL: m.blurDataURL } : {}) }, status: 'ready' }
88
+ return { asset: pick(m), crop: v.crop, focalPoint: v.focalPoint, rendition: null, status: 'processing' }
89
+ }
90
+ export const expandImage = (v: ImageStored, _o: O, ctx: ExpandCtx): ImageRead =>
91
+ v === null
92
+ ? null
93
+ : {
94
+ ...readVariant(v, ctx),
95
+ alt: v.alt,
96
+ decorative: v.decorative,
97
+ caption: v.caption,
98
+ variants: Object.fromEntries(Object.entries(v.variants).filter(([, x]) => x).map(([k, x]) => [k, readVariant(x!, ctx)])),
99
+ ...(ctx.draft ? { $path: ctx.at } : {}),
100
+ }
101
+
102
+ /** A seed's `{ source }` reads as an asset served from that path, until seeding imports it. */
103
+ const sourceCtx = (ctx: ExpandCtx, sources: Map<string, string>): ExpandCtx => ({
104
+ ...ctx,
105
+ media: (id) => {
106
+ const source = sources.get(String(id))
107
+ return source ? { id, url: source, mimeType: '', width: null, height: null, filename: source.split('/').pop() ?? source } : ctx.media(id)
108
+ },
109
+ })
110
+ const toSeedStored = (s: NonNullable<ImageSeed>, sources: Map<string, string>): NonNullable<ImageStored> => {
111
+ const lower = (g: { asset: Id | { source: string }; crop: ImageCrop | null; focalPoint: { x: number; y: number } | null }) => {
112
+ if (typeof g.asset === 'object') { const key = `seed:${g.asset.source}`; sources.set(key, g.asset.source); return { ...g, asset: key } }
113
+ return g as { asset: Id; crop: ImageCrop | null; focalPoint: { x: number; y: number } | null }
114
+ }
115
+ return { ...lower(s), alt: s.alt, decorative: s.decorative, caption: s.caption, variants: Object.fromEntries(Object.entries(s.variants).filter(([, g]) => g).map(([k, g]) => [k, lower(g!)])) }
116
+ }
117
+
118
+ export const imageToStored = async (s: ImageSeed, o: O, ctx: SeedCtx): Promise<ImageStored> => {
119
+ if (s === null) return null
120
+ const resolve = async (g: { asset: Id | { source: string }; crop: ImageCrop | null; focalPoint: { x: number; y: number } | null }) => ({ ...g, asset: typeof g.asset === 'object' ? await ctx.importAsset(g.asset.source) : g.asset })
121
+ const variants: Record<string, { asset: Id; crop: ImageCrop | null; focalPoint: { x: number; y: number } | null }> = {}
122
+ for (const [k, g] of Object.entries(s.variants)) if (g) variants[k] = await resolve(g)
123
+ return imageStored(o).parse({ ...(await resolve(s)), alt: s.alt, decorative: s.decorative, caption: s.caption, variants })
124
+ }
125
+
126
+ const mediaRefs = (v: ImageStored): Ref[] => (v ? [{ kind: 'media', id: v.asset }, ...Object.values(v.variants).filter((x) => x).map((x) => ({ kind: 'media' as const, id: x!.asset }))] : [])
127
+
128
+ export const image = defineElement({
129
+ id: 'u90/image',
130
+ version: 1,
131
+ label: 'Image',
132
+ placement: 'any',
133
+ options: ImageOptions,
134
+ seed: imageSeed,
135
+ stored: imageStored,
136
+ read: () => imageRead,
137
+ complete: (v, _o, field) => {
138
+ if (v == null) return requiredIssue(false, field)
139
+ return !v.decorative && !v.alt.trim() ? [{ path: ['alt'], code: 'alt', message: 'Meaningful images need alternative text for publication.' }] : []
140
+ },
141
+ refs: mediaRefs,
142
+ toStored: imageToStored,
143
+ expand: expandImage,
144
+ expandSeed: (s, o, ctx) => { if (s === null) return null; const sources = new Map<string, string>(); return expandImage(toSeedStored(s, sources), o, sourceCtx(ctx, sources)) },
145
+ toText: (v) => (v ? [v.decorative ? '' : v.alt, v.caption ?? ''].filter(Boolean).join(' ') : ''),
146
+ translatable: (v) => (v ? [...(!v.decorative && v.alt ? [{ path: ['alt'], value: v.alt }] : []), ...(v.caption ? [{ path: ['caption'], value: v.caption }] : [])] : []),
147
+ translation: {
148
+ applies: () => true,
149
+ value: () => z.object({ alt: z.string().min(1).optional(), caption: z.string().optional() }).refine((t) => t.alt !== undefined || t.caption !== undefined, 'An image translation names its alt text or caption.'),
150
+ pick: (v) => {
151
+ if (!v) return null
152
+ const alt = !v.decorative && v.alt ? v.alt : undefined
153
+ const caption = v.caption ? v.caption : undefined
154
+ return alt === undefined && caption === undefined ? null : { ...(alt !== undefined ? { alt } : {}), ...(caption !== undefined ? { caption } : {}) }
155
+ },
156
+ apply: (base, t) => (base ? { ...base, ...(t.alt !== undefined && !base.decorative ? { alt: t.alt } : {}), ...(t.caption !== undefined ? { caption: t.caption } : {}) } : undefined),
157
+ },
158
+ empty: () => null,
159
+ upgrade: {},
160
+ })