@crombolo/transfer 0.0.0-stage → 0.9.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/src/load.ts ADDED
@@ -0,0 +1,360 @@
1
+ /**
2
+ * A bundle on disk → `ContentSet`, validated.
3
+ *
4
+ * Reading is strict about shape and about integrity, and says which file was
5
+ * wrong, because everything downstream assumes a bundle is well formed: the
6
+ * check reports on what it would do, and by then a malformed node file would be
7
+ * a crash rather than a finding.
8
+ */
9
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import {
12
+ BUNDLE_FORMAT_VERSION,
13
+ BUNDLE_SECTIONS,
14
+ type BundleKind,
15
+ type BundleManifest,
16
+ type BundleNode,
17
+ type BundleProblem,
18
+ type BundleSection,
19
+ type BundleValue,
20
+ type BundleVariant,
21
+ type ContentSet,
22
+ } from './model.ts'
23
+ import { blobPath, bundleIntegrity, MANIFEST_FILE, nodePath, sectionOf } from './write.ts'
24
+
25
+ export interface LoadedBundle {
26
+ set: ContentSet | undefined
27
+ problems: BundleProblem[]
28
+ /**
29
+ * The carried section files, by their path inside the bundle, as paths on
30
+ * disk — the structure and views an install applies. Empty for a
31
+ * content-only bundle, which is every version 1 bundle.
32
+ */
33
+ files: Map<string, string>
34
+ /**
35
+ * The media files the bundle carries, by store key, as paths on disk.
36
+ *
37
+ * Paths rather than bytes: a bundle of a media library is as large as the
38
+ * library, and nothing here needs it in memory at once.
39
+ */
40
+ blobs: Map<string, string>
41
+ }
42
+
43
+ const KINDS: readonly BundleKind[] = ['document', 'media', 'element', 'blueprint']
44
+
45
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
46
+ typeof v === 'object' && v !== null && !Array.isArray(v)
47
+
48
+ const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
49
+ const nullableStr = (v: unknown): string | null | undefined =>
50
+ v === null ? null : typeof v === 'string' ? v : undefined
51
+
52
+ function parseVariant(
53
+ raw: unknown,
54
+ file: string,
55
+ problems: BundleProblem[],
56
+ ): BundleVariant | undefined {
57
+ if (!isRecord(raw)) {
58
+ problems.push({ file, message: 'a variant is not an object' })
59
+ return undefined
60
+ }
61
+ const name = str(raw.name)
62
+ const culture = nullableStr(raw.culture)
63
+ const segment = nullableStr(raw.segment)
64
+ if (name === undefined || culture === undefined || segment === undefined) {
65
+ problems.push({ file, message: 'a variant needs culture, segment and name' })
66
+ return undefined
67
+ }
68
+ return { culture, segment, name, published: raw.published === true }
69
+ }
70
+
71
+ function parseValue(
72
+ raw: unknown,
73
+ file: string,
74
+ problems: BundleProblem[],
75
+ ): BundleValue | undefined {
76
+ if (!isRecord(raw)) {
77
+ problems.push({ file, message: 'a value is not an object' })
78
+ return undefined
79
+ }
80
+ const property = str(raw.property)
81
+ const editor = str(raw.editor)
82
+ const culture = nullableStr(raw.culture)
83
+ const segment = nullableStr(raw.segment)
84
+ if (
85
+ property === undefined ||
86
+ editor === undefined ||
87
+ culture === undefined ||
88
+ segment === undefined
89
+ ) {
90
+ problems.push({ file, message: 'a value needs property, editor, culture and segment' })
91
+ return undefined
92
+ }
93
+ const references = Array.isArray(raw.references)
94
+ ? raw.references.flatMap((r) => (typeof r === 'string' ? [r] : []))
95
+ : undefined
96
+ return { property, culture, segment, editor, value: raw.value, references }
97
+ }
98
+
99
+ export function parseNode(
100
+ text: string,
101
+ file: string,
102
+ problems: BundleProblem[],
103
+ ): BundleNode | undefined {
104
+ let raw: unknown
105
+ try {
106
+ raw = JSON.parse(text)
107
+ } catch (error) {
108
+ problems.push({ file, message: `not JSON: ${(error as Error).message}` })
109
+ return undefined
110
+ }
111
+ if (!isRecord(raw)) {
112
+ problems.push({ file, message: 'not an object' })
113
+ return undefined
114
+ }
115
+ const key = str(raw.key)
116
+ const kind = str(raw.kind) as BundleKind | undefined
117
+ const contentType = isRecord(raw.contentType) ? raw.contentType : undefined
118
+ const typeKey = str(contentType?.key)
119
+ const typeAlias = str(contentType?.alias)
120
+ if (!key) problems.push({ file, message: 'no key' })
121
+ if (!kind || !KINDS.includes(kind))
122
+ problems.push({ file, message: `kind must be one of ${KINDS.join(', ')}` })
123
+ if (!typeAlias) problems.push({ file, message: 'no contentType.alias' })
124
+ const parent = nullableStr(raw.parent)
125
+ if (parent === undefined) problems.push({ file, message: 'parent must be a key or null' })
126
+ if (!key || !kind || !KINDS.includes(kind) || !typeAlias || parent === undefined) return undefined
127
+
128
+ const variants = Array.isArray(raw.variants)
129
+ ? raw.variants.flatMap((v) => {
130
+ const parsed = parseVariant(v, file, problems)
131
+ return parsed ? [parsed] : []
132
+ })
133
+ : []
134
+ const values = Array.isArray(raw.values)
135
+ ? raw.values.flatMap((v) => {
136
+ const parsed = parseValue(v, file, problems)
137
+ return parsed ? [parsed] : []
138
+ })
139
+ : []
140
+ if (variants.length === 0) problems.push({ file, message: 'no variants: a node needs a name' })
141
+
142
+ return {
143
+ key,
144
+ kind,
145
+ contentType: { key: typeKey ?? '', alias: typeAlias },
146
+ parent,
147
+ sortOrder: typeof raw.sortOrder === 'number' ? raw.sortOrder : 0,
148
+ template: nullableStr(raw.template) ?? null,
149
+ variants,
150
+ values,
151
+ }
152
+ }
153
+
154
+ function parseManifest(text: string, problems: BundleProblem[]): BundleManifest | undefined {
155
+ let raw: unknown
156
+ try {
157
+ raw = JSON.parse(text)
158
+ } catch (error) {
159
+ problems.push({ file: MANIFEST_FILE, message: `not JSON: ${(error as Error).message}` })
160
+ return undefined
161
+ }
162
+ if (!isRecord(raw)) {
163
+ problems.push({ file: MANIFEST_FILE, message: 'not an object' })
164
+ return undefined
165
+ }
166
+ const formatVersion = typeof raw.formatVersion === 'number' ? raw.formatVersion : undefined
167
+ if (formatVersion === undefined) {
168
+ problems.push({ file: MANIFEST_FILE, message: 'no formatVersion' })
169
+ return undefined
170
+ }
171
+ if (formatVersion > BUNDLE_FORMAT_VERSION) {
172
+ problems.push({
173
+ file: MANIFEST_FILE,
174
+ message: `format ${formatVersion} is newer than this version understands (${BUNDLE_FORMAT_VERSION}) — upgrade crombolo to import it`,
175
+ })
176
+ return undefined
177
+ }
178
+ const provenance = isRecord(raw.provenance) ? raw.provenance : {}
179
+ const selector = isRecord(raw.selector) ? raw.selector : {}
180
+ const dependencies = isRecord(raw.dependencies) ? raw.dependencies : {}
181
+ const schema = isRecord(dependencies.schema) ? dependencies.schema : {}
182
+ const keys = (v: unknown): string[] =>
183
+ Array.isArray(v) ? v.flatMap((k) => (typeof k === 'string' ? [k] : [])) : []
184
+
185
+ return {
186
+ formatVersion,
187
+ id: str(raw.id) ?? '',
188
+ ...(str(raw.label) ? { label: str(raw.label) as string } : {}),
189
+ createdAt: str(raw.createdAt) ?? '',
190
+ createdBy: str(raw.createdBy) ?? '',
191
+ integrity: str(raw.integrity) ?? '',
192
+ snapshot: raw.snapshot === 'drafts' ? 'drafts' : 'published',
193
+ provenance: {
194
+ siteName: str(provenance.siteName) ?? '',
195
+ schemaVersion: str(provenance.schemaVersion) ?? '',
196
+ schemaRevision: str(provenance.schemaRevision) ?? '',
197
+ schemaHash: nullableStr(provenance.schemaHash) ?? null,
198
+ },
199
+ selector: {
200
+ roots: keys(selector.roots),
201
+ descendants: selector.descendants !== false,
202
+ asGiven: keys(selector.asGiven),
203
+ },
204
+ counts: isRecord(raw.counts) ? (raw.counts as BundleManifest['counts']) : {},
205
+ dependencies: {
206
+ carried: keys(dependencies.carried),
207
+ expected: Array.isArray(dependencies.expected)
208
+ ? dependencies.expected.flatMap((e) =>
209
+ isRecord(e) && typeof e.key === 'string'
210
+ ? [
211
+ {
212
+ key: e.key,
213
+ objectType: str(e.objectType) ?? '',
214
+ name: str(e.name) ?? '',
215
+ why: str(e.why) ?? '',
216
+ },
217
+ ]
218
+ : [],
219
+ )
220
+ : [],
221
+ schema: {
222
+ contentTypes: Array.isArray(schema.contentTypes)
223
+ ? schema.contentTypes.flatMap((t) =>
224
+ isRecord(t) && typeof t.alias === 'string'
225
+ ? [{ key: str(t.key) ?? '', alias: t.alias }]
226
+ : [],
227
+ )
228
+ : [],
229
+ components: keys(schema.components),
230
+ languages: keys(schema.languages),
231
+ },
232
+ },
233
+ blobs: Array.isArray(raw.blobs)
234
+ ? raw.blobs.flatMap((b) =>
235
+ isRecord(b) && typeof b.key === 'string'
236
+ ? [
237
+ {
238
+ key: b.key,
239
+ node: str(b.node) ?? '',
240
+ etag: nullableStr(b.etag) ?? null,
241
+ size: typeof b.size === 'number' ? b.size : null,
242
+ included: b.included === true,
243
+ },
244
+ ]
245
+ : [],
246
+ )
247
+ : [],
248
+ ...(isRecord(raw.carries) ? { carries: parseCarries(raw.carries) } : {}),
249
+ }
250
+ }
251
+
252
+ /**
253
+ * The declared sections, dropping any name this version does not know and any
254
+ * path that tries to climb out of the bundle — a manifest is a file that
255
+ * travelled, so what it names is read, never trusted.
256
+ */
257
+ function parseCarries(raw: Record<string, unknown>): Partial<Record<BundleSection, string[]>> {
258
+ const out: Partial<Record<BundleSection, string[]>> = {}
259
+ for (const section of Object.keys(BUNDLE_SECTIONS) as BundleSection[]) {
260
+ const paths = raw[section]
261
+ if (!Array.isArray(paths)) continue
262
+ const safe = paths.flatMap((path) =>
263
+ typeof path === 'string' && sectionOf(path) === section && !path.split('/').includes('..')
264
+ ? [path]
265
+ : [],
266
+ )
267
+ if (safe.length > 0) out[section] = safe.sort()
268
+ }
269
+ return out
270
+ }
271
+
272
+ /** Reads a bundle directory. `set` is undefined when it cannot be trusted at all. */
273
+ export function loadBundle(dir: string): LoadedBundle {
274
+ const problems: BundleProblem[] = []
275
+ const blobs = new Map<string, string>()
276
+ const files = new Map<string, string>()
277
+ const manifestFile = join(dir, MANIFEST_FILE)
278
+ if (!existsSync(manifestFile)) {
279
+ problems.push({ file: MANIFEST_FILE, message: `no bundle at ${dir}` })
280
+ return { set: undefined, problems, blobs, files }
281
+ }
282
+ const manifest = parseManifest(readFileSync(manifestFile, 'utf8'), problems)
283
+ if (!manifest) return { set: undefined, problems, blobs, files }
284
+
285
+ const nodesDir = join(dir, 'nodes')
286
+ const nodeFiles = existsSync(nodesDir)
287
+ ? readdirSync(nodesDir)
288
+ .filter((name) => name.endsWith('.json'))
289
+ .sort()
290
+ : []
291
+ const read: Array<{ path: string; text?: string; bytes?: Uint8Array }> = nodeFiles.map(
292
+ (name) => ({
293
+ path: `nodes/${name}`,
294
+ text: readFileSync(join(nodesDir, name), 'utf8'),
295
+ }),
296
+ )
297
+
298
+ // A blob the manifest says it carries has to be here, and has to be the file
299
+ // the hash was taken over: `included` is a claim, and this is what checks it.
300
+ for (const blob of manifest.blobs) {
301
+ if (!blob.included) continue
302
+ const path = blobPath(blob.key)
303
+ const file = join(dir, path)
304
+ if (!existsSync(file)) {
305
+ problems.push({
306
+ file: path,
307
+ message: 'the manifest says this file is carried, and it is not',
308
+ })
309
+ continue
310
+ }
311
+ blobs.set(blob.key, file)
312
+ read.push({ path, bytes: new Uint8Array(readFileSync(file)) })
313
+ }
314
+
315
+ // Every file the manifest says it carries has to be here, and has to be the
316
+ // file the hash was taken over — the same rule as a blob's `included`.
317
+ for (const [, paths] of Object.entries(manifest.carries ?? {})) {
318
+ for (const path of paths as string[]) {
319
+ const file = join(dir, path)
320
+ if (!existsSync(file)) {
321
+ problems.push({
322
+ file: path,
323
+ message: 'the manifest says this file is carried, and it is not',
324
+ })
325
+ continue
326
+ }
327
+ files.set(path, file)
328
+ read.push({ path, text: readFileSync(file, 'utf8') })
329
+ }
330
+ }
331
+
332
+ // Before shape: a truncated copy produces confusing field errors otherwise.
333
+ const integrity = bundleIntegrity(read)
334
+ if (manifest.integrity && manifest.integrity !== integrity)
335
+ problems.push({
336
+ file: MANIFEST_FILE,
337
+ message: `integrity does not match the node files (${manifest.integrity} vs ${integrity}) — the bundle is incomplete or was edited`,
338
+ })
339
+
340
+ const nodes = read.flatMap((file) => {
341
+ // Only `nodes/` holds nodes. Blobs have no text, and a carried section is a
342
+ // TOML or TSX file that has no business being parsed as one.
343
+ if (file.text === undefined || !file.path.startsWith('nodes/')) return []
344
+ const node = parseNode(file.text, file.path, problems)
345
+ if (node && nodePath(node.key) !== file.path)
346
+ problems.push({
347
+ file: file.path,
348
+ message: `holds node ${node.key}, so it should be named after it`,
349
+ })
350
+ return node ? [node] : []
351
+ })
352
+
353
+ const seen = new Set<string>()
354
+ for (const node of nodes) {
355
+ if (seen.has(node.key)) problems.push({ file: nodePath(node.key), message: 'appears twice' })
356
+ seen.add(node.key)
357
+ }
358
+
359
+ return { set: { manifest, nodes }, problems, blobs, files }
360
+ }
package/src/model.ts ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The bundle: content selected out of one environment and reviewable on its way
3
+ * to another. `docs/13-content-transfer.md`.
4
+ *
5
+ * Everything travels by uuid and alias, never by integer id, because the ids
6
+ * differ between environments and the keys do not. A content type is matched by
7
+ * key first — tooling writes keys into `schema/*.toml`, so they are stable
8
+ * across environments — with the alias as the readable fallback.
9
+ */
10
+
11
+ /** Bumped when a change to the shapes below stops an older reader understanding a bundle. */
12
+ export const BUNDLE_FORMAT_VERSION = 2
13
+
14
+ /**
15
+ * The version a bundle carrying only content is still written as.
16
+ *
17
+ * A content-only bundle is byte-for-byte what version 1 was, so it keeps saying
18
+ * 1 and an older node can still read it. Only a bundle that carries a section
19
+ * beyond its content needs a reader that knows about `carries`, and that one
20
+ * says 2 — which an older node refuses with a message naming the upgrade,
21
+ * rather than failing an integrity check it cannot explain.
22
+ */
23
+ export const CONTENT_ONLY_FORMAT_VERSION = 1
24
+
25
+ /**
26
+ * The sections a bundle can carry beyond its content, and the directory each
27
+ * one occupies.
28
+ *
29
+ * Logical names, not the destination's directory names: a site may hold its
30
+ * components anywhere (`componentsDir`), so the artifact names what a file *is*
31
+ * and the importer decides where it goes. A bundle written against one site's
32
+ * layout therefore installs into another's.
33
+ *
34
+ * There is one section for components, not two. Umbraco's split of templates
35
+ * and partial views was a Razor concern (`docs/05-rendering.md`), and a bundle
36
+ * that carried it would push the distinction back into every site that
37
+ * installed one.
38
+ */
39
+ export const BUNDLE_SECTIONS = {
40
+ schema: 'schema',
41
+ components: 'components',
42
+ styles: 'styles',
43
+ scripts: 'scripts',
44
+ dictionary: 'dictionary',
45
+ } as const
46
+
47
+ export type BundleSection = keyof typeof BUNDLE_SECTIONS
48
+
49
+ /**
50
+ * The kinds that travel. `member` is deliberately absent: members are personal
51
+ * data, and a bundle is a file that gets copied around and committed.
52
+ */
53
+ export type BundleKind = 'document' | 'media' | 'element' | 'blueprint'
54
+
55
+ /** Which snapshot of a node's values the bundle carries. */
56
+ export type BundleSnapshot = 'published' | 'drafts'
57
+
58
+ export interface BundleValue {
59
+ property: string
60
+ culture: string | null
61
+ segment: string | null
62
+ /** The editor that wrote it, so the destination can refuse a property whose editor changed. */
63
+ editor: string
64
+ value: unknown
65
+ /** Node keys this value refers to, extracted at export so the importer need not re-parse. */
66
+ references?: string[]
67
+ }
68
+
69
+ /**
70
+ * One culture (or the invariant `null`) of a node: its name and whether it was
71
+ * published at the source. Shaped to feed the repository's own variant input.
72
+ */
73
+ export interface BundleVariant {
74
+ culture: string | null
75
+ segment: string | null
76
+ name: string
77
+ published: boolean
78
+ }
79
+
80
+ export interface BundleNode {
81
+ key: string
82
+ kind: BundleKind
83
+ contentType: { key: string; alias: string }
84
+ /** The parent's key, or null for a node at the tree root. */
85
+ parent: string | null
86
+ sortOrder: number
87
+ /** A component alias — the path under `components/`, so aliases travel, not keys. */
88
+ template: string | null
89
+ variants: BundleVariant[]
90
+ values: BundleValue[]
91
+ }
92
+
93
+ /** Something the bundle refers to but does not carry: it must already exist at the destination. */
94
+ export interface BundleDependency {
95
+ key: string
96
+ objectType: string
97
+ name: string
98
+ /** Why it is needed, in words, so a blocking finding can explain itself. */
99
+ why: string
100
+ }
101
+
102
+ /** A media file the bundle's media nodes name. Blobs travel only when asked for. */
103
+ export interface BundleBlob {
104
+ /** The media store key, as the value names it. */
105
+ key: string
106
+ etag: string | null
107
+ size: number | null
108
+ included: boolean
109
+ /** The media node that names it. */
110
+ node: string
111
+ }
112
+
113
+ export interface BundleDependencies {
114
+ /** Keys carried in the bundle. */
115
+ carried: string[]
116
+ /** Keys that must already exist at the destination. */
117
+ expected: BundleDependency[]
118
+ /** What the destination's schema and views must provide. */
119
+ schema: {
120
+ contentTypes: Array<{ key: string; alias: string }>
121
+ components: string[]
122
+ languages: string[]
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Where a bundle came from. Recorded and printed, never gated on: development
128
+ * runs at revision 0 by design, so refusing a bundle whose revision is higher
129
+ * than the destination's would refuse every transfer out of production. What
130
+ * the destination actually has to satisfy is checked element by element.
131
+ */
132
+ export interface BundleProvenance {
133
+ schemaVersion: string
134
+ schemaRevision: string
135
+ schemaHash: string | null
136
+ siteName: string
137
+ }
138
+
139
+ export interface BundleSelector {
140
+ /** The resolved subtree roots. */
141
+ roots: string[]
142
+ descendants: boolean
143
+ /** What was typed on the command line, for readability when a path was given. */
144
+ asGiven: string[]
145
+ }
146
+
147
+ export interface BundleManifest {
148
+ formatVersion: number
149
+ id: string
150
+ /**
151
+ * What a person calls this bundle — a created package's name, when that is
152
+ * where it came from.
153
+ *
154
+ * It travels with the artifact rather than being supplied at import time, so
155
+ * a downloaded bundle still knows what it is: two files are otherwise
156
+ * distinguishable only by filename, which is the first thing to be lost.
157
+ */
158
+ label?: string
159
+ createdAt: string
160
+ createdBy: string
161
+ /** `sha256-…` over the node files, so a half-copied bundle is refused rather than imported. */
162
+ integrity: string
163
+ snapshot: BundleSnapshot
164
+ provenance: BundleProvenance
165
+ selector: BundleSelector
166
+ counts: Partial<Record<BundleKind, number>>
167
+ dependencies: BundleDependencies
168
+ blobs: BundleBlob[]
169
+ /**
170
+ * The files this bundle carries beyond its content and media, by section, in
171
+ * path order — the structure and the views that make a bundle installable
172
+ * into a site that has neither.
173
+ *
174
+ * A claim the reader checks, exactly as `blobs.included` is: every path named
175
+ * here has to be present, and the integrity hash covers it. Absent on a
176
+ * content-only bundle, which is what version 1 always was.
177
+ */
178
+ carries?: Partial<Record<BundleSection, string[]>>
179
+ }
180
+
181
+ /**
182
+ * A bundle read into memory: the manifest and its nodes. Named for
183
+ * `SchemaSet` in `@crombolo/schema`, which plays the same part for structure.
184
+ */
185
+ export interface ContentSet {
186
+ manifest: BundleManifest
187
+ nodes: BundleNode[]
188
+ }
189
+
190
+ /** A problem with the bundle itself, as opposed to a finding about applying it. */
191
+ export interface BundleProblem {
192
+ file: string
193
+ message: string
194
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * What a stored value refers to.
3
+ *
4
+ * `ReferenceRepository` answers the other direction — what refers to *this* node
5
+ * — by scanning for a known key. Exporting needs the outbound direction, from a
6
+ * value to the keys inside it, and nothing knew how to do that.
7
+ *
8
+ * Two spellings travel, as `references.ts` in `@crombolo/data` documents: a
9
+ * picker stores `umb://document/<hex>` or a bare uuid, dashed or not. Element
10
+ * pickers store a bare `Guid[]`, and rich text and block editors hold both
11
+ * inside JSON, so the scan goes through structures and through JSON held in a
12
+ * string.
13
+ *
14
+ * **These are candidates, not references.** An undashed 32-hex run is
15
+ * indistinguishable from any other 32 hex characters, so the caller resolves
16
+ * each against `node.unique_id` and discards what does not exist. Being wrong
17
+ * in this direction is cheap; missing a reference is not.
18
+ */
19
+ import { keyFromReference } from '@crombolo/core'
20
+
21
+ /** Dashed uuids, and 32-hex runs that are not part of a longer hex string. */
22
+ const DASHED = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
23
+ const PLAIN = /(?<![0-9a-f])[0-9a-f]{32}(?![0-9a-f])/gi
24
+
25
+ function fromString(text: string, into: Set<string>): void {
26
+ for (const match of text.matchAll(DASHED)) {
27
+ const key = keyFromReference(match[0])
28
+ if (key) into.add(key)
29
+ }
30
+ for (const match of text.matchAll(PLAIN)) {
31
+ const key = keyFromReference(match[0])
32
+ if (key) into.add(key)
33
+ }
34
+ }
35
+
36
+ function walk(value: unknown, into: Set<string>, depth: number): void {
37
+ // Block editors nest, but not deeply; the guard is against a cycle in a
38
+ // structure we did not build, not against legitimate nesting.
39
+ if (depth > 32) return
40
+ if (typeof value === 'string') {
41
+ fromString(value, into)
42
+ // A value is often JSON in a string. Scanning the text already found every
43
+ // key in it, so parsing adds nothing — except where a key is escaped or
44
+ // split across the encoding, which is why the parse is attempted at all.
45
+ if (/^\s*[[{]/.test(value)) {
46
+ try {
47
+ walk(JSON.parse(value), into, depth + 1)
48
+ } catch {
49
+ // Not JSON after all; the text scan above stands.
50
+ }
51
+ }
52
+ return
53
+ }
54
+ if (Array.isArray(value)) {
55
+ for (const item of value) walk(item, into, depth + 1)
56
+ return
57
+ }
58
+ if (typeof value === 'object' && value !== null) {
59
+ for (const item of Object.values(value)) walk(item, into, depth + 1)
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Every key-shaped candidate inside a value, dashed and lowercased. Resolve
65
+ * them against real nodes before treating any of them as a reference.
66
+ */
67
+ export function referenceCandidates(value: unknown): string[] {
68
+ const found = new Set<string>()
69
+ walk(value, found, 0)
70
+ return [...found].sort()
71
+ }