@unitninety/site-kit 0.0.0-stage → 1.6.2
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 +30 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/DraftEntries.tsx +41 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1015 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +273 -0
- package/src/bridge/registry.ts +30 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/Entries.tsx +16 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +13 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/seed.ts +2 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { SLOT, isLeaf, type DocumentDecl, type FieldDecl, type Issue, type ListDecl, type Schema, type SeedCtx, type ZoneDecl } from '../define'
|
|
2
|
+
import { isDocumentId } from '../ids'
|
|
3
|
+
import { zodIssues } from '../leaf'
|
|
4
|
+
import { path } from '../path'
|
|
5
|
+
import { CURRENT_CONTRACT, getElement } from '../registry'
|
|
6
|
+
import { record } from '../containers/group'
|
|
7
|
+
import { orderedEntries } from '../order'
|
|
8
|
+
|
|
9
|
+
export class SeedError extends Error {
|
|
10
|
+
constructor(public failures: { path: string; message: string }[]) {
|
|
11
|
+
super(failures.map((f) => `${f.path}: ${f.message}`).join('\n'))
|
|
12
|
+
this.name = 'SeedError'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
type Fail = (p: string, e: unknown) => void
|
|
16
|
+
const collector = () => {
|
|
17
|
+
const failures: { path: string; message: string }[] = []
|
|
18
|
+
const fail: Fail = (p, e) => failures.push({ path: p, message: e instanceof Error ? e.message : String(e) })
|
|
19
|
+
return { failures, fail }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One declared field's stored value, from an explicit seed value or its declared default. */
|
|
23
|
+
async function seedField(schema: Schema, decl: FieldDecl, v: unknown, ctx: SeedCtx, here: string, fail: Fail): Promise<unknown> {
|
|
24
|
+
if (isLeaf(decl)) {
|
|
25
|
+
const entry = getElement(decl.element)
|
|
26
|
+
const parsed = entry.options.safeParse(decl.options ?? {})
|
|
27
|
+
if (!parsed.success) { fail(here, 'The field’s declared options are invalid.'); return null }
|
|
28
|
+
const o = parsed.data
|
|
29
|
+
const input = v !== undefined ? v : decl.default
|
|
30
|
+
if (input === undefined) return entry.empty(o)
|
|
31
|
+
try { return await entry.toStored(entry.seed(o).parse(input), o, ctx) } catch (e) { fail(here, e); return entry.empty(o) }
|
|
32
|
+
}
|
|
33
|
+
if (decl.container === 'u90/group') {
|
|
34
|
+
const src = record(v) ? v : {}
|
|
35
|
+
const out: Record<string, unknown> = {}
|
|
36
|
+
for (const [name, f] of orderedEntries(decl.fields, decl.order)) out[name] = await seedField(schema, f, src[name], ctx, path.child(here, name), fail)
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
if (decl.container === 'u90/list') {
|
|
40
|
+
const out: unknown[] = []
|
|
41
|
+
for (const item of Array.isArray(v) ? v : (decl.default ?? [])) out.push(await seedListItem(schema, decl, item, ctx, here, fail))
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
const out: unknown[] = []
|
|
45
|
+
for (const item of Array.isArray(v) ? v : (decl.default ?? []).map((name) => ({ _block: name }))) {
|
|
46
|
+
const block = await seedZoneItem(schema, decl, item, ctx, here, fail)
|
|
47
|
+
if (block) out.push(block)
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
async function seedListItem(schema: Schema, decl: ListDecl, item: unknown, ctx: SeedCtx, here: string, fail: Fail): Promise<Record<string, unknown>> {
|
|
52
|
+
const _id = ctx.newId()
|
|
53
|
+
const inner = await seedField(schema, decl.item, item, ctx, path.child(here, _id), fail)
|
|
54
|
+
return isLeaf(decl.item) ? { _id, _value: inner } : { _id, ...(inner as object) }
|
|
55
|
+
}
|
|
56
|
+
async function seedZoneItem(schema: Schema, decl: ZoneDecl, item: unknown, ctx: SeedCtx, here: string, fail: Fail): Promise<Record<string, unknown> | null> {
|
|
57
|
+
const _id = ctx.newId()
|
|
58
|
+
const block = record(item) && typeof item._block === 'string' ? item._block : ''
|
|
59
|
+
if (block === SLOT) return { _id, _block: SLOT }
|
|
60
|
+
const declared = Object.hasOwn(schema.blocks, block) ? schema.blocks[block] : undefined
|
|
61
|
+
if (!declared) { fail(path.child(here, _id), `No block named "${block}".`); return null }
|
|
62
|
+
if (!decl.allow.includes(block)) fail(path.child(here, _id), `"${block}" is not allowed here.`)
|
|
63
|
+
const { _block: _b, ...rest } = item as Record<string, unknown>
|
|
64
|
+
return { _id, _block: block, ...((await seedField(schema, { container: 'u90/group', fields: declared.fields, order: declared.order }, rest, ctx, path.child(here, _id), fail)) as object) }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Declared defaults, or an explicit seed value, to stored JSON. Every failing path is collected before throwing. */
|
|
68
|
+
export async function seed(schema: Schema, docDecl: DocumentDecl, value: unknown, ctx: SeedCtx, at: string): Promise<unknown> {
|
|
69
|
+
const { failures, fail } = collector()
|
|
70
|
+
let doc: unknown
|
|
71
|
+
switch (docDecl.kind) {
|
|
72
|
+
case 'page':
|
|
73
|
+
case 'layout': {
|
|
74
|
+
// A blocks array, or `{ blocks }` (the stored document's own shape); absent means the zone's default blocks.
|
|
75
|
+
const blocks = Array.isArray(value) ? value : record(value) && Array.isArray(value.blocks) ? value.blocks : undefined
|
|
76
|
+
if (value !== undefined && blocks === undefined)
|
|
77
|
+
throw new SeedError([{ path: path.child(at, 'blocks'), message: 'Seed a page or layout with a blocks array, or { blocks }.' }])
|
|
78
|
+
doc = { _contract: CURRENT_CONTRACT, blocks: await seedField(schema, docDecl.blocks, blocks, ctx, path.child(at, 'blocks'), fail) }
|
|
79
|
+
break
|
|
80
|
+
}
|
|
81
|
+
case 'entry': {
|
|
82
|
+
const c = Object.hasOwn(schema.collections, docDecl.collection) ? schema.collections[docDecl.collection] : undefined
|
|
83
|
+
if (!c) throw new SeedError([{ path: at, message: `No collection named "${docDecl.collection}".` }])
|
|
84
|
+
const v = record(value) ? value : {}
|
|
85
|
+
doc = {
|
|
86
|
+
_contract: CURRENT_CONTRACT,
|
|
87
|
+
fields: await seedField(schema, { container: 'u90/group', fields: c.fields, order: c.order }, v.fields, ctx, path.child(at, 'fields'), fail),
|
|
88
|
+
...(c.template ? { blocks: await seedField(schema, c.template, v.blocks, ctx, path.child(at, 'blocks'), fail) } : {}),
|
|
89
|
+
}
|
|
90
|
+
break
|
|
91
|
+
}
|
|
92
|
+
case 'single': {
|
|
93
|
+
const s = Object.hasOwn(schema.singles, docDecl.key) ? schema.singles[docDecl.key] : undefined
|
|
94
|
+
if (!s) throw new SeedError([{ path: at, message: `No Single named "${docDecl.key}".` }])
|
|
95
|
+
doc = { _contract: CURRENT_CONTRACT, value: await seedField(schema, s.field, value, ctx, at, fail) }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (failures.length) throw new SeedError(failures)
|
|
99
|
+
return doc
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* One new item for a list or a zone, as the write path's `insert` stores it: a fresh `_id`, and every
|
|
104
|
+
* field filled from the declared defaults unless `value` gives it. A zone item names its block with
|
|
105
|
+
* `_block`; a SLOT cannot be inserted. Every failing path is collected and thrown as one SeedError.
|
|
106
|
+
*/
|
|
107
|
+
export async function seedItem(schema: Schema, decl: ListDecl | ZoneDecl, value: unknown, ctx: SeedCtx, at: string): Promise<Record<string, unknown>> {
|
|
108
|
+
if (decl.container === 'u90/zone' && record(value) && value._block === SLOT) throw new SeedError([{ path: at, message: 'A SLOT cannot be inserted.' }])
|
|
109
|
+
const { failures, fail } = collector()
|
|
110
|
+
const item = decl.container === 'u90/list' ? await seedListItem(schema, decl, value, ctx, at, fail) : await seedZoneItem(schema, decl, value, ctx, at, fail)
|
|
111
|
+
if (failures.length || !item) throw new SeedError(failures.length ? failures : [{ path: at, message: 'Nothing to insert.' }])
|
|
112
|
+
return item
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type SeedKnown = { page(key: string): boolean; entry(collection: string, key: string): boolean }
|
|
116
|
+
/** The page and entry keys a leaf's parsed seed value names: a link's `to.page` or `to.entry`, a relation's or selection's `$ref`. */
|
|
117
|
+
const namedKeys = (element: string, options: Record<string, unknown>, v: unknown): ({ page: string } | { entry: [string, string] })[] => {
|
|
118
|
+
const out: ({ page: string } | { entry: [string, string] })[] = []
|
|
119
|
+
if (element === 'u90/relation' || element === 'u90/selection')
|
|
120
|
+
for (const r of Array.isArray(v) ? v : [v])
|
|
121
|
+
if (record(r) && record(r.$ref) && typeof r.$ref.key === 'string')
|
|
122
|
+
out.push({ entry: [typeof r.$ref.collection === 'string' ? r.$ref.collection : String(options.to), r.$ref.key] })
|
|
123
|
+
if (element === 'u90/link' && record(v) && record(v.to)) {
|
|
124
|
+
const to = v.to
|
|
125
|
+
if (typeof to.page === 'string' && !isDocumentId(to.page)) out.push({ page: to.page })
|
|
126
|
+
if (record(to.entry) && typeof to.entry.collection === 'string' && typeof to.entry.key === 'string') out.push({ entry: [to.entry.collection, to.entry.key] })
|
|
127
|
+
}
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* What seeding `value` would refuse, found without seeding: each leaf's seed value against its
|
|
132
|
+
* element's seed schema, unknown fields and blocks, and page or entry keys the site does not declare.
|
|
133
|
+
* `defineSite` runs it on every declared entry, because the build cannot wait on `seed`, which
|
|
134
|
+
* imports assets. List and zone items are named by position, having no `_id` yet.
|
|
135
|
+
*/
|
|
136
|
+
export function seedIssues(schema: Schema, docDecl: DocumentDecl, value: unknown, at: string, known: SeedKnown): Issue[] {
|
|
137
|
+
const issues: Issue[] = []
|
|
138
|
+
const add = (p: string, code: string, message: string): void => { issues.push({ path: p, code, message, severity: 'error' }) }
|
|
139
|
+
const field = (decl: FieldDecl, v: unknown, here: string): void => {
|
|
140
|
+
if (v === undefined || v === null) return
|
|
141
|
+
if (isLeaf(decl)) {
|
|
142
|
+
const entry = getElement(decl.element)
|
|
143
|
+
const o = entry.options.safeParse(decl.options ?? {})
|
|
144
|
+
if (!o.success) return add(here, 'invalid-options', 'The field’s declared options are invalid.')
|
|
145
|
+
const r = entry.seed(o.data).safeParse(v)
|
|
146
|
+
if (!r.success) return zodIssues(r.error).forEach((i) => add(path.child(here, ...i.path), i.code, i.message))
|
|
147
|
+
for (const k of namedKeys(decl.element, o.data as Record<string, unknown>, r.data)) {
|
|
148
|
+
if ('page' in k && !known.page(k.page)) add(here, 'unknown-page', `No page with the key "${k.page}" is declared.`)
|
|
149
|
+
if ('entry' in k && !known.entry(k.entry[0], k.entry[1])) add(here, 'unknown-entry', `No entry "${k.entry[1]}" is declared in ${k.entry[0]}.`)
|
|
150
|
+
}
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
if (decl.container === 'u90/group') {
|
|
154
|
+
if (!record(v)) return add(here, 'invalid_type', 'Expected an object of fields.')
|
|
155
|
+
for (const k of Object.keys(v)) if (!Object.hasOwn(decl.fields, k)) add(path.child(here, k), 'unknown-field', `No field named "${k}".`)
|
|
156
|
+
for (const [name, f] of orderedEntries(decl.fields, decl.order)) field(f, v[name], path.child(here, name))
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
if (!Array.isArray(v)) return add(here, 'invalid_type', decl.container === 'u90/list' ? 'Expected a list.' : 'Expected a list of blocks.')
|
|
160
|
+
if (decl.container === 'u90/list') return v.forEach((item, i) => field(decl.item, item, path.child(here, i)))
|
|
161
|
+
v.forEach((item, i) => {
|
|
162
|
+
const itemAt = path.child(here, i)
|
|
163
|
+
const block = record(item) && typeof item._block === 'string' ? item._block : ''
|
|
164
|
+
if (block === SLOT) return
|
|
165
|
+
const declared = Object.hasOwn(schema.blocks, block) ? schema.blocks[block] : undefined
|
|
166
|
+
if (!declared) return add(itemAt, 'unknown-block', `No block named "${block}".`)
|
|
167
|
+
if (!decl.allow.includes(block)) add(itemAt, 'not-allowed', `"${block}" is not allowed here.`)
|
|
168
|
+
const { _block: _b, ...rest } = item as Record<string, unknown>
|
|
169
|
+
field({ container: 'u90/group', fields: declared.fields, order: declared.order }, rest, itemAt)
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
if (docDecl.kind === 'entry') {
|
|
173
|
+
const c = Object.hasOwn(schema.collections, docDecl.collection) ? schema.collections[docDecl.collection] : undefined
|
|
174
|
+
if (!c) return [{ path: at, code: 'unknown-collection', message: `No collection named "${docDecl.collection}".`, severity: 'error' }]
|
|
175
|
+
const v = record(value) ? value : {}
|
|
176
|
+
field({ container: 'u90/group', fields: c.fields, order: c.order }, v.fields, path.child(at, 'fields'))
|
|
177
|
+
if (c.template) field(c.template, v.blocks, path.child(at, 'blocks'))
|
|
178
|
+
} else if (docDecl.kind === 'single') {
|
|
179
|
+
const s = Object.hasOwn(schema.singles, docDecl.key) ? schema.singles[docDecl.key] : undefined
|
|
180
|
+
if (s) field(s.field, value, at)
|
|
181
|
+
} else {
|
|
182
|
+
field(docDecl.blocks, Array.isArray(value) ? value : record(value) ? value.blocks : undefined, path.child(at, 'blocks'))
|
|
183
|
+
}
|
|
184
|
+
return issues
|
|
185
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DocumentDecl, Schema } from '../define'
|
|
2
|
+
import { hasStoredShape } from '../leaf'
|
|
3
|
+
import { walkDocument } from '../walk'
|
|
4
|
+
|
|
5
|
+
/** Every leaf's text, non-empty ones joined by newlines: for search, summaries and agents. */
|
|
6
|
+
export function textOf(schema: Schema, docDecl: DocumentDecl, doc: unknown, at: string): string {
|
|
7
|
+
if ((docDecl.kind === 'entry' && !Object.hasOwn(schema.collections, docDecl.collection)) || (docDecl.kind === 'single' && !Object.hasOwn(schema.singles, docDecl.key))) return ''
|
|
8
|
+
const parts: string[] = []
|
|
9
|
+
walkDocument(schema, docDecl, doc, {
|
|
10
|
+
leaf: ({ entry, decl, value }) => {
|
|
11
|
+
if (value === undefined || value === null) return
|
|
12
|
+
const o = entry.options.safeParse(decl.options ?? {})
|
|
13
|
+
if (!o.success) return
|
|
14
|
+
if (!hasStoredShape(entry, o.data, value)) return
|
|
15
|
+
const t = entry.toText(value, o.data)
|
|
16
|
+
if (t) parts.push(t)
|
|
17
|
+
},
|
|
18
|
+
}, at)
|
|
19
|
+
return parts.join('\n')
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { DocumentDecl, Schema } from '../define'
|
|
2
|
+
import { hasStoredShape } from '../leaf'
|
|
3
|
+
import { path } from '../path'
|
|
4
|
+
import { walkDocument } from '../walk'
|
|
5
|
+
|
|
6
|
+
export function translatableParts(schema: Schema, docDecl: DocumentDecl, doc: unknown, at: string): { path: string; value: string }[] {
|
|
7
|
+
if ((docDecl.kind === 'entry' && !Object.hasOwn(schema.collections, docDecl.collection)) || (docDecl.kind === 'single' && !Object.hasOwn(schema.singles, docDecl.key))) return []
|
|
8
|
+
const out: { path: string; value: string }[] = []
|
|
9
|
+
walkDocument(schema, docDecl, doc, {
|
|
10
|
+
leaf: ({ entry, decl, value, at: here }) => {
|
|
11
|
+
if (value === undefined || value === null) return
|
|
12
|
+
const o = entry.options.safeParse(decl.options ?? {})
|
|
13
|
+
if (!o.success) return
|
|
14
|
+
if (!hasStoredShape(entry, o.data, value)) return
|
|
15
|
+
for (const part of entry.translatable(value, o.data)) out.push({ path: path.child(here, ...part.path), value: part.value })
|
|
16
|
+
},
|
|
17
|
+
}, at)
|
|
18
|
+
return out
|
|
19
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { DocumentDecl, ElementEntry, Schema } from '../define'
|
|
2
|
+
import type { ElementId } from '../ids'
|
|
3
|
+
import { CONTRACTS, CURRENT_CONTRACT, elements } from '../registry'
|
|
4
|
+
import { walkDocument } from '../walk'
|
|
5
|
+
|
|
6
|
+
export class UpgradeError extends Error {
|
|
7
|
+
constructor(public path: string, public from: number, public to: number, public cause: unknown) {
|
|
8
|
+
super(`Upgrading ${path} from v${from} to v${to} failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
9
|
+
this.name = 'UpgradeError'
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export type UpgradeOptions = { registry?: Record<ElementId, ElementEntry>; contracts?: Record<number, Record<ElementId, number>>; current?: number }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Runs each leaf's upgrade chain from the element version recorded for `fromContract` up to the
|
|
16
|
+
* registry's current version, writes the results back, and stamps `_contract`. Reads run this on
|
|
17
|
+
* the fly; writes store the result. v1 ships no upgrades, so this is a stamp unless a registry with
|
|
18
|
+
* newer versions is passed (as the tests do).
|
|
19
|
+
*/
|
|
20
|
+
export function upgrade(schema: Schema, docDecl: DocumentDecl, doc: any, at: string, fromContract: number, opts: UpgradeOptions = {}): unknown {
|
|
21
|
+
const registry = opts.registry ?? (elements as Record<ElementId, ElementEntry>)
|
|
22
|
+
const contracts = opts.contracts ?? CONTRACTS
|
|
23
|
+
const current = opts.current ?? CURRENT_CONTRACT
|
|
24
|
+
const versions = contracts[fromContract]
|
|
25
|
+
if (!versions) throw new Error(`Unknown contract version ${fromContract}.`)
|
|
26
|
+
walkDocument(schema, docDecl, doc, {
|
|
27
|
+
leaf: ({ decl, value, at: here, set }) => {
|
|
28
|
+
if (value === undefined) return
|
|
29
|
+
const entry = registry[decl.element]
|
|
30
|
+
let v = versions[decl.element] ?? 1
|
|
31
|
+
let upgraded: unknown = value
|
|
32
|
+
while (v < entry.version) {
|
|
33
|
+
const step = entry.upgrade[v]
|
|
34
|
+
if (!step) throw new UpgradeError(here, v, v + 1, new Error(`${decl.element} has no upgrade from v${v}.`))
|
|
35
|
+
try { upgraded = step(upgraded) } catch (cause) { throw new UpgradeError(here, v, v + 1, cause) }
|
|
36
|
+
v++
|
|
37
|
+
}
|
|
38
|
+
if (upgraded !== value) set(upgraded)
|
|
39
|
+
},
|
|
40
|
+
}, at)
|
|
41
|
+
doc._contract = current
|
|
42
|
+
return doc
|
|
43
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { SLOT, type DocumentDecl, type Issue, type LeafIssue, type Mode, type Schema } from '../define'
|
|
2
|
+
import { hasStoredShape, validateLeaf } from '../leaf'
|
|
3
|
+
import { path } from '../path'
|
|
4
|
+
import { walkDocument } from '../walk'
|
|
5
|
+
import { record } from '../containers/group'
|
|
6
|
+
|
|
7
|
+
export type ValidateOptions = { mode: Mode; previous?: unknown }
|
|
8
|
+
|
|
9
|
+
const valueAtPointer = (doc: unknown, pointer: string): unknown =>
|
|
10
|
+
pointer === '' ? doc : pointer.slice(1).split('/').reduce<any>((n, seg) => (n == null ? undefined : n[seg.replace(/~1/g, '/').replace(/~0/g, '~')]), doc)
|
|
11
|
+
const dig = (v: unknown, sub: string) => sub.split('.').reduce<any>((n, k) => (n == null ? undefined : n[k]), v)
|
|
12
|
+
const slots = (v: unknown) => (Array.isArray(v) ? v.filter((x) => record(x) && x._block === SLOT).length : 0)
|
|
13
|
+
|
|
14
|
+
export function validate(schema: Schema, docDecl: DocumentDecl, doc: unknown, at: string, opts: ValidateOptions): Issue[] {
|
|
15
|
+
const issues: Issue[] = []
|
|
16
|
+
const push = (base: string, i: LeafIssue) => issues.push({ path: path.child(base, ...i.path), code: i.code, message: i.message, severity: i.severity ?? 'error' })
|
|
17
|
+
const ids = new Map<string, string>()
|
|
18
|
+
const layoutRoot = docDecl.kind === 'layout' ? path.child(at, 'blocks') : null
|
|
19
|
+
const root = (p: string, code: string, message: string): Issue[] => [{ path: p, code, message, severity: 'error' }]
|
|
20
|
+
if (!record(doc)) return root(at, 'invalid_type', 'A stored document is an object.')
|
|
21
|
+
if (docDecl.kind === 'entry' && !Object.hasOwn(schema.collections, docDecl.collection)) return root(at, 'unknown-collection', `No collection named "${docDecl.collection}".`)
|
|
22
|
+
if (docDecl.kind === 'single' && !Object.hasOwn(schema.singles, docDecl.key)) return root(at, 'unknown-single', `No Single named "${docDecl.key}".`)
|
|
23
|
+
if (docDecl.kind === 'entry' && doc.fields !== undefined && !record(doc.fields)) return root(path.child(at, 'fields'), 'invalid_type', 'An entry stores its fields as an object.')
|
|
24
|
+
walkDocument(schema, docDecl, doc, {
|
|
25
|
+
enter: ({ container, decl, value, at: here }) => {
|
|
26
|
+
container.enter(decl, value, schema).forEach((i) => push(here, i))
|
|
27
|
+
if (container.id === 'u90/zone' && slots(value) !== (here === layoutRoot ? 1 : 0))
|
|
28
|
+
push(here, { path: [], code: 'slot', message: here === layoutRoot ? 'A layout holds exactly one SLOT.' : 'Only a layout may hold a SLOT.' })
|
|
29
|
+
if (Array.isArray(value))
|
|
30
|
+
for (const item of value)
|
|
31
|
+
if (record(item) && typeof item._id === 'string') {
|
|
32
|
+
if (ids.has(item._id)) push(here, { path: [], code: 'duplicate-id', message: `The id "${item._id}" is already used at ${ids.get(item._id)}.` })
|
|
33
|
+
else ids.set(item._id, here)
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
leaf: ({ entry, decl, value, at: here }) => {
|
|
37
|
+
validateLeaf(entry, decl, value, opts.mode).forEach((i) => push(here, i))
|
|
38
|
+
if (decl.lock?.length && opts.previous !== undefined) {
|
|
39
|
+
const pointer = path.toPointer(opts.previous, here)
|
|
40
|
+
const before = pointer === null ? undefined : valueAtPointer(opts.previous, pointer)
|
|
41
|
+
if (before !== undefined)
|
|
42
|
+
for (const sub of decl.lock)
|
|
43
|
+
if (JSON.stringify(dig(before, sub)) !== JSON.stringify(dig(value, sub)))
|
|
44
|
+
push(here, { path: sub.split('.'), code: 'locked', message: 'This part is set by the site and cannot be changed here.' })
|
|
45
|
+
}
|
|
46
|
+
if (value !== undefined && value !== null) {
|
|
47
|
+
const o = entry.options.safeParse(decl.options ?? {})
|
|
48
|
+
if (o.success && hasStoredShape(entry, o.data, value))
|
|
49
|
+
for (const ref of entry.refs(value, o.data, here))
|
|
50
|
+
if (ref.kind === 'single' && !Object.hasOwn(schema.singles, ref.key))
|
|
51
|
+
push(here, { path: [], code: 'unknown-single', message: `No Single named "${ref.key}".`, severity: 'warning' })
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
}, at)
|
|
55
|
+
return issues
|
|
56
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** A record's keys in declared order: the listed keys that exist, then any others in their stored order. */
|
|
2
|
+
export function orderedKeys(record: Record<string, unknown>, order?: readonly string[]): string[] {
|
|
3
|
+
const keys = Object.keys(record)
|
|
4
|
+
if (!order?.length) return keys
|
|
5
|
+
const listed = order.filter((k, i) => Object.hasOwn(record, k) && order.indexOf(k) === i)
|
|
6
|
+
return [...listed, ...keys.filter((k) => !listed.includes(k))]
|
|
7
|
+
}
|
|
8
|
+
export function orderedEntries<T>(record: Record<string, T>, order?: readonly string[]): [string, T][] {
|
|
9
|
+
return orderedKeys(record, order).map((k) => [k, record[k]])
|
|
10
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export { ID_RE, newId } from './ids'
|
|
2
|
+
const KINDS = ['page', 'entry', 'layout', 'single'] as const
|
|
3
|
+
export type DocKind = (typeof KINDS)[number]
|
|
4
|
+
export type Path = string
|
|
5
|
+
|
|
6
|
+
const doc = (kind: DocKind, id: string | number): Path => `${kind}:${id}`
|
|
7
|
+
const child = (p: Path, ...segments: (string | number)[]): Path =>
|
|
8
|
+
segments.length ? `${p}/${segments.join('/')}` : p
|
|
9
|
+
const parse = (p: Path): { kind: DocKind; id: string; segments: string[] } => {
|
|
10
|
+
const m = /^(page|entry|layout|single):([^/]+)(?:\/(.*))?$/.exec(p)
|
|
11
|
+
if (!m) throw new Error(`Not a u90 path: ${p}`)
|
|
12
|
+
return { kind: m[1] as DocKind, id: m[2], segments: m[3] ? m[3].split('/') : [] }
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* JSON Pointer (RFC 6901) of a path inside its document. Arrays are addressed by `_id`,
|
|
16
|
+
* and a leaf list item resolves to its `_value`. Returns null when a segment does not exist.
|
|
17
|
+
*/
|
|
18
|
+
const toPointer = (document: unknown, p: Path): string | null => {
|
|
19
|
+
const { kind, segments } = parse(p)
|
|
20
|
+
// A Single document is `{ value }`; its leaf sits at the document path itself.
|
|
21
|
+
let node: any = kind === 'single' ? (document as any)?.value : document
|
|
22
|
+
let pointer = kind === 'single' ? '/value' : ''
|
|
23
|
+
for (const seg of segments) {
|
|
24
|
+
if (Array.isArray(node)) {
|
|
25
|
+
const i = node.findIndex((item) => item && typeof item === 'object' && item._id === seg)
|
|
26
|
+
if (i < 0) return null
|
|
27
|
+
node = node[i]
|
|
28
|
+
pointer += `/${i}`
|
|
29
|
+
if (node && typeof node === 'object' && '_value' in node) {
|
|
30
|
+
node = node._value
|
|
31
|
+
pointer += '/_value'
|
|
32
|
+
}
|
|
33
|
+
} else if (node && typeof node === 'object' && seg in node) {
|
|
34
|
+
node = node[seg]
|
|
35
|
+
pointer += `/${seg.replace(/~/g, '~0').replace(/\//g, '~1')}`
|
|
36
|
+
} else return null
|
|
37
|
+
}
|
|
38
|
+
return pointer
|
|
39
|
+
}
|
|
40
|
+
export const path = { doc, child, parse, toPointer }
|
|
41
|
+
|
|
42
|
+
/** A leaf's key in an overlay: its path inside the document, `blocks/<blockId>/headline`; '' for a Single's own leaf. */
|
|
43
|
+
export const overlayKey = (at: Path): string => parse(at).segments.join('/')
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/** `route` under a language's address prefix (`/fr`): the home page is the prefix itself. It imports nothing, so the languages proxy can load it without the element registry. */
|
|
2
|
+
export const prefixedRoute = (prefix: string, route: string): string => (route === '/' ? prefix : `${prefix}${route}`)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ContainerEntry, ElementEntry, FieldDecl } from './define'
|
|
2
|
+
import { ELEMENT_IDS, type ContainerId, type ElementId } from './ids'
|
|
3
|
+
import { boolean } from './leaves/boolean'
|
|
4
|
+
import { choice } from './leaves/choice'
|
|
5
|
+
import { date } from './leaves/date'
|
|
6
|
+
import { file } from './leaves/file'
|
|
7
|
+
import { image } from './leaves/image'
|
|
8
|
+
import { link } from './leaves/link'
|
|
9
|
+
import { number } from './leaves/number'
|
|
10
|
+
import { query } from './leaves/query'
|
|
11
|
+
import { relation } from './leaves/relation'
|
|
12
|
+
import { richText } from './leaves/rich-text'
|
|
13
|
+
import { selection } from './leaves/selection'
|
|
14
|
+
import { single } from './leaves/single'
|
|
15
|
+
import { text } from './leaves/text'
|
|
16
|
+
import { video } from './leaves/video'
|
|
17
|
+
import { group } from './containers/group'
|
|
18
|
+
import { list } from './containers/list'
|
|
19
|
+
import { zone } from './containers/zone'
|
|
20
|
+
|
|
21
|
+
/** Every element. `satisfies` fails the build if an id has no entry. */
|
|
22
|
+
export const elements = {
|
|
23
|
+
'u90/text': text,
|
|
24
|
+
'u90/rich-text': richText,
|
|
25
|
+
'u90/image': image,
|
|
26
|
+
'u90/video': video,
|
|
27
|
+
'u90/file': file,
|
|
28
|
+
'u90/link': link,
|
|
29
|
+
'u90/date': date,
|
|
30
|
+
'u90/number': number,
|
|
31
|
+
'u90/boolean': boolean,
|
|
32
|
+
'u90/choice': choice,
|
|
33
|
+
'u90/relation': relation,
|
|
34
|
+
'u90/selection': selection,
|
|
35
|
+
'u90/query': query,
|
|
36
|
+
'u90/single': single,
|
|
37
|
+
} satisfies Record<ElementId, ElementEntry>
|
|
38
|
+
|
|
39
|
+
export const getElement = (id: ElementId): ElementEntry => elements[id] as ElementEntry
|
|
40
|
+
|
|
41
|
+
export const CURRENT_CONTRACT = 1
|
|
42
|
+
/** Which element version each contract version was written with. Add a row when any element's version changes. */
|
|
43
|
+
export const CONTRACTS: Record<number, Record<ElementId, number>> = {
|
|
44
|
+
1: Object.fromEntries(ELEMENT_IDS.map((id) => [id, 1])) as Record<ElementId, number>,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const containers = { 'u90/group': group, 'u90/list': list, 'u90/zone': zone } satisfies Record<ContainerId, ContainerEntry<any>>
|
|
48
|
+
export const getContainer = (id: ContainerId): ContainerEntry<FieldDecl> => containers[id] as unknown as ContainerEntry<FieldDecl>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** `{key}` inside copy points at a Single. Keys are lower-case identifiers. */
|
|
2
|
+
export const TOKEN = /\{([a-z][a-zA-Z0-9_-]*)\}/g
|
|
3
|
+
export const tokenKeys = (s: string): string[] => [...s.matchAll(TOKEN)].map((m) => m[1])
|
|
4
|
+
/** The text a Single's read value contributes inline: Text gives `text`, Link gives `label`. */
|
|
5
|
+
export const textOfRead = (read: unknown): string | undefined => {
|
|
6
|
+
if (!read || typeof read !== 'object') return undefined
|
|
7
|
+
const r = read as Record<string, unknown>
|
|
8
|
+
if (typeof r.text === 'string') return r.text
|
|
9
|
+
if (typeof r.label === 'string') return r.label
|
|
10
|
+
return undefined
|
|
11
|
+
}
|
|
12
|
+
/** Unknown keys stay literal; `validate` reports them as `unknown-single` warnings. */
|
|
13
|
+
export const resolveTokens = (s: string, single: (key: string) => unknown): string =>
|
|
14
|
+
s.replace(TOKEN, (match, key: string) => textOfRead(single(key)) ?? match)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { isLeaf, type ContainerEntry, type DocumentDecl, type ElementEntry, type FieldDecl, type GroupDecl, type LeafDecl, type ListDecl, type Schema, type ZoneDecl } from './define'
|
|
2
|
+
import { path } from './path'
|
|
3
|
+
import { getContainer, getElement } from './registry'
|
|
4
|
+
|
|
5
|
+
export type LeafNode = { entry: ElementEntry; decl: LeafDecl; value: unknown; at: string; set(v: unknown): void }
|
|
6
|
+
export type ContainerNode = { container: ContainerEntry<FieldDecl>; decl: GroupDecl | ListDecl | ZoneDecl; value: unknown; at: string; set(v: unknown): void }
|
|
7
|
+
export type Visitor = { leaf?(n: LeafNode): void; enter?(n: ContainerNode): void; exit?(n: ContainerNode): void }
|
|
8
|
+
const noop = () => {}
|
|
9
|
+
|
|
10
|
+
/** The only traversal. Depth first; containers are entered, their children walked, then exited. */
|
|
11
|
+
export function walk(schema: Schema, decl: FieldDecl, value: unknown, visitor: Visitor, at: string, set: (v: unknown) => void = noop): void {
|
|
12
|
+
if (isLeaf(decl)) {
|
|
13
|
+
visitor.leaf?.({ entry: getElement(decl.element), decl, value, at, set })
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
const container = getContainer(decl.container)
|
|
17
|
+
const node: ContainerNode = { container, decl, value, at, set }
|
|
18
|
+
visitor.enter?.(node)
|
|
19
|
+
for (const child of container.children(decl, value, schema)) walk(schema, child.decl, child.value, visitor, path.child(at, child.key), child.set)
|
|
20
|
+
visitor.exit?.(node)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Stored document shapes: page/layout `{ blocks }`, entry `{ fields, blocks? }`, single `{ value }`. */
|
|
24
|
+
export function walkDocument(schema: Schema, docDecl: DocumentDecl, doc: any, visitor: Visitor, at: string): void {
|
|
25
|
+
switch (docDecl.kind) {
|
|
26
|
+
case 'page':
|
|
27
|
+
case 'layout':
|
|
28
|
+
return walk(schema, docDecl.blocks, doc?.blocks, visitor, path.child(at, 'blocks'), (v) => { doc.blocks = v })
|
|
29
|
+
case 'entry': {
|
|
30
|
+
const collection = Object.hasOwn(schema.collections, docDecl.collection) ? schema.collections[docDecl.collection] : undefined
|
|
31
|
+
if (!collection) throw new Error(`No collection named "${docDecl.collection}".`)
|
|
32
|
+
walk(schema, { container: 'u90/group', fields: collection.fields, order: collection.order }, doc?.fields, visitor, path.child(at, 'fields'), (v) => { doc.fields = v })
|
|
33
|
+
if (collection.template) walk(schema, collection.template, doc?.blocks, visitor, path.child(at, 'blocks'), (v) => { doc.blocks = v })
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
case 'single': {
|
|
37
|
+
const single = Object.hasOwn(schema.singles, docDecl.key) ? schema.singles[docDecl.key] : undefined
|
|
38
|
+
if (!single) throw new Error(`No Single named "${docDecl.key}".`)
|
|
39
|
+
return walk(schema, single.field, doc?.value, visitor, at, (v) => { doc.value = v })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|