@estiva-app/interop 0.5.0 → 0.7.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/CHANGELOG.md +114 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/projection.d.ts +116 -12
- package/dist/projection.d.ts.map +1 -1
- package/dist/projection.js +240 -19
- package/dist/projection.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +6 -0
- package/src/projection.ts +383 -25
package/src/projection.ts
CHANGED
|
@@ -370,7 +370,28 @@ export interface ManifestAction {
|
|
|
370
370
|
*/
|
|
371
371
|
alsoRead?: number[]
|
|
372
372
|
}
|
|
373
|
-
input?: {
|
|
373
|
+
input?: {
|
|
374
|
+
type: string
|
|
375
|
+
/** For a scalar input: the vocabulary its value must come from. */
|
|
376
|
+
enum?: string
|
|
377
|
+
/**
|
|
378
|
+
* For `type: 'object'`: the fields a consumer draws, and which are required.
|
|
379
|
+
*
|
|
380
|
+
* **A property's name is the tag its value is written to** (PRO-4). That
|
|
381
|
+
* rule was implicit — Ship's `add-issue` declares `{ title }` and a Ship
|
|
382
|
+
* issue carries `["title", …]`, so it held by coincidence of naming and
|
|
383
|
+
* nothing said so. It is stated here because a consumer cannot construct
|
|
384
|
+
* the event without it, and RFC 0.4 §13.4 asserts the existing declaration
|
|
385
|
+
* is already sufficient for one to try.
|
|
386
|
+
*
|
|
387
|
+
* Its limit, recorded rather than designed around: **nothing can target an
|
|
388
|
+
* event's `content`.** Ship's issue description lives there and is
|
|
389
|
+
* therefore not creatable from another app, which is why `add-issue`
|
|
390
|
+
* declares only a title.
|
|
391
|
+
*/
|
|
392
|
+
properties?: Record<string, { type?: string; enum?: string }>
|
|
393
|
+
required?: string[]
|
|
394
|
+
}
|
|
374
395
|
}
|
|
375
396
|
|
|
376
397
|
/**
|
|
@@ -383,6 +404,16 @@ export interface ManifestAction {
|
|
|
383
404
|
* them on this side is what lets the React component stay a dumb renderer that
|
|
384
405
|
* would work for any app.
|
|
385
406
|
*/
|
|
407
|
+
/** One field of an object-creating action's form. */
|
|
408
|
+
export interface ActionFormField {
|
|
409
|
+
/** Also the tag its value is written to — see {@link ManifestAction.input}. */
|
|
410
|
+
name: string
|
|
411
|
+
type: string
|
|
412
|
+
required: boolean
|
|
413
|
+
/** When the property names a vocabulary, its entries, already looked up. */
|
|
414
|
+
options?: { value: string; label: string; colour?: string }[]
|
|
415
|
+
}
|
|
416
|
+
|
|
386
417
|
export interface ResolvedAction {
|
|
387
418
|
id: string
|
|
388
419
|
label: string
|
|
@@ -393,9 +424,23 @@ export interface ResolvedAction {
|
|
|
393
424
|
* something this version does not recognise — both meaning *unknown*.
|
|
394
425
|
*/
|
|
395
426
|
effect?: ActionEffect
|
|
396
|
-
control: 'select' | 'pubkey' | 'text'
|
|
427
|
+
control: 'select' | 'pubkey' | 'text' | 'form'
|
|
397
428
|
/** For `select`: the declared vocabulary, already looked up. */
|
|
398
429
|
options?: { value: string; label: string; colour?: string }[]
|
|
430
|
+
/**
|
|
431
|
+
* For `form`: the fields to draw, in declaration order, with any vocabulary
|
|
432
|
+
* already looked up — the same service `options` performs for a `select`.
|
|
433
|
+
*/
|
|
434
|
+
fields?: ActionFormField[]
|
|
435
|
+
/**
|
|
436
|
+
* For `form`: that the new object hangs under the one the action was invoked
|
|
437
|
+
* on (`emits.toAddressOf: 'self'`).
|
|
438
|
+
*
|
|
439
|
+
* Surfaced because it is the other half of "needs a form **and a parent**",
|
|
440
|
+
* which is what made these unrenderable: a consumer drawing only the
|
|
441
|
+
* properties would publish an orphan.
|
|
442
|
+
*/
|
|
443
|
+
createsUnder?: string
|
|
399
444
|
/** The value this field holds right now, so a control can show it. */
|
|
400
445
|
current?: string
|
|
401
446
|
field?: string
|
|
@@ -422,12 +467,50 @@ function resolveActions(
|
|
|
422
467
|
const out: ResolvedAction[] = []
|
|
423
468
|
for (const action of manifest.actions ?? []) {
|
|
424
469
|
if (!applies(action)) continue
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
470
|
+
/*
|
|
471
|
+
Three shapes now, where there were two.
|
|
472
|
+
|
|
473
|
+
A field-setting change and a comment each render as one control. An action
|
|
474
|
+
that creates a whole new object was skipped, because it "needs a form and
|
|
475
|
+
a parent" — and that was true right up until something drew one. It is
|
|
476
|
+
the single most useful cross-app action there is, so the manifest could
|
|
477
|
+
describe it and no app could offer it (PRO-4).
|
|
478
|
+
*/
|
|
428
479
|
const isChange = !!action.emits.field
|
|
429
480
|
const isComment = action.emits.scope === 'address'
|
|
430
|
-
|
|
481
|
+
const isCreation = action.input?.type === 'object' && !!action.input.properties
|
|
482
|
+
if (!isChange && !isComment && !isCreation) continue
|
|
483
|
+
|
|
484
|
+
if (isCreation) {
|
|
485
|
+
const properties = action.input!.properties!
|
|
486
|
+
const required = new Set(action.input!.required ?? [])
|
|
487
|
+
out.push({
|
|
488
|
+
id: action.id,
|
|
489
|
+
label: action.label,
|
|
490
|
+
...(action.description ? { description: action.description } : {}),
|
|
491
|
+
...(action.effect ? { effect: action.effect } : {}),
|
|
492
|
+
control: 'form',
|
|
493
|
+
fields: Object.entries(properties).map(([name, spec]) => ({
|
|
494
|
+
name,
|
|
495
|
+
type: spec.type ?? 'string',
|
|
496
|
+
required: required.has(name),
|
|
497
|
+
...(spec.enum && manifest.vocabularies?.[spec.enum]
|
|
498
|
+
? {
|
|
499
|
+
options: manifest.vocabularies[spec.enum].map((v) => ({
|
|
500
|
+
value: v.value,
|
|
501
|
+
label: v.label,
|
|
502
|
+
colour: v.colour,
|
|
503
|
+
})),
|
|
504
|
+
}
|
|
505
|
+
: {}),
|
|
506
|
+
})),
|
|
507
|
+
// The parent is not a property and never appears in `properties`; it
|
|
508
|
+
// comes from `emits`, and a form that omitted it would publish an
|
|
509
|
+
// orphan the owning app cannot show anywhere.
|
|
510
|
+
...(action.emits.toAddressOf ? { createsUnder: action.emits.toAddressOf } : {}),
|
|
511
|
+
})
|
|
512
|
+
continue
|
|
513
|
+
}
|
|
431
514
|
|
|
432
515
|
const vocab = action.input?.enum ? manifest.vocabularies?.[action.input.enum] : undefined
|
|
433
516
|
out.push({
|
|
@@ -720,7 +803,18 @@ function byOrder(a: SignedEvent, b: SignedEvent): number {
|
|
|
720
803
|
* several paragraphs establishing is the worst available outcome, because a
|
|
721
804
|
* blank card reads as "that app is broken". It was never caught because Ship
|
|
722
805
|
* is the only app that had ever published a manifest, and Ship folds. Nothing
|
|
723
|
-
* in RFC 0.4 §13.1 makes `records` mandatory
|
|
806
|
+
* in RFC 0.4 §13.1 makes `records` mandatory — but SPEC §7 did, and that is the
|
|
807
|
+
* document a stranger implements from. It said an app **MUST** publish a
|
|
808
|
+
* manifest whose content carries `records` and `projections`, so the code was
|
|
809
|
+
* right and the specification was wrong, in a way that would have made anyone
|
|
810
|
+
* following the text declare change events they do not have. SPEC §7.1 now says
|
|
811
|
+
* `records` is OPTIONAL and states the consumer's obligation as a MUST: render
|
|
812
|
+
* the projection, treat every `fold` slot as absent, fall through to its
|
|
813
|
+
* `default`, and do not refuse (PRO-12).
|
|
814
|
+
*
|
|
815
|
+
* The citation is corrected in place rather than deleted because the two
|
|
816
|
+
* documents disagreeing is the thing worth remembering: reasoning from the RFC
|
|
817
|
+
* alone reached the right behaviour and the wrong justification.
|
|
724
818
|
*
|
|
725
819
|
* The substitute rule below is only ever used to *fold*, never to query, and
|
|
726
820
|
* that distinction is load-bearing. The first attempt used `changeKind: -1` as
|
|
@@ -745,13 +839,71 @@ function foldRuleOf(manifest: Manifest): RecordsRule {
|
|
|
745
839
|
)
|
|
746
840
|
}
|
|
747
841
|
|
|
842
|
+
/**
|
|
843
|
+
* What content model a body is written in — SPEC §13.4.
|
|
844
|
+
*
|
|
845
|
+
* Three values, not two, and not the raw tag string. A consumer has to make a
|
|
846
|
+
* three-way decision and **must not guess the third**:
|
|
847
|
+
*
|
|
848
|
+
* - `marker` — the §13.2 dialect. The default, permanently. 731 published
|
|
849
|
+
* bodies carry no tag and none of them can be given one, so this is not a
|
|
850
|
+
* migration window that closes.
|
|
851
|
+
* - `blocks` — a §13.3 JSON block document.
|
|
852
|
+
* - `unknown` — a format declared after this runtime was written. Render the
|
|
853
|
+
* body as plain text; §13.5 says declining to format is conformant, and
|
|
854
|
+
* parsing it as either known model is what §13 forbids outright.
|
|
855
|
+
*
|
|
856
|
+
* It is resolved here rather than left to each consumer for the same reason
|
|
857
|
+
* `CLOSED_WIDGETS` is: two copies of `tag === undefined ? marker : tag ===
|
|
858
|
+
* 'estiva-blocks-1' ? blocks : text` disagree the first time a third format
|
|
859
|
+
* exists, and the disagreement shows up as one app rendering JSON at a person.
|
|
860
|
+
*/
|
|
861
|
+
export type ContentFormat = 'marker' | 'blocks' | 'unknown'
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* The one slot in SPEC §7.2's closed set that means "structured content".
|
|
865
|
+
*
|
|
866
|
+
* Named rather than inlined because the runtime keys behaviour on it twice —
|
|
867
|
+
* whether a value carries a content format, and whether it may be truncated —
|
|
868
|
+
* and a consumer reads `slots.body` to find it.
|
|
869
|
+
*/
|
|
870
|
+
export const BODY_SLOT = 'body'
|
|
871
|
+
|
|
872
|
+
/** The tag SPEC §13.4 defines. Absence is a declaration, not an omission. */
|
|
873
|
+
export const CONTENT_FORMAT_TAG = 'content-format'
|
|
874
|
+
|
|
875
|
+
/** The one format §13.3 names today. */
|
|
876
|
+
export const BLOCK_DOCUMENT_FORMAT = 'estiva-blocks-1'
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* The content model an event's body is in.
|
|
880
|
+
*
|
|
881
|
+
* **Decided by the tag alone.** §13.4 is explicit that a reader MUST NOT decide
|
|
882
|
+
* by inspecting the body: a legacy description that happens to begin with `{`
|
|
883
|
+
* is marker text, because it carries no tag.
|
|
884
|
+
*/
|
|
885
|
+
export function contentFormatOf(event: SignedEvent): ContentFormat {
|
|
886
|
+
const declared = tagValue(event, CONTENT_FORMAT_TAG)
|
|
887
|
+
if (declared === undefined || declared === '') return 'marker'
|
|
888
|
+
return declared === BLOCK_DOCUMENT_FORMAT ? 'blocks' : 'unknown'
|
|
889
|
+
}
|
|
890
|
+
|
|
748
891
|
function foldChanges(changes: SignedEvent[], rule: RecordsRule) {
|
|
749
|
-
const fields: Record<string, { value: string; by: string; at: number }> = {}
|
|
892
|
+
const fields: Record<string, { value: string; by: string; at: number; format: ContentFormat }> = {}
|
|
750
893
|
for (const change of [...changes].sort(byOrder)) {
|
|
751
894
|
const field = tagValue(change, rule.fieldTag)
|
|
752
895
|
const value = tagValue(change, rule.valueTag)
|
|
753
896
|
if (!field || value === undefined) continue // a partial change sets nothing
|
|
754
|
-
|
|
897
|
+
// The format travels with the *winning* change, not with the object. SPEC
|
|
898
|
+
// §13.4: "an app MUST read the tag from the event it took the value from,
|
|
899
|
+
// never from the object's root" — a description created as marker text and
|
|
900
|
+
// later edited into blocks is a root with no tag and a change with one.
|
|
901
|
+
fields[field] = {
|
|
902
|
+
value,
|
|
903
|
+
by: change.pubkey,
|
|
904
|
+
at: change.created_at,
|
|
905
|
+
format: contentFormatOf(change),
|
|
906
|
+
}
|
|
755
907
|
}
|
|
756
908
|
return fields
|
|
757
909
|
}
|
|
@@ -845,6 +997,19 @@ export interface ResolvedSlot {
|
|
|
845
997
|
colour?: string
|
|
846
998
|
/** True when the value is a pubkey and should be shown as a person. */
|
|
847
999
|
isPubkey?: boolean
|
|
1000
|
+
/**
|
|
1001
|
+
* The content model this value is written in — SPEC §13.4.
|
|
1002
|
+
*
|
|
1003
|
+
* Set only for a slot whose source can carry a body (`{field: "content"}`, or
|
|
1004
|
+
* a `fold` a change event has actually set). **Undefined means the value is
|
|
1005
|
+
* not a body at all** — a tag, a seed, a default — not that it is marker
|
|
1006
|
+
* text. `'marker'` is what "a body with no declared format" resolves to, and
|
|
1007
|
+
* that is permanent rather than a migration state.
|
|
1008
|
+
*
|
|
1009
|
+
* A consumer rendering `slots.body` must branch on this. Parsing a block
|
|
1010
|
+
* document as marker text, or the reverse, is forbidden outright by §13.
|
|
1011
|
+
*/
|
|
1012
|
+
format?: ContentFormat
|
|
848
1013
|
/**
|
|
849
1014
|
* The underlying field this slot reads, when it has a name.
|
|
850
1015
|
*
|
|
@@ -879,21 +1044,79 @@ function firstTag(root: SignedEvent, tag: string | string[] | undefined): string
|
|
|
879
1044
|
return undefined
|
|
880
1045
|
}
|
|
881
1046
|
|
|
1047
|
+
/**
|
|
1048
|
+
* A slot's value, and the content model it is written in when it has one.
|
|
1049
|
+
*
|
|
1050
|
+
* `format` is set only where the value can be a **body**, which is true in two
|
|
1051
|
+
* places and no others:
|
|
1052
|
+
*
|
|
1053
|
+
* 1. `{field: "content"}` — an event's own body, content by definition. It gets
|
|
1054
|
+
* a format whatever slot it was declared into, which is what keeps the PRO-8
|
|
1055
|
+
* guard below reachable: `{"subtitle": {"field": "content", "truncate": 120}}`
|
|
1056
|
+
* is a mis-declaration, and it must not also escape the truncation rule.
|
|
1057
|
+
* 2. a slot named `body` — SPEC §7.2's closed set gives that name one meaning,
|
|
1058
|
+
* "structured content per §13". A `fold` is how §13.4's own example arrives
|
|
1059
|
+
* (a description created as marker text and later edited into blocks is a
|
|
1060
|
+
* root with no tag and a change with one), so it must be able to carry a
|
|
1061
|
+
* format — but only when the slot says it is a body.
|
|
1062
|
+
*
|
|
1063
|
+
* Everything else reports nothing. A tag is a short scalar and is never a body,
|
|
1064
|
+
* so a `title` reports no format even on an event whose *content* is a block
|
|
1065
|
+
* document; and a folded `lead` is a pubkey, not prose. Reporting `marker` for
|
|
1066
|
+
* those would be a claim about a value that is not content at all.
|
|
1067
|
+
*/
|
|
1068
|
+
interface RawSlotValue {
|
|
1069
|
+
value: string
|
|
1070
|
+
format?: ContentFormat
|
|
1071
|
+
}
|
|
1072
|
+
|
|
882
1073
|
function rawSlotValue(
|
|
883
1074
|
spec: SlotSpec,
|
|
884
1075
|
root: SignedEvent,
|
|
885
|
-
folded: Record<string, { value: string }>,
|
|
886
|
-
|
|
1076
|
+
folded: Record<string, { value: string; format?: ContentFormat }>,
|
|
1077
|
+
isBody: boolean,
|
|
1078
|
+
): RawSlotValue | undefined {
|
|
887
1079
|
// `fold` first, and a spec may carry both: a field that starts as a tag on
|
|
888
1080
|
// the root event and is then overridden by changes (a project's lead is the
|
|
889
1081
|
// case in hand). Reading the tag first would render the value the object was
|
|
890
1082
|
// created with forever — which is exactly what someone sees right after
|
|
891
1083
|
// reassigning it from here.
|
|
892
1084
|
if (spec.fold) {
|
|
893
|
-
|
|
1085
|
+
const change = folded[spec.fold]
|
|
1086
|
+
if (change) return { value: change.value, format: isBody ? (change.format ?? 'marker') : undefined }
|
|
1087
|
+
/*
|
|
1088
|
+
Nobody has changed this field, so the object's own creation value stands.
|
|
1089
|
+
§7.2 rule 2 covers seeding from a tag; `field: "content"` seeds from the
|
|
1090
|
+
event body, and the two compose — tag first, then content.
|
|
1091
|
+
|
|
1092
|
+
**Needed because a description is where an app puts its body and `content`
|
|
1093
|
+
is where the body goes.** Ship's issue description is
|
|
1094
|
+
`fields.description?.value ?? event.content` and its project description
|
|
1095
|
+
puts a `description` tag between the two (`fold.ts`), and neither could be
|
|
1096
|
+
declared before this. The nearest expressible declarations were both
|
|
1097
|
+
wrong in the way §7.2 rule 1 already warns about: `{field: "content"}`
|
|
1098
|
+
alone renders the value the object was created with for ever, and
|
|
1099
|
+
`{fold: "description"}` alone renders blank for every object nobody has
|
|
1100
|
+
edited — which is most of them, and blank reads as "that app is broken"
|
|
1101
|
+
(PEE-10).
|
|
1102
|
+
|
|
1103
|
+
The seed tag reports no format: a tag is a scalar. Content does, because
|
|
1104
|
+
it is the event's body whatever slot it was declared into — the same rule
|
|
1105
|
+
the direct `field: "content"` branch below follows, and what keeps the
|
|
1106
|
+
truncation guard reachable.
|
|
1107
|
+
*/
|
|
1108
|
+
const seedTag = firstTag(root, spec.tag)
|
|
1109
|
+
if (seedTag !== undefined) return { value: seedTag }
|
|
1110
|
+
if (spec.field === 'content' && root.content !== '') {
|
|
1111
|
+
return { value: root.content, format: contentFormatOf(root) }
|
|
1112
|
+
}
|
|
1113
|
+
return spec.default === undefined ? undefined : { value: spec.default }
|
|
894
1114
|
}
|
|
895
|
-
if (spec.tag)
|
|
896
|
-
|
|
1115
|
+
if (spec.tag) {
|
|
1116
|
+
const value = firstTag(root, spec.tag)
|
|
1117
|
+
return value === undefined ? undefined : { value }
|
|
1118
|
+
}
|
|
1119
|
+
if (spec.field === 'content') return { value: root.content, format: contentFormatOf(root) }
|
|
897
1120
|
/*
|
|
898
1121
|
`pubkey` — the event's author — added by PRO-6.
|
|
899
1122
|
|
|
@@ -908,21 +1131,37 @@ function rawSlotValue(
|
|
|
908
1131
|
in a new source. Paired with `as: "pubkey"` it renders as a person, which is
|
|
909
1132
|
what a message wants as its title everywhere it appears.
|
|
910
1133
|
*/
|
|
911
|
-
if (spec.field === 'pubkey') return root.pubkey
|
|
1134
|
+
if (spec.field === 'pubkey') return { value: root.pubkey }
|
|
912
1135
|
return undefined
|
|
913
1136
|
}
|
|
914
1137
|
|
|
915
1138
|
function resolveSlot(
|
|
916
1139
|
spec: SlotSpec,
|
|
917
1140
|
root: SignedEvent,
|
|
918
|
-
folded: Record<string, { value: string }>,
|
|
1141
|
+
folded: Record<string, { value: string; format?: ContentFormat }>,
|
|
919
1142
|
manifest: Manifest,
|
|
1143
|
+
name?: string,
|
|
920
1144
|
): ResolvedSlot | null {
|
|
921
|
-
const
|
|
1145
|
+
const source = rawSlotValue(spec, root, folded, name === BODY_SLOT)
|
|
1146
|
+
|
|
1147
|
+
if (source === undefined || source.value === '') return null
|
|
1148
|
+
const raw = source.value
|
|
922
1149
|
|
|
923
|
-
|
|
1150
|
+
/*
|
|
1151
|
+
PRO-8's rule, now enforceable rather than only written down.
|
|
1152
|
+
|
|
1153
|
+
`truncate` is a plain-text operation. Ship's manifest carried a comment
|
|
1154
|
+
saying so and a type that forbade the pairing, but nothing stopped another
|
|
1155
|
+
app publishing `{"field": "content", "truncate": 120}` — and the consumer
|
|
1156
|
+
would happily slice 120 characters out of a JSON block document and render
|
|
1157
|
+
the fragment. That is the PRO-8 defect exactly: output that is wrong and
|
|
1158
|
+
cannot tell that it is wrong.
|
|
924
1159
|
|
|
925
|
-
|
|
1160
|
+
Marker text is still truncated. It degrades honestly — a cut `**bold` is
|
|
1161
|
+
visibly a cut, and 548 published messages are written in it.
|
|
1162
|
+
*/
|
|
1163
|
+
const structured = source.format === 'blocks' || source.format === 'unknown'
|
|
1164
|
+
let value = structured ? raw : truncate(raw, spec.truncate)
|
|
926
1165
|
let colour: string | undefined
|
|
927
1166
|
if (spec.map) {
|
|
928
1167
|
const entry = manifest.vocabularies?.[spec.map]?.find((v) => v.value === raw)
|
|
@@ -938,6 +1177,11 @@ function resolveSlot(
|
|
|
938
1177
|
colour,
|
|
939
1178
|
isPubkey: spec.as === 'pubkey',
|
|
940
1179
|
field: spec.fold ?? (Array.isArray(spec.tag) ? spec.tag[0] : spec.tag),
|
|
1180
|
+
// Present only on a slot that can carry a body. Absent is the honest shape
|
|
1181
|
+
// for a title read from a tag — and it keeps the key out of every existing
|
|
1182
|
+
// consumer's deep comparisons, which is not the reason but is a real cost
|
|
1183
|
+
// avoided: `format: undefined` is an own property to `deepStrictEqual`.
|
|
1184
|
+
...(source.format ? { format: source.format } : {}),
|
|
941
1185
|
}
|
|
942
1186
|
}
|
|
943
1187
|
|
|
@@ -951,7 +1195,7 @@ function resolveSlot(
|
|
|
951
1195
|
function resolveSlots(
|
|
952
1196
|
projection: { slots: Record<string, SlotSpec | SlotSpec[]> },
|
|
953
1197
|
root: SignedEvent,
|
|
954
|
-
folded: Record<string, { value: string }>,
|
|
1198
|
+
folded: Record<string, { value: string; format?: ContentFormat }>,
|
|
955
1199
|
manifest: Manifest,
|
|
956
1200
|
): { slots: Record<string, ResolvedSlot>; meta: ResolvedSlot[] } {
|
|
957
1201
|
const slots: Record<string, ResolvedSlot> = {}
|
|
@@ -959,11 +1203,13 @@ function resolveSlots(
|
|
|
959
1203
|
for (const [name, spec] of Object.entries(projection.slots)) {
|
|
960
1204
|
if (Array.isArray(spec)) {
|
|
961
1205
|
for (const one of spec) {
|
|
1206
|
+
// An array spec collects into `meta`, so the name it was declared under
|
|
1207
|
+
// is not the slot's meaning — nothing in an array is a body.
|
|
962
1208
|
const value = resolveSlot(one, root, folded, manifest)
|
|
963
1209
|
if (value) meta.push(value)
|
|
964
1210
|
}
|
|
965
1211
|
} else {
|
|
966
|
-
const value = resolveSlot(spec, root, folded, manifest)
|
|
1212
|
+
const value = resolveSlot(spec, root, folded, manifest, name)
|
|
967
1213
|
if (value) slots[name] = value
|
|
968
1214
|
}
|
|
969
1215
|
}
|
|
@@ -1099,7 +1345,7 @@ function buildObject(args: {
|
|
|
1099
1345
|
pointer: AddressPointer
|
|
1100
1346
|
manifest: Manifest
|
|
1101
1347
|
projection: { widget: string | string[]; slots: Record<string, SlotSpec | SlotSpec[]> }
|
|
1102
|
-
folded: Record<string, { value: string }>
|
|
1348
|
+
folded: Record<string, { value: string; format?: ContentFormat }>
|
|
1103
1349
|
viaRecommendation: boolean
|
|
1104
1350
|
webTemplate?: string
|
|
1105
1351
|
comments?: ForeignObject['comments']
|
|
@@ -1124,7 +1370,7 @@ function buildObject(args: {
|
|
|
1124
1370
|
// *is* keyed on is the first, which is the one the app writes today. The
|
|
1125
1371
|
// rest are only there to keep older records rendering.
|
|
1126
1372
|
const field = spec.fold ?? (Array.isArray(spec.tag) ? spec.tag[0] : spec.tag)
|
|
1127
|
-
const raw = field
|
|
1373
|
+
const raw = field ? rawSlotValue(spec, root, folded, false)?.value : undefined
|
|
1128
1374
|
if (field && raw !== undefined) held[field] = raw
|
|
1129
1375
|
}
|
|
1130
1376
|
const objectAddress = pointerToAddress(pointer)
|
|
@@ -2112,7 +2358,7 @@ export async function resolveFolderProject(
|
|
|
2112
2358
|
const ticketChanges = changesFor(pointerToAddress(ticketPointer))
|
|
2113
2359
|
const folded = foldChanges(ticketChanges, records)
|
|
2114
2360
|
const status = statusSpec ? resolveSlot(statusSpec, event, folded, manifest) : null
|
|
2115
|
-
const raw = statusSpec ? rawSlotValue(statusSpec, event, folded) : undefined
|
|
2361
|
+
const raw = statusSpec ? rawSlotValue(statusSpec, event, folded, false)?.value : undefined
|
|
2116
2362
|
|
|
2117
2363
|
// The owning app's declaration first, the label guess only if it has none.
|
|
2118
2364
|
const stage = declaredStage(manifest, statusSpec, raw)
|
|
@@ -2168,6 +2414,95 @@ export async function resolveFolderProject(
|
|
|
2168
2414
|
* Returns a string on refusal rather than throwing — every failure here is
|
|
2169
2415
|
* something a user should read.
|
|
2170
2416
|
*/
|
|
2417
|
+
/**
|
|
2418
|
+
* NIP-01's parameterized-replaceable range. An event in it is addressed by
|
|
2419
|
+
* `(kind, pubkey, d)`, so one created without a `d` has no address — it cannot
|
|
2420
|
+
* be referenced, commented on, or acted upon, and the owning app will not find
|
|
2421
|
+
* it where it looks.
|
|
2422
|
+
*/
|
|
2423
|
+
const isAddressableKind = (kind: number) => kind >= 30000 && kind < 40000
|
|
2424
|
+
|
|
2425
|
+
/**
|
|
2426
|
+
* The event an object-creating action publishes.
|
|
2427
|
+
*
|
|
2428
|
+
* Split out because it shares almost nothing with a change: the tags come from
|
|
2429
|
+
* the form rather than from `records`, and the result is a new object rather
|
|
2430
|
+
* than a statement about an existing one.
|
|
2431
|
+
*
|
|
2432
|
+
* **Every refusal names what was allowed**, and that is the requirement rather
|
|
2433
|
+
* than a nicety. The owning app cannot enforce any of this — anyone can publish
|
|
2434
|
+
* anything — so a consumer that guesses is the one putting junk in a shared
|
|
2435
|
+
* record, and a consumer told only "invalid" cannot do better next time.
|
|
2436
|
+
*/
|
|
2437
|
+
function buildCreationEvent(args: {
|
|
2438
|
+
declared: ManifestAction
|
|
2439
|
+
vocabularies?: Manifest['vocabularies']
|
|
2440
|
+
address: string
|
|
2441
|
+
folder: string
|
|
2442
|
+
value: string | Record<string, string>
|
|
2443
|
+
newId?: string
|
|
2444
|
+
pubkey: string
|
|
2445
|
+
createdAtMs: number
|
|
2446
|
+
}): UnsignedActionEvent | string {
|
|
2447
|
+
const { declared, address, folder, value, newId } = args
|
|
2448
|
+
const properties = declared.input!.properties!
|
|
2449
|
+
const required = declared.input!.required ?? []
|
|
2450
|
+
|
|
2451
|
+
if (typeof value === 'string') {
|
|
2452
|
+
return `"${declared.label}" takes a form: ${Object.keys(properties).join(', ')}.`
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
const allowed = Object.keys(properties)
|
|
2456
|
+
for (const name of Object.keys(value)) {
|
|
2457
|
+
if (!allowed.includes(name)) {
|
|
2458
|
+
return `"${name}" is not a field of "${declared.label}" — it takes ${allowed.join(', ')}.`
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
for (const name of required) {
|
|
2462
|
+
if (!value[name]?.trim()) return `"${name}" is required by "${declared.label}".`
|
|
2463
|
+
}
|
|
2464
|
+
/*
|
|
2465
|
+
A property may name a vocabulary of its own, checked exactly as a scalar
|
|
2466
|
+
action's is — the honour system does not get weaker because there are
|
|
2467
|
+
several fields. Ship declares none today (`add-issue` takes a bare title),
|
|
2468
|
+
so this is a path an app grows into rather than one in use.
|
|
2469
|
+
*/
|
|
2470
|
+
for (const [name, held] of Object.entries(value)) {
|
|
2471
|
+
const vocabName = properties[name]?.enum
|
|
2472
|
+
if (!vocabName || !held) continue
|
|
2473
|
+
const vocab = args.vocabularies?.[vocabName] ?? []
|
|
2474
|
+
if (!vocab.some((entry) => entry.value === held)) {
|
|
2475
|
+
return `"${held}" is not one of ${vocab.map((e) => e.value).join(', ')}.`
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
if (isAddressableKind(declared.emits.kind) && !newId) {
|
|
2480
|
+
return `Creating a kind ${declared.emits.kind} needs an identifier, and none was supplied.`
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
const tags: string[][] = []
|
|
2484
|
+
if (isAddressableKind(declared.emits.kind)) tags.push(['d', newId!])
|
|
2485
|
+
// A property's name is the tag it writes. See `ManifestAction.input`.
|
|
2486
|
+
for (const [name, held] of Object.entries(value)) {
|
|
2487
|
+
if (held !== '') tags.push([name, held])
|
|
2488
|
+
}
|
|
2489
|
+
// The parent. `toAddressOf: "self"` names the object the action was invoked
|
|
2490
|
+
// on; any other value is a shape nothing declares yet, and guessing at one
|
|
2491
|
+
// would publish a link the owning app never asked for.
|
|
2492
|
+
if (declared.emits.setTag && declared.emits.toAddressOf === 'self') {
|
|
2493
|
+
tags.push([declared.emits.setTag, address])
|
|
2494
|
+
}
|
|
2495
|
+
tags.push(['h', folder])
|
|
2496
|
+
|
|
2497
|
+
return {
|
|
2498
|
+
pubkey: args.pubkey,
|
|
2499
|
+
created_at: Math.floor(args.createdAtMs / 1000),
|
|
2500
|
+
kind: declared.emits.kind,
|
|
2501
|
+
tags,
|
|
2502
|
+
content: '',
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2171
2506
|
export function buildActionEvent(args: {
|
|
2172
2507
|
manifest: { records?: RecordsRule; actions?: ManifestAction[]; vocabularies?: Manifest['vocabularies'] }
|
|
2173
2508
|
kind: number
|
|
@@ -2177,7 +2512,20 @@ export function buildActionEvent(args: {
|
|
|
2177
2512
|
objectAuthor: string
|
|
2178
2513
|
folder: string
|
|
2179
2514
|
actionId: string
|
|
2180
|
-
|
|
2515
|
+
/**
|
|
2516
|
+
* A scalar for a change or a comment; `{ property: value }` for an
|
|
2517
|
+
* object-creating action, whose form has several fields.
|
|
2518
|
+
*/
|
|
2519
|
+
value: string | Record<string, string>
|
|
2520
|
+
/**
|
|
2521
|
+
* A fresh identifier for an object being created, when its kind is
|
|
2522
|
+
* parameterized-replaceable and therefore needs a `d`.
|
|
2523
|
+
*
|
|
2524
|
+
* Supplied rather than generated: ADR 0002 §10 constraint 2 — the runtime
|
|
2525
|
+
* reaches for nothing and is handed everything. It also makes the built event
|
|
2526
|
+
* a pure function of its inputs, which is what lets a test assert on one.
|
|
2527
|
+
*/
|
|
2528
|
+
newId?: string
|
|
2181
2529
|
pubkey: string
|
|
2182
2530
|
createdAtMs: number
|
|
2183
2531
|
}): UnsignedActionEvent | string {
|
|
@@ -2192,6 +2540,16 @@ export function buildActionEvent(args: {
|
|
|
2192
2540
|
return `"${declared.label}" does not apply to a kind ${kind}.`
|
|
2193
2541
|
}
|
|
2194
2542
|
|
|
2543
|
+
// An object-creating action is a different event entirely — a new object
|
|
2544
|
+
// rather than a change to one — so it branches before the scalar path.
|
|
2545
|
+
if (declared.input?.type === 'object' && declared.input.properties) {
|
|
2546
|
+
return buildCreationEvent({ ...args, declared, vocabularies: manifest.vocabularies })
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
if (typeof value !== 'string') {
|
|
2550
|
+
return `"${declared.label}" takes a single value, not a form.`
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2195
2553
|
// Validate against the manifest's own vocabulary. The owning app cannot
|
|
2196
2554
|
// enforce this — anyone can publish anything (RFC_UPDATES.md §3) — so a
|
|
2197
2555
|
// consumer that skips the check is the one putting junk in the shared record.
|