@crombolo/transfer 0.10.0 → 0.12.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.
- package/package.json +3 -3
- package/src/check.ts +221 -28
- package/src/export.ts +46 -0
- package/src/import.ts +331 -193
- package/src/index.ts +3 -0
- package/src/load.ts +166 -2
- package/src/model.ts +59 -1
- package/src/package.ts +7 -0
- package/src/people-apply.ts +572 -0
- package/src/people.ts +167 -0
- package/src/resolutions.ts +69 -7
- package/src/revert.ts +44 -5
- package/src/seal.ts +129 -0
- package/src/write.ts +173 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/transfer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Content transfer for crombolo: bundles of content moved between environments.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/core": "0.
|
|
32
|
-
"@crombolo/data": "0.
|
|
31
|
+
"@crombolo/core": "0.12.1",
|
|
32
|
+
"@crombolo/data": "0.12.1"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/check.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type NodeRow,
|
|
27
27
|
} from '@crombolo/data'
|
|
28
28
|
import type { BundleNode, ContentSet } from './model.ts'
|
|
29
|
+
import { type PeoplePlan, planPeople } from './people-apply.ts'
|
|
29
30
|
import { referenceCandidates } from './references.ts'
|
|
30
31
|
|
|
31
32
|
/** What a person can decide about a node the destination already has. */
|
|
@@ -36,6 +37,50 @@ export const RESOLUTIONS: readonly Resolution[] = ['take-bundle', 'keep-local',
|
|
|
36
37
|
export const isResolution = (value: string): value is Resolution =>
|
|
37
38
|
(RESOLUTIONS as readonly string[]).includes(value)
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The answers for one node. `"*"` answers the whole node; `"<alias>"` or
|
|
42
|
+
* `"<alias>@<culture>"` answers one property, and wins over `"*"` for it.
|
|
43
|
+
*/
|
|
44
|
+
export type NodeResolutions = Readonly<Record<string, Resolution>>
|
|
45
|
+
|
|
46
|
+
/** The node itself, in a node's answers. */
|
|
47
|
+
export const WHOLE_NODE = '*'
|
|
48
|
+
|
|
49
|
+
/** How a property is named in a node's answers and in `--resolve`. */
|
|
50
|
+
export const propertyHandle = (alias: string, culture: string | null | undefined): string =>
|
|
51
|
+
culture ? `${alias}@${culture}` : alias
|
|
52
|
+
|
|
53
|
+
/** The answer for one property: its own, its alias's across cultures, the node's, then everyone's. Pure. */
|
|
54
|
+
export function answerFor(
|
|
55
|
+
answers: NodeResolutions | undefined,
|
|
56
|
+
alias: string,
|
|
57
|
+
culture: string | null | undefined,
|
|
58
|
+
all: Resolution | undefined,
|
|
59
|
+
): Resolution | undefined {
|
|
60
|
+
return (
|
|
61
|
+
(culture ? answers?.[propertyHandle(alias, culture)] : undefined) ??
|
|
62
|
+
answers?.[alias] ??
|
|
63
|
+
answers?.[WHOLE_NODE] ??
|
|
64
|
+
all
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Why a node's answers cannot be used: `skip` belongs to a whole node, not a property. Pure. */
|
|
69
|
+
export function nodeResolutionProblems(key: string, answers: Record<string, unknown>): string[] {
|
|
70
|
+
const problems: string[] = []
|
|
71
|
+
for (const [handle, answer] of Object.entries(answers)) {
|
|
72
|
+
if (typeof answer !== 'string' || !isResolution(answer))
|
|
73
|
+
problems.push(
|
|
74
|
+
`"${key}" answers ${handle} with "${String(answer)}", which is not one of ${RESOLUTIONS.join(', ')}`,
|
|
75
|
+
)
|
|
76
|
+
else if (handle !== WHOLE_NODE && answer === 'skip')
|
|
77
|
+
problems.push(
|
|
78
|
+
`"${key}" answers ${handle} with skip, which leaves a whole node alone, not one property`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
return problems
|
|
82
|
+
}
|
|
83
|
+
|
|
39
84
|
/**
|
|
40
85
|
* What to suggest for a finding, as a function of its code — so the table keeps
|
|
41
86
|
* its stable shape and both the CLI and the dashboard render the same advice.
|
|
@@ -44,9 +89,9 @@ export function resolutionsFor(code: string): readonly string[] {
|
|
|
44
89
|
switch (code) {
|
|
45
90
|
case 'local-edit':
|
|
46
91
|
return [
|
|
47
|
-
'--resolve <key>=take-bundle to let the bundle win',
|
|
48
|
-
'--resolve <key>=keep-local to keep what is here',
|
|
49
|
-
'--resolve <key
|
|
92
|
+
'--resolve <key>/<property>=take-bundle to let the bundle win for this property',
|
|
93
|
+
'--resolve <key>/<property>=keep-local to keep what is here for this property',
|
|
94
|
+
'--resolve <key>=<choice> to answer the whole node, skip included',
|
|
50
95
|
'--resolve-all=<choice> to answer every one the same way',
|
|
51
96
|
]
|
|
52
97
|
case 'missing-parent':
|
|
@@ -75,6 +120,58 @@ export function resolutionsFor(code: string): readonly string[] {
|
|
|
75
120
|
return ['rename one of them, or --resolve <key>=skip']
|
|
76
121
|
case 'missing-template':
|
|
77
122
|
return ['deploy the view, and the template attaches on the next save']
|
|
123
|
+
case 'content-sealed':
|
|
124
|
+
return [
|
|
125
|
+
'--passphrase-env <name>, or answer the prompt, with the passphrase it was exported with',
|
|
126
|
+
]
|
|
127
|
+
case 'person-conflict':
|
|
128
|
+
return [
|
|
129
|
+
'--resolve <key>=take-bundle to update the person here with the bundle’s details',
|
|
130
|
+
'--resolve <key>=skip to leave this one out',
|
|
131
|
+
]
|
|
132
|
+
case 'missing-member-type':
|
|
133
|
+
return ['deploy schema/ to this environment first']
|
|
134
|
+
default:
|
|
135
|
+
return []
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** One answer a finding can be given: the handle it is recorded under, and the choice. */
|
|
140
|
+
export interface FindingChoice {
|
|
141
|
+
handle: string
|
|
142
|
+
resolution: Resolution
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const LEAVE_OUT: FindingChoice = { handle: WHOLE_NODE, resolution: 'skip' }
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The answers a finding can be given in the backoffice, as a function of its
|
|
149
|
+
* code, beside `resolutionsFor`'s advice. Recorded under the finding's node,
|
|
150
|
+
* they are what `--resolve` would have set; a finding with none is answered
|
|
151
|
+
* by deploying something, not by choosing.
|
|
152
|
+
*/
|
|
153
|
+
export function choicesFor(finding: {
|
|
154
|
+
code: string
|
|
155
|
+
propertyAlias: string | null
|
|
156
|
+
culture: string | null
|
|
157
|
+
}): readonly FindingChoice[] {
|
|
158
|
+
switch (finding.code) {
|
|
159
|
+
case 'local-edit': {
|
|
160
|
+
if (!finding.propertyAlias) return [LEAVE_OUT]
|
|
161
|
+
const handle = propertyHandle(finding.propertyAlias, finding.culture)
|
|
162
|
+
return [
|
|
163
|
+
{ handle, resolution: 'take-bundle' },
|
|
164
|
+
{ handle, resolution: 'keep-local' },
|
|
165
|
+
LEAVE_OUT,
|
|
166
|
+
]
|
|
167
|
+
}
|
|
168
|
+
case 'person-conflict':
|
|
169
|
+
return [{ handle: WHOLE_NODE, resolution: 'take-bundle' }, LEAVE_OUT]
|
|
170
|
+
case 'local-trashed':
|
|
171
|
+
case 'missing-blob':
|
|
172
|
+
case 'name-collision':
|
|
173
|
+
case 'unresolved-reference':
|
|
174
|
+
return [LEAVE_OUT]
|
|
78
175
|
default:
|
|
79
176
|
return []
|
|
80
177
|
}
|
|
@@ -83,8 +180,8 @@ export function resolutionsFor(code: string): readonly string[] {
|
|
|
83
180
|
export interface CheckOptions {
|
|
84
181
|
/** Re-root the bundle here instead of at the parents it names. */
|
|
85
182
|
under?: NodeRow | undefined
|
|
86
|
-
/**
|
|
87
|
-
resolutions?: Readonly<Record<string,
|
|
183
|
+
/** Decisions by node key: the node's (`"*"`) and its properties'. */
|
|
184
|
+
resolutions?: Readonly<Record<string, NodeResolutions>>
|
|
88
185
|
/** One decision for every node that needs one. */
|
|
89
186
|
resolveAll?: Resolution | undefined
|
|
90
187
|
/** Template aliases with a view on disk; absent means templates are not checked. */
|
|
@@ -94,6 +191,8 @@ export interface CheckOptions {
|
|
|
94
191
|
allowMissingBlobs?: boolean
|
|
95
192
|
/** For linking a finding to the document it is about. */
|
|
96
193
|
backOfficePath?: string
|
|
194
|
+
/** Users here the bundle's may take over by e-mail or username: a site this boot created. */
|
|
195
|
+
adoptUsers?: boolean
|
|
97
196
|
}
|
|
98
197
|
|
|
99
198
|
/** What the import would do to one node. */
|
|
@@ -104,7 +203,10 @@ export interface NodePlan {
|
|
|
104
203
|
action: 'create' | 'update' | 'unchanged' | 'skip'
|
|
105
204
|
/** Property aliases whose value would change. */
|
|
106
205
|
changing: string[]
|
|
206
|
+
/** The node's own answer. */
|
|
107
207
|
resolution: Resolution | undefined
|
|
208
|
+
/** Values kept as they are here, by property handle; the rest come from the bundle. */
|
|
209
|
+
keepLocal?: string[]
|
|
108
210
|
}
|
|
109
211
|
|
|
110
212
|
export interface TransferCheck {
|
|
@@ -114,6 +216,8 @@ export interface TransferCheck {
|
|
|
114
216
|
outstanding: Finding[]
|
|
115
217
|
plan: NodePlan[]
|
|
116
218
|
counts: { create: number; update: number; unchanged: number; skip: number }
|
|
219
|
+
/** What the bundle's people would do here, when it carries any. */
|
|
220
|
+
people?: PeoplePlan
|
|
117
221
|
}
|
|
118
222
|
|
|
119
223
|
const documentLink = (backOfficePath: string | undefined, key: string): string | null =>
|
|
@@ -182,6 +286,31 @@ export async function checkBundle(
|
|
|
182
286
|
link: null,
|
|
183
287
|
})
|
|
184
288
|
|
|
289
|
+
// Nothing can be compared while the values are sealed, and a check that
|
|
290
|
+
// compared them as absent would report every one as cleared.
|
|
291
|
+
if (set.sealed) {
|
|
292
|
+
const sealed = bundleFinding(
|
|
293
|
+
'blocking',
|
|
294
|
+
'content-sealed',
|
|
295
|
+
'this bundle’s content is encrypted — give the passphrase it was exported with to check or import it',
|
|
296
|
+
)
|
|
297
|
+
return {
|
|
298
|
+
bundleId: set.manifest.id,
|
|
299
|
+
findings: [sealed],
|
|
300
|
+
outstanding: [sealed],
|
|
301
|
+
plan,
|
|
302
|
+
counts: { create: 0, update: 0, unchanged: 0, skip: 0 },
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (set.manifest.content === 'omitted')
|
|
306
|
+
findings.push(
|
|
307
|
+
bundleFinding(
|
|
308
|
+
'auto',
|
|
309
|
+
'content-omitted',
|
|
310
|
+
'this bundle was exported without its content: pages are placed and named, and no value here changes',
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
|
|
185
314
|
// Every content type the bundle names, matched by key first and alias second:
|
|
186
315
|
// keys are stable across environments because tooling writes them into the
|
|
187
316
|
// schema files, and the alias is what a person reads in the message.
|
|
@@ -285,7 +414,8 @@ export async function checkBundle(
|
|
|
285
414
|
|
|
286
415
|
for (const node of set.nodes) {
|
|
287
416
|
const name = node.variants[0]?.name ?? node.key
|
|
288
|
-
const
|
|
417
|
+
const answers = resolutions[node.key]
|
|
418
|
+
const resolution = answers?.[WHOLE_NODE] ?? options.resolveAll
|
|
289
419
|
const existing = await nodes.byKey(node.key)
|
|
290
420
|
const editors = await editorsFor(node.contentType.alias)
|
|
291
421
|
const typeMissing = !(await typeFor(node.contentType))
|
|
@@ -491,15 +621,14 @@ export async function checkBundle(
|
|
|
491
621
|
const currentByKey = new Map(
|
|
492
622
|
(current?.values ?? []).map((v) => [`${v.alias}|${v.culture ?? ''}|${v.segment ?? ''}`, v]),
|
|
493
623
|
)
|
|
494
|
-
const
|
|
495
|
-
.
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
.map((value) => value.property)
|
|
624
|
+
const changedValues = node.values.filter((value) => {
|
|
625
|
+
if (!editors.has(value.property)) return false
|
|
626
|
+
const mine = currentByKey.get(
|
|
627
|
+
`${value.property}|${value.culture ?? ''}|${value.segment ?? ''}`,
|
|
628
|
+
)
|
|
629
|
+
return !sameValue(mine?.value, value.value)
|
|
630
|
+
})
|
|
631
|
+
const changing = [...new Set(changedValues.map((value) => value.property))]
|
|
503
632
|
|
|
504
633
|
if (changing.length === 0) {
|
|
505
634
|
plan.push({
|
|
@@ -516,31 +645,94 @@ export async function checkBundle(
|
|
|
516
645
|
// It exists, and the bundle disagrees with it. We cannot know whether
|
|
517
646
|
// somebody edited it here or the bundle is simply newer, so this is a
|
|
518
647
|
// decision rather than a defect — and it is the one an import waits on.
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
648
|
+
// One per property, so each can be answered on its own.
|
|
649
|
+
const keepLocal: string[] = []
|
|
650
|
+
let taking = false
|
|
651
|
+
for (const value of changedValues) {
|
|
652
|
+
const handle = propertyHandle(value.property, value.culture)
|
|
653
|
+
const answer = answerFor(answers, value.property, value.culture, options.resolveAll)
|
|
654
|
+
if (answer === 'keep-local') keepLocal.push(handle)
|
|
655
|
+
else if (answer === 'take-bundle') taking = true
|
|
656
|
+
else
|
|
657
|
+
findings.push({
|
|
658
|
+
kind: 'person',
|
|
659
|
+
code: 'local-edit',
|
|
660
|
+
subjectType: node.kind === 'media' ? 'media' : 'document',
|
|
661
|
+
subjectKey: node.key,
|
|
662
|
+
subjectName: name,
|
|
663
|
+
propertyAlias: value.property,
|
|
664
|
+
culture: value.culture ?? null,
|
|
665
|
+
message: `"${name}" is already here and its ${value.property}${value.culture ? ` (${value.culture})` : ''} differs from the bundle's`,
|
|
666
|
+
link: link(node.key),
|
|
667
|
+
})
|
|
668
|
+
}
|
|
531
669
|
plan.push({
|
|
532
670
|
key: node.key,
|
|
533
671
|
name,
|
|
534
672
|
kind: node.kind,
|
|
535
|
-
action:
|
|
673
|
+
action: taking || keepLocal.length < changedValues.length ? 'update' : 'unchanged',
|
|
536
674
|
changing,
|
|
537
675
|
resolution,
|
|
676
|
+
keepLocal,
|
|
538
677
|
})
|
|
539
678
|
}
|
|
540
679
|
|
|
541
680
|
const counts = { create: 0, update: 0, unchanged: 0, skip: 0 }
|
|
542
681
|
for (const entry of plan) counts[entry.action] += 1
|
|
543
682
|
|
|
683
|
+
// The rules a node carries: a protected page needs its login and error
|
|
684
|
+
// pages, and names groups that may not exist here yet.
|
|
685
|
+
const groupsHere = new Set(
|
|
686
|
+
(
|
|
687
|
+
await db.query<{ text: string }>('SELECT text FROM node WHERE node_object_type = ?', [
|
|
688
|
+
ObjectTypes.MemberGroup,
|
|
689
|
+
])
|
|
690
|
+
).map((row) => String(row.text)),
|
|
691
|
+
)
|
|
692
|
+
for (const group of set.people?.memberGroups ?? []) groupsHere.add(group.name)
|
|
693
|
+
const skipped = new Set(plan.filter((p) => p.action === 'skip').map((p) => p.key))
|
|
694
|
+
for (const node of set.nodes) {
|
|
695
|
+
if (!node.access || skipped.has(node.key)) continue
|
|
696
|
+
const name = node.variants[0]?.name ?? node.key
|
|
697
|
+
const pages = [node.access.loginNode, node.access.errorNode]
|
|
698
|
+
const absent = pages.filter((key) => !carried.has(key))
|
|
699
|
+
const found = new Set((await nodes.byKeys(absent)).map((row) => row.key))
|
|
700
|
+
if (absent.some((key) => !found.has(key)))
|
|
701
|
+
findings.push({
|
|
702
|
+
kind: 'blocking',
|
|
703
|
+
code: 'access-page-missing',
|
|
704
|
+
subjectType: 'document',
|
|
705
|
+
subjectKey: node.key,
|
|
706
|
+
subjectName: name,
|
|
707
|
+
propertyAlias: null,
|
|
708
|
+
culture: null,
|
|
709
|
+
message: `"${name}" is protected, and its sign-in or no-access page is neither in the bundle nor here`,
|
|
710
|
+
link: link(node.key),
|
|
711
|
+
})
|
|
712
|
+
const unknown = node.access.memberGroups.filter((group) => !groupsHere.has(group))
|
|
713
|
+
if (unknown.length > 0)
|
|
714
|
+
findings.push({
|
|
715
|
+
kind: 'auto',
|
|
716
|
+
code: 'access-group-missing',
|
|
717
|
+
subjectType: 'document',
|
|
718
|
+
subjectKey: node.key,
|
|
719
|
+
subjectName: name,
|
|
720
|
+
propertyAlias: null,
|
|
721
|
+
culture: null,
|
|
722
|
+
message: `"${name}" is protected for ${unknown.join(', ')}, which there is no member group for here yet: nobody can see it until there is`,
|
|
723
|
+
link: link(node.key),
|
|
724
|
+
})
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const people = set.people
|
|
728
|
+
? await planPeople(db, set.people, {
|
|
729
|
+
resolutions,
|
|
730
|
+
resolveAll: options.resolveAll,
|
|
731
|
+
adoptUsers: options.adoptUsers,
|
|
732
|
+
})
|
|
733
|
+
: undefined
|
|
734
|
+
if (people) findings.push(...people.findings)
|
|
735
|
+
|
|
544
736
|
return {
|
|
545
737
|
bundleId: set.manifest.id,
|
|
546
738
|
findings,
|
|
@@ -548,5 +740,6 @@ export async function checkBundle(
|
|
|
548
740
|
outstanding: findings.filter((f) => f.kind === 'blocking' || f.kind === 'person'),
|
|
549
741
|
plan,
|
|
550
742
|
counts,
|
|
743
|
+
...(people ? { people } : {}),
|
|
551
744
|
}
|
|
552
745
|
}
|
package/src/export.ts
CHANGED
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
type Db,
|
|
20
20
|
DocumentRepository,
|
|
21
21
|
type NodeRow,
|
|
22
|
+
PublicAccessRepository,
|
|
23
|
+
RedirectRepository,
|
|
22
24
|
} from '@crombolo/data'
|
|
23
25
|
import {
|
|
24
26
|
BUNDLE_FORMAT_VERSION,
|
|
@@ -245,6 +247,38 @@ export async function exportBundle(db: Db, options: ExportOptions): Promise<Expo
|
|
|
245
247
|
blobs.push({ key, node: node.key, etag: null, size: null, included: false })
|
|
246
248
|
}
|
|
247
249
|
|
|
250
|
+
// The rules each page carries: who may see it, what is scheduled, and the
|
|
251
|
+
// routes it used to answer on.
|
|
252
|
+
const access = new PublicAccessRepository(db)
|
|
253
|
+
const redirects = new RedirectRepository(db)
|
|
254
|
+
for (const node of bundleNodes) {
|
|
255
|
+
if (node.kind !== 'document') continue
|
|
256
|
+
const entry = await access.byNodeKey(node.key)
|
|
257
|
+
if (entry)
|
|
258
|
+
node.access = {
|
|
259
|
+
loginNode: entry.loginNodeKey,
|
|
260
|
+
errorNode: entry.errorNodeKey,
|
|
261
|
+
memberGroups: entry.memberGroupNames,
|
|
262
|
+
memberUserNames: entry.memberUserNames,
|
|
263
|
+
}
|
|
264
|
+
const schedule = await repoFor('document').schedules(node.key)
|
|
265
|
+
if (schedule.length > 0)
|
|
266
|
+
node.schedule = schedule.map((s) => ({
|
|
267
|
+
culture: s.culture,
|
|
268
|
+
action: s.action === 'Release' ? ('publish' as const) : ('unpublish' as const),
|
|
269
|
+
date: s.date.toISOString(),
|
|
270
|
+
}))
|
|
271
|
+
const tracked = (await redirects.byDocument(node.key, { take: 1000 })).items.filter(
|
|
272
|
+
(r) => r.source === 'tracked' && r.matchKind === 'exact',
|
|
273
|
+
)
|
|
274
|
+
if (tracked.length > 0)
|
|
275
|
+
node.redirects = tracked.map((r) => ({
|
|
276
|
+
path: r.pattern,
|
|
277
|
+
root: r.rootKey,
|
|
278
|
+
culture: r.culture,
|
|
279
|
+
}))
|
|
280
|
+
}
|
|
281
|
+
|
|
248
282
|
const carried = new Set(bundleNodes.map((n) => n.key))
|
|
249
283
|
|
|
250
284
|
// Anything referenced or parented that the bundle does not carry must already
|
|
@@ -261,6 +295,18 @@ export async function exportBundle(db: Db, options: ExportOptions): Promise<Expo
|
|
|
261
295
|
why: 'referenced by a value in this bundle',
|
|
262
296
|
})
|
|
263
297
|
}
|
|
298
|
+
for (const node of bundleNodes)
|
|
299
|
+
for (const page of node.access ? [node.access.loginNode, node.access.errorNode] : []) {
|
|
300
|
+
if (carried.has(page) || expected.some((e) => e.key === page)) continue
|
|
301
|
+
const row = await nodes.byKey(page)
|
|
302
|
+
if (row)
|
|
303
|
+
expected.push({
|
|
304
|
+
key: row.key,
|
|
305
|
+
objectType: row.objectType ?? '',
|
|
306
|
+
name: row.text ?? '',
|
|
307
|
+
why: `the sign-in or no-access page of ${node.variants[0]?.name ?? node.key}`,
|
|
308
|
+
})
|
|
309
|
+
}
|
|
264
310
|
for (const node of bundleNodes) {
|
|
265
311
|
if (!node.parent || carried.has(node.parent)) continue
|
|
266
312
|
if (expected.some((e) => e.key === node.parent)) continue
|