@phosphorco/bb-cross-references 0.0.0-stage → 0.1.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/dist/react.js ADDED
@@ -0,0 +1,1308 @@
1
+ // src/canonical.ts
2
+ import { sha256 } from "@noble/hashes/sha256";
3
+ import { bytesToHex } from "@noble/hashes/utils";
4
+ var PROTOCOL_VERSION = 1;
5
+ var MAX_RESOURCE_KEYS = 32;
6
+ var MAX_KEY_VALUE_BYTES = 512;
7
+ var MAX_KEY_VALUE_MATERIAL_BYTES = 8192;
8
+ var MAX_CANONICAL_IDENTITY_BYTES = 16 * 1024;
9
+ var MAX_PRESENTATION_BYTES = 4 * 1024;
10
+ var MAX_PRESENTATION_LABEL_BYTES = 256;
11
+ var MAX_PRESENTATION_DETAIL_BYTES = 1024;
12
+ var MAX_PRESENTATION_URL_BYTES = 2048;
13
+ var MAX_TARGETS = 256;
14
+ var MAX_PROJECTION_PAYLOAD_BYTES = 256 * 1024;
15
+ var MAX_MUTATION_ID_BYTES = 128;
16
+ var MACHINE_MONITOR_ROUTE = "/plugins/machine-monitor/machine-monitor";
17
+ var NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/;
18
+ var ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
19
+ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
20
+ var CONTROL_PATTERN = /\p{Cc}/u;
21
+ var encoder = new TextEncoder;
22
+ var SENSITIVE_URL_PARAMETER_NAMES = new Set([
23
+ "access_token",
24
+ "api_key",
25
+ "apikey",
26
+ "authorization",
27
+ "code",
28
+ "cookie",
29
+ "id_token",
30
+ "password",
31
+ "passwd",
32
+ "refresh_token",
33
+ "secret",
34
+ "session",
35
+ "sessionid",
36
+ "sid",
37
+ "sig",
38
+ "signature",
39
+ "token",
40
+ "x-amz-credential",
41
+ "x-amz-security-token",
42
+ "x-amz-signature",
43
+ "x-goog-credential",
44
+ "x-goog-signature",
45
+ "x-ms-signature"
46
+ ]);
47
+
48
+ class CrossReferenceValidationError extends Error {
49
+ constructor(message) {
50
+ super(message);
51
+ this.name = "CrossReferenceValidationError";
52
+ }
53
+ }
54
+ function utf8ByteLength(value) {
55
+ return encoder.encode(value).byteLength;
56
+ }
57
+ function fail(message) {
58
+ throw new CrossReferenceValidationError(message);
59
+ }
60
+ function isPlainObject(value) {
61
+ if (typeof value !== "object" || value === null || Array.isArray(value))
62
+ return false;
63
+ const prototype = Object.getPrototypeOf(value);
64
+ return prototype === Object.prototype || prototype === null;
65
+ }
66
+ function assertPlainObject(value, label) {
67
+ if (!isPlainObject(value))
68
+ fail(`${label} must be a plain object.`);
69
+ if (Reflect.ownKeys(value).some((key) => typeof key !== "string")) {
70
+ fail(`${label} must not contain symbol keys.`);
71
+ }
72
+ }
73
+ function assertExactObjectKeys(value, allowed, label) {
74
+ const allowedSet = new Set(allowed);
75
+ for (const key of Object.keys(value)) {
76
+ if (!allowedSet.has(key))
77
+ fail(`${label} contains unknown field ${key}.`);
78
+ }
79
+ }
80
+ function assertWellFormedUnicode(value, label) {
81
+ for (let index = 0;index < value.length; index += 1) {
82
+ const codeUnit = value.charCodeAt(index);
83
+ if (codeUnit >= 55296 && codeUnit <= 56319) {
84
+ const next = value.charCodeAt(index + 1);
85
+ if (next < 56320 || next > 57343)
86
+ fail(`${label} contains malformed Unicode.`);
87
+ index += 1;
88
+ } else if (codeUnit >= 56320 && codeUnit <= 57343) {
89
+ fail(`${label} contains malformed Unicode.`);
90
+ }
91
+ }
92
+ }
93
+ function assertSafeText(value, label, maxBytes, options = {}) {
94
+ assertWellFormedUnicode(value, label);
95
+ if (CONTROL_PATTERN.test(value))
96
+ fail(`${label} contains a Unicode control character.`);
97
+ if (options.nonblank === true && value.trim().length === 0)
98
+ fail(`${label} must not be blank.`);
99
+ if (utf8ByteLength(value) > maxBytes)
100
+ fail(`${label} exceeds its ${maxBytes}-byte limit.`);
101
+ return value;
102
+ }
103
+ function assertName(value, label) {
104
+ if (typeof value !== "string" || !NAME_PATTERN.test(value)) {
105
+ fail(`${label} must match ^[a-z][a-z0-9._-]{0,63}$.`);
106
+ }
107
+ return value;
108
+ }
109
+ function validateBbId(value, label) {
110
+ if (typeof value !== "string" || !ID_PATTERN.test(value)) {
111
+ fail(`${label} must match ^[A-Za-z0-9_-]{1,128}$.`);
112
+ }
113
+ return value;
114
+ }
115
+ function validateBbIdentity(provider, keys) {
116
+ if (provider !== "bb")
117
+ return;
118
+ const names = Object.keys(keys);
119
+ if (names.length === 1 && names[0] === "project") {
120
+ validateBbId(keys.project, "projectId");
121
+ return;
122
+ }
123
+ if (names.length === 2 && names[0] === "project" && names[1] === "thread") {
124
+ validateBbId(keys.project, "projectId");
125
+ validateBbId(keys.thread, "threadId");
126
+ return;
127
+ }
128
+ if (names.length === 2 && names[0] === "page" && names[1] === "plugin" && keys.page === "machine-monitor" && keys.plugin === "machine-monitor") {
129
+ return;
130
+ }
131
+ fail("provider bb must use a v1 project, thread, or Machine Monitor identity.");
132
+ }
133
+ function hasSensitiveParameterName(params) {
134
+ for (const name of params.keys()) {
135
+ if (SENSITIVE_URL_PARAMETER_NAMES.has(name.toLowerCase()))
136
+ return true;
137
+ }
138
+ return false;
139
+ }
140
+ function validateUrlIdentity(provider, keys) {
141
+ if (provider !== "url")
142
+ return;
143
+ const names = Object.keys(keys);
144
+ if (names.length !== 1 || names[0] !== "href")
145
+ fail("provider url must use exactly the href key.");
146
+ const href = keys.href;
147
+ let parsed;
148
+ try {
149
+ parsed = new URL(href);
150
+ } catch {
151
+ fail("url href must be a valid HTTP(S) URL.");
152
+ }
153
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
154
+ fail("url href must use HTTP(S).");
155
+ if (parsed.username !== "" || parsed.password !== "")
156
+ fail("url href must not contain credentials.");
157
+ if (hasSensitiveParameterName(parsed.searchParams) || parsed.hash.startsWith("#") && hasSensitiveParameterName(new URLSearchParams(parsed.hash.slice(1)))) {
158
+ fail("url href must not contain credential-shaped query or fragment parameters.");
159
+ }
160
+ if (parsed.href !== href)
161
+ fail("url href must use canonical URL serialization.");
162
+ }
163
+ function validateProducerPluginId(value) {
164
+ return assertName(value, "producerPluginId");
165
+ }
166
+ function validateMutationId(value) {
167
+ if (typeof value !== "string" || utf8ByteLength(value) > MAX_MUTATION_ID_BYTES || !UUID_PATTERN.test(value)) {
168
+ fail("mutationId must be a lower-case UUID string.");
169
+ }
170
+ return value;
171
+ }
172
+ function validateRevision(value, label, minimum) {
173
+ if (!Number.isSafeInteger(value) || value < minimum) {
174
+ fail(`${label} must be a safe integer >= ${minimum}.`);
175
+ }
176
+ return value;
177
+ }
178
+ function validateDigest(value, label = "payloadDigest") {
179
+ if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) {
180
+ fail(`${label} must be lower-case SHA-256 hex.`);
181
+ }
182
+ return value;
183
+ }
184
+ function canonicalizeKeyMap(value) {
185
+ assertPlainObject(value, "keys");
186
+ const keyNames = Object.keys(value);
187
+ if (keyNames.length < 1 || keyNames.length > MAX_RESOURCE_KEYS) {
188
+ fail(`keys must contain between 1 and ${MAX_RESOURCE_KEYS} entries.`);
189
+ }
190
+ let materialBytes = 0;
191
+ const entries = keyNames.map((key) => {
192
+ assertName(key, "key name");
193
+ const rawValue = value[key];
194
+ if (typeof rawValue !== "string")
195
+ fail(`key ${key} value must be a string.`);
196
+ const normalizedValue = rawValue.normalize("NFC");
197
+ const safeValue = assertSafeText(normalizedValue, `key ${key} value`, MAX_KEY_VALUE_BYTES, { nonblank: true });
198
+ materialBytes += utf8ByteLength(key) + utf8ByteLength(safeValue);
199
+ return [key, safeValue];
200
+ });
201
+ if (materialBytes > MAX_KEY_VALUE_MATERIAL_BYTES) {
202
+ fail(`combined key/value material exceeds its ${MAX_KEY_VALUE_MATERIAL_BYTES}-byte limit.`);
203
+ }
204
+ entries.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
205
+ const keys = Object.fromEntries(entries);
206
+ return { keys, canonicalKeysJson: JSON.stringify(keys) };
207
+ }
208
+ function canonicalizeIdentity(value) {
209
+ assertPlainObject(value, "resource identity");
210
+ assertExactObjectKeys(value, ["provider", "keys"], "resource identity");
211
+ const provider = assertName(value.provider, "provider");
212
+ const { keys, canonicalKeysJson } = canonicalizeKeyMap(value.keys);
213
+ validateBbIdentity(provider, keys);
214
+ validateUrlIdentity(provider, keys);
215
+ const identityObject = { provider, keys };
216
+ const canonicalIdentityJson = JSON.stringify(identityObject);
217
+ if (utf8ByteLength(canonicalIdentityJson) > MAX_CANONICAL_IDENTITY_BYTES) {
218
+ fail(`canonical identity exceeds its ${MAX_CANONICAL_IDENTITY_BYTES}-byte limit.`);
219
+ }
220
+ return {
221
+ provider,
222
+ keys,
223
+ canonicalKeysJson,
224
+ canonicalIdentityJson,
225
+ identityDigest: sha256Hex(canonicalIdentityJson)
226
+ };
227
+ }
228
+ function canonicalizePresentation(value) {
229
+ assertPlainObject(value, "presentation");
230
+ assertExactObjectKeys(value, ["label", "detail", "url"], "presentation");
231
+ if (typeof value.label !== "string")
232
+ fail("presentation.label must be a string.");
233
+ const label = assertSafeText(value.label, "presentation.label", MAX_PRESENTATION_LABEL_BYTES, { nonblank: true });
234
+ const presentation = { label };
235
+ if (value.detail !== undefined) {
236
+ if (typeof value.detail !== "string")
237
+ fail("presentation.detail must be a string when supplied.");
238
+ presentation.detail = assertSafeText(value.detail, "presentation.detail", MAX_PRESENTATION_DETAIL_BYTES, { nonblank: true });
239
+ }
240
+ if (value.url !== undefined) {
241
+ if (typeof value.url !== "string")
242
+ fail("presentation.url must be a string when supplied.");
243
+ const url = assertSafeText(value.url, "presentation.url", MAX_PRESENTATION_URL_BYTES, { nonblank: true });
244
+ if (url.startsWith("/") && !url.startsWith("//") && !url.includes("\\")) {
245
+ presentation.url = url;
246
+ } else if (url.startsWith("http://") || url.startsWith("https://")) {
247
+ try {
248
+ const parsed = new URL(url);
249
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
250
+ fail("presentation.url must use http or https.");
251
+ presentation.url = url;
252
+ } catch {
253
+ fail("presentation.url must be a valid http or https URL.");
254
+ }
255
+ } else {
256
+ fail("presentation.url must be a same-origin route or an http(s) URL.");
257
+ }
258
+ }
259
+ const presentationJson = JSON.stringify(presentation);
260
+ if (utf8ByteLength(presentationJson) > MAX_PRESENTATION_BYTES) {
261
+ fail(`presentation exceeds its ${MAX_PRESENTATION_BYTES}-byte limit.`);
262
+ }
263
+ return { presentation, presentationJson };
264
+ }
265
+ function canonicalizeResource(value) {
266
+ assertPlainObject(value, "resource");
267
+ assertExactObjectKeys(value, ["provider", "keys", "presentation"], "resource");
268
+ const identity = canonicalizeIdentity({ provider: value.provider, keys: value.keys });
269
+ const presentation = canonicalizePresentation(value.presentation);
270
+ if (identity.provider === "url" && presentation.presentation.url !== identity.keys.href) {
271
+ fail("provider url presentation.url must equal its canonical href identity.");
272
+ }
273
+ return { ...identity, ...presentation };
274
+ }
275
+ function resourceIdentityOf(resource) {
276
+ return { provider: resource.provider, keys: resource.keys };
277
+ }
278
+ function projectIdentity(projectId) {
279
+ return { provider: "bb", keys: { project: validateBbId(projectId, "projectId") } };
280
+ }
281
+ function threadIdentity(projectId, threadId) {
282
+ return {
283
+ provider: "bb",
284
+ keys: {
285
+ project: validateBbId(projectId, "projectId"),
286
+ thread: validateBbId(threadId, "threadId")
287
+ }
288
+ };
289
+ }
290
+ function machineMonitorIdentity() {
291
+ return { provider: "bb", keys: { page: "machine-monitor", plugin: "machine-monitor" } };
292
+ }
293
+ function projectResource(projectId, presentation) {
294
+ return { ...projectIdentity(projectId), presentation };
295
+ }
296
+ function threadResource(projectId, threadId, presentation) {
297
+ return { ...threadIdentity(projectId, threadId), presentation };
298
+ }
299
+ function machineMonitorResource(presentation = {
300
+ label: "Machine Monitor",
301
+ url: MACHINE_MONITOR_ROUTE
302
+ }) {
303
+ return { ...machineMonitorIdentity(), presentation };
304
+ }
305
+ function serializeResource(resource) {
306
+ return {
307
+ provider: resource.provider,
308
+ keys: resource.keys,
309
+ presentation: resource.presentation
310
+ };
311
+ }
312
+ function sha256Hex(value) {
313
+ return bytesToHex(sha256(encoder.encode(value)));
314
+ }
315
+ function projectionPayloadJson(producerPluginId, source, tombstone, targets) {
316
+ const payload = {
317
+ protocolVersion: PROTOCOL_VERSION,
318
+ producerPluginId,
319
+ source: serializeResource(source),
320
+ tombstone,
321
+ targets: targets.map(serializeResource)
322
+ };
323
+ const json = JSON.stringify(payload);
324
+ if (utf8ByteLength(json) > MAX_PROJECTION_PAYLOAD_BYTES) {
325
+ fail(`projection payload exceeds its ${MAX_PROJECTION_PAYLOAD_BYTES}-byte limit.`);
326
+ }
327
+ return json;
328
+ }
329
+ function projectionPayloadDigest(producerPluginId, source, tombstone, targets) {
330
+ return sha256Hex(projectionPayloadJson(producerPluginId, source, tombstone, targets));
331
+ }
332
+ function isExactBbThreadResource(resource) {
333
+ const keys = Object.keys(resource.keys);
334
+ return resource.provider === "bb" && keys.length === 2 && keys[0] === "project" && keys[1] === "thread" && ID_PATTERN.test(resource.keys.project) && ID_PATTERN.test(resource.keys.thread);
335
+ }
336
+ function isUrlResource(resource) {
337
+ return resource.provider === "url" && Object.keys(resource.keys).length === 1 && resource.keys.href != null && resource.presentation.url === resource.keys.href;
338
+ }
339
+ function normalizeThreadOrUrlReference(target) {
340
+ const resource = canonicalizeResource(target);
341
+ if (!isExactBbThreadResource(resource) && !isUrlResource(resource)) {
342
+ throw new CrossReferenceValidationError("References must be exact BB threads or safe HTTP(S) URLs.");
343
+ }
344
+ if (isExactBbThreadResource(resource) && resource.presentation.url !== undefined && resource.presentation.url !== `/projects/${resource.keys.project}/threads/${resource.keys.thread}` && resource.presentation.url !== `/threads/${resource.keys.thread}`) {
345
+ throw new CrossReferenceValidationError("Native thread presentation URL must match its thread identity.");
346
+ }
347
+ return resource;
348
+ }
349
+ function urlResource(href, presentation) {
350
+ const canonicalHref = new URL(href).href;
351
+ return { provider: "url", keys: { href: canonicalHref }, presentation: { ...presentation, url: canonicalHref } };
352
+ }
353
+ var MAX_REFERENCE_URL_BYTES = 512;
354
+ var MAX_REFERENCE_LABEL_BYTES = 256;
355
+ function hasSensitiveUrlParameter(url) {
356
+ return hasSensitiveParameterName(url.searchParams) || url.hash.startsWith("#") && hasSensitiveParameterName(new URLSearchParams(url.hash.slice(1)));
357
+ }
358
+ function referenceLabelError(value) {
359
+ if (value.trim().length === 0)
360
+ return "Give this external reference a name.";
361
+ if (utf8ByteLength(value) > MAX_REFERENCE_LABEL_BYTES) {
362
+ return `Reference names are limited to ${MAX_REFERENCE_LABEL_BYTES} UTF-8 bytes.`;
363
+ }
364
+ return null;
365
+ }
366
+
367
+ // src/index.ts
368
+ import { z } from "zod";
369
+ var FORWARD_REFERENCE_CHECK_LIMIT = 10;
370
+ function assertInputRecord(value) {
371
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
372
+ throw new CrossReferenceValidationError("projection input must be an object.");
373
+ }
374
+ const fields = Object.keys(value).sort();
375
+ if (fields.join(",") !== "expectedRevision,mutationId,payloadDigest,producerPluginId,protocolVersion,revision,source,targets,tombstone") {
376
+ throw new CrossReferenceValidationError("projection input fields are invalid.");
377
+ }
378
+ }
379
+ function normalizeProjectionCommand(input) {
380
+ assertInputRecord(input);
381
+ if (input.protocolVersion !== PROTOCOL_VERSION) {
382
+ throw new CrossReferenceValidationError(`protocolVersion must be ${PROTOCOL_VERSION}.`);
383
+ }
384
+ const producerPluginId = validateProducerPluginId(input.producerPluginId);
385
+ const mutationId = validateMutationId(input.mutationId);
386
+ const revision = validateRevision(input.revision, "revision", 1);
387
+ const expectedRevision = validateRevision(input.expectedRevision, "expectedRevision", 0);
388
+ if (typeof input.tombstone !== "boolean") {
389
+ throw new CrossReferenceValidationError("tombstone must be a boolean.");
390
+ }
391
+ if (!Array.isArray(input.targets) || input.targets.length > MAX_TARGETS) {
392
+ throw new CrossReferenceValidationError(`targets must contain at most ${MAX_TARGETS} resources.`);
393
+ }
394
+ const source = canonicalizeResource(input.source);
395
+ const targets = input.targets.map((target) => canonicalizeResource(target));
396
+ const identitySet = new Set;
397
+ for (const target of targets) {
398
+ if (identitySet.has(target.canonicalIdentityJson)) {
399
+ throw new CrossReferenceValidationError("targets must not contain duplicate resource identities.");
400
+ }
401
+ identitySet.add(target.canonicalIdentityJson);
402
+ }
403
+ if (input.tombstone && targets.length !== 0) {
404
+ throw new CrossReferenceValidationError("a tombstone must have an empty target list.");
405
+ }
406
+ const payloadJson = projectionPayloadJson(producerPluginId, source, input.tombstone, targets);
407
+ const computedPayloadDigest = sha256Hex(payloadJson);
408
+ const payloadDigest = validateDigest(input.payloadDigest);
409
+ if (payloadDigest !== computedPayloadDigest) {
410
+ throw new CrossReferenceValidationError("payloadDigest does not match the canonical projection payload.");
411
+ }
412
+ return {
413
+ protocolVersion: PROTOCOL_VERSION,
414
+ producerPluginId,
415
+ mutationId,
416
+ source,
417
+ revision,
418
+ expectedRevision,
419
+ payloadDigest,
420
+ tombstone: input.tombstone,
421
+ targets,
422
+ payloadJson,
423
+ computedPayloadDigest
424
+ };
425
+ }
426
+ function normalizeIdentityInput(input) {
427
+ return canonicalizeIdentity(input);
428
+ }
429
+ function defaultPageSize(pageSize) {
430
+ if (pageSize === undefined)
431
+ return 25;
432
+ const validated = validateRevision(pageSize, "pageSize", 1);
433
+ if (validated > 100) {
434
+ throw new CrossReferenceValidationError("pageSize must be between 1 and 100.");
435
+ }
436
+ return validated;
437
+ }
438
+ var namePattern = /^[a-z][a-z0-9._-]{0,63}$/;
439
+ var uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
440
+ var digestPattern = /^[0-9a-f]{64}$/;
441
+ var safeInteger = (schema) => schema.refine(Number.isSafeInteger, { message: "must be a safe integer" });
442
+ var presentationSchema = z.object({
443
+ label: z.string().max(MAX_PRESENTATION_LABEL_BYTES),
444
+ detail: z.string().max(MAX_PRESENTATION_DETAIL_BYTES).optional(),
445
+ url: z.string().max(MAX_PRESENTATION_URL_BYTES).optional()
446
+ }).strict().superRefine((value, context) => {
447
+ try {
448
+ canonicalizeResource({ provider: "test", keys: { resource: "placeholder" }, presentation: value });
449
+ } catch (cause) {
450
+ context.addIssue({ code: "custom", message: cause instanceof Error ? cause.message : String(cause) });
451
+ }
452
+ });
453
+ var resourceSchema = z.object({
454
+ provider: z.string().max(64).regex(namePattern),
455
+ keys: z.record(z.string().max(64), z.string().max(MAX_KEY_VALUE_BYTES)),
456
+ presentation: presentationSchema
457
+ }).strict().superRefine((value, context) => {
458
+ try {
459
+ canonicalizeResource(value);
460
+ } catch (cause) {
461
+ context.addIssue({ code: "custom", message: cause instanceof Error ? cause.message : String(cause) });
462
+ }
463
+ });
464
+ var resourceIdentitySchema = z.object({
465
+ provider: z.string().max(64).regex(namePattern),
466
+ keys: z.record(z.string().max(64), z.string().max(MAX_KEY_VALUE_BYTES))
467
+ }).strict().superRefine((value, context) => {
468
+ try {
469
+ canonicalizeIdentity(value);
470
+ } catch (cause) {
471
+ context.addIssue({ code: "custom", message: cause instanceof Error ? cause.message : String(cause) });
472
+ }
473
+ });
474
+ var producerPluginIdSchema = z.string().max(64).regex(namePattern);
475
+ var mutationIdSchema = z.string().max(MAX_MUTATION_ID_BYTES).regex(uuidPattern);
476
+ var revisionSchema = safeInteger(z.number().int());
477
+ var applyProjectionInputSchema = z.object({
478
+ protocolVersion: z.literal(PROTOCOL_VERSION),
479
+ producerPluginId: producerPluginIdSchema,
480
+ mutationId: mutationIdSchema,
481
+ source: resourceSchema,
482
+ revision: revisionSchema.positive(),
483
+ expectedRevision: revisionSchema.nonnegative(),
484
+ payloadDigest: z.string().regex(digestPattern),
485
+ tombstone: z.boolean(),
486
+ targets: z.array(resourceSchema).max(MAX_TARGETS)
487
+ }).strict().superRefine((value, context) => {
488
+ try {
489
+ normalizeProjectionCommand(value);
490
+ } catch (cause) {
491
+ context.addIssue({ code: "custom", message: cause instanceof Error ? cause.message : String(cause) });
492
+ }
493
+ });
494
+ var applyProjectionResponseSchema = z.object({
495
+ outcome: z.enum(["applied", "duplicate", "equal", "stale", "conflict", "cas-mismatch"]),
496
+ currentRevision: revisionSchema.nonnegative(),
497
+ currentDigest: z.string().regex(digestPattern).nullable()
498
+ }).strict();
499
+ var projectionSchema = z.object({
500
+ producerPluginId: producerPluginIdSchema,
501
+ source: resourceSchema,
502
+ revision: revisionSchema.nonnegative(),
503
+ mutationId: mutationIdSchema,
504
+ payloadDigest: z.string().regex(digestPattern),
505
+ tombstone: z.boolean(),
506
+ targets: z.array(resourceSchema).max(MAX_TARGETS)
507
+ }).strict();
508
+ var getProjectionInputSchema = z.object({
509
+ producerPluginId: producerPluginIdSchema,
510
+ source: resourceIdentitySchema
511
+ }).strict();
512
+ var getProjectionOutputSchema = z.object({ projection: projectionSchema.nullable() }).strict();
513
+ var backlinkRowSchema = z.object({
514
+ source: resourceSchema,
515
+ producerPluginId: producerPluginIdSchema,
516
+ revision: revisionSchema.nonnegative(),
517
+ targetPresentation: presentationSchema,
518
+ position: z.number().int().min(0).max(MAX_TARGETS - 1).refine(Number.isSafeInteger),
519
+ lastSeenAt: revisionSchema.nonnegative().nullable().optional()
520
+ }).strict();
521
+ var listBacklinksInputSchema = z.object({
522
+ target: resourceIdentitySchema,
523
+ pageSize: z.number().int().min(1).max(100).refine(Number.isSafeInteger).optional(),
524
+ cursor: z.string().max(4096).optional()
525
+ }).strict();
526
+ var listBacklinksOutputSchema = z.object({
527
+ rows: z.array(backlinkRowSchema).max(100),
528
+ total: revisionSchema.nonnegative(),
529
+ nextCursor: z.string().max(4096).nullable()
530
+ }).strict();
531
+ var forwardReferenceRowSchema = z.object({
532
+ target: resourceSchema,
533
+ producerPluginId: producerPluginIdSchema,
534
+ revision: revisionSchema.nonnegative(),
535
+ position: z.number().int().min(0).max(MAX_TARGETS - 1).refine(Number.isSafeInteger),
536
+ lastSeenAt: revisionSchema.nonnegative().nullable().optional()
537
+ }).strict();
538
+ var listForwardReferencesInputSchema = z.object({
539
+ source: resourceIdentitySchema,
540
+ producerPluginId: producerPluginIdSchema.optional(),
541
+ pageSize: z.number().int().min(1).max(100).refine(Number.isSafeInteger).optional(),
542
+ cursor: z.string().max(4096).optional()
543
+ }).strict();
544
+ var listForwardReferencesOutputSchema = z.object({
545
+ rows: z.array(forwardReferenceRowSchema).max(100),
546
+ total: revisionSchema.nonnegative(),
547
+ nextCursor: z.string().max(4096).nullable()
548
+ }).strict();
549
+ var checkForwardReferencesInputSchema = z.object({
550
+ source: resourceIdentitySchema,
551
+ producerPluginId: producerPluginIdSchema.optional()
552
+ }).strict();
553
+ var forwardReferenceStatusSchema = z.object({
554
+ url: z.string().max(MAX_PRESENTATION_URL_BYTES),
555
+ status: z.number().int().min(100).max(599).nullable(),
556
+ label: z.string().min(1).max(128)
557
+ }).strict();
558
+ var checkForwardReferencesOutputSchema = z.array(forwardReferenceStatusSchema).max(FORWARD_REFERENCE_CHECK_LIMIT);
559
+ var CROSS_REFERENCES_PLUGIN_ID = "cross-references";
560
+ var CROSS_REFERENCES_PROTOCOL = "cross-references";
561
+ var LIMITS = Object.freeze({ callMs: 5000, describeMs: 1000, responseBytes: 1024 * 1024 });
562
+ var describeInputSchema = z.null();
563
+ var describeOutputSchema = z.object({
564
+ protocol: z.literal(CROSS_REFERENCES_PROTOCOL),
565
+ versions: z.array(z.number().int().positive().refine(Number.isSafeInteger)).max(16)
566
+ });
567
+ var crossReferencesRpcSchemas = {
568
+ "crossReferences.describe": { input: describeInputSchema, output: describeOutputSchema },
569
+ applyProjection: { input: applyProjectionInputSchema, output: applyProjectionResponseSchema },
570
+ getProjection: { input: getProjectionInputSchema, output: getProjectionOutputSchema },
571
+ listBacklinks: { input: listBacklinksInputSchema, output: listBacklinksOutputSchema },
572
+ listForwardReferences: { input: listForwardReferencesInputSchema, output: listForwardReferencesOutputSchema },
573
+ checkForwardReferences: { input: checkForwardReferencesInputSchema, output: checkForwardReferencesOutputSchema }
574
+ };
575
+ var referenceSnapshotSchema = z.object({
576
+ revision: revisionSchema.nonnegative(),
577
+ targets: z.array(resourceSchema).max(MAX_TARGETS),
578
+ status: z.object({ state: z.enum(["synced", "pending", "degraded", "blocked"]), error: z.string().max(4096).nullable() })
579
+ });
580
+
581
+ // src/react.tsx
582
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
583
+ import { jsx, jsxs, Fragment } from "react/jsx-runtime";
584
+ var MAX_SEARCH_RESULTS = 24;
585
+ var MAX_REFERENCE_INPUT_LENGTH = 2048;
586
+ function targetThread(target) {
587
+ if (target.provider !== "bb" || Object.keys(target.keys).length !== 2)
588
+ return null;
589
+ const projectId = target.keys.project;
590
+ const threadId = target.keys.thread;
591
+ return typeof projectId === "string" && typeof threadId === "string" ? { projectId, threadId } : null;
592
+ }
593
+ function targetUrl(target) {
594
+ return target.provider === "url" && Object.keys(target.keys).length === 1 && typeof target.keys.href === "string" && target.presentation.url === target.keys.href ? target.keys.href : null;
595
+ }
596
+ function threadResource2(thread) {
597
+ return {
598
+ provider: "bb",
599
+ keys: { project: thread.projectId, thread: thread.id },
600
+ presentation: {
601
+ label: thread.title,
602
+ detail: `Project ${thread.projectId}`
603
+ }
604
+ };
605
+ }
606
+ function resourceKey(resource) {
607
+ return JSON.stringify([resource.provider, Object.entries(resource.keys).sort(([left], [right]) => left.localeCompare(right))]);
608
+ }
609
+ function referenceInput(value, origin) {
610
+ const input = value.trim();
611
+ if (input.length === 0)
612
+ return { kind: "empty" };
613
+ if (!/^https?:\/\//iu.test(input))
614
+ return { kind: "query", query: input };
615
+ let url;
616
+ try {
617
+ url = new URL(input);
618
+ } catch {
619
+ return { kind: "invalid", input, message: "Enter a valid HTTP or HTTPS URL." };
620
+ }
621
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
622
+ return { kind: "invalid", input, message: "Only HTTP and HTTPS links can be attached." };
623
+ }
624
+ if (url.username !== "" || url.password !== "" || hasSensitiveUrlParameter(url)) {
625
+ return { kind: "invalid", input, message: "Links containing credentials or sensitive parameters cannot be attached." };
626
+ }
627
+ if (utf8ByteLength(url.href) > MAX_REFERENCE_URL_BYTES) {
628
+ return { kind: "invalid", input, message: `Links are limited to ${MAX_REFERENCE_URL_BYTES} UTF-8 bytes after URL encoding.` };
629
+ }
630
+ if (url.origin === origin) {
631
+ const projectRoute = url.pathname.match(/^\/projects\/([A-Za-z0-9_-]{1,128})\/threads\/([A-Za-z0-9_-]{1,128})\/?$/u);
632
+ if (projectRoute != null) {
633
+ return { kind: "thread-url", input, projectId: projectRoute[1], threadId: projectRoute[2] };
634
+ }
635
+ const personalRoute = url.pathname.match(/^\/threads\/([A-Za-z0-9_-]{1,128})\/?$/u);
636
+ if (personalRoute != null)
637
+ return { kind: "thread-url", input, projectId: null, threadId: personalRoute[1] };
638
+ }
639
+ return {
640
+ kind: "external-url",
641
+ href: url.href,
642
+ suggestedLabel: url.hostname.replace(/^www\./u, "") || "External link"
643
+ };
644
+ }
645
+ function useThreadSearch(query, retry, api, connection, origin) {
646
+ const apiRef = useRef(api);
647
+ apiRef.current = api;
648
+ const input = query.trim();
649
+ const [state, setState] = useState({ status: "idle", input: "" });
650
+ useEffect(() => {
651
+ const parsed = referenceInput(input, origin);
652
+ if (parsed.kind === "empty" || parsed.kind === "query" && parsed.query.length < 2) {
653
+ setState({ status: "idle", input });
654
+ return;
655
+ }
656
+ if (parsed.kind === "invalid") {
657
+ setState({ status: "error", input, message: parsed.message });
658
+ return;
659
+ }
660
+ if (parsed.kind === "external-url") {
661
+ setState({ status: "external", input, href: parsed.href, suggestedLabel: parsed.suggestedLabel });
662
+ return;
663
+ }
664
+ if (parsed.kind === "query" && parsed.query.length > 256) {
665
+ setState({ status: "error", input, message: "Thread searches are limited to 256 characters." });
666
+ return;
667
+ }
668
+ if (connection !== "connected") {
669
+ setState({ status: "waiting", input });
670
+ return;
671
+ }
672
+ let active = true;
673
+ setState({ status: "searching", input });
674
+ const timer = window.setTimeout(() => {
675
+ if (parsed.kind === "thread-url") {
676
+ const href = new URL(parsed.input).href;
677
+ apiRef.current.thread(parsed.threadId).then((thread) => {
678
+ if (!active)
679
+ return;
680
+ if (parsed.projectId == null || thread.projectId === parsed.projectId) {
681
+ setState({ status: "ready", input, threads: [thread] });
682
+ } else {
683
+ setState({ status: "external", input, href, suggestedLabel: "BB thread link" });
684
+ }
685
+ }, () => {
686
+ if (active)
687
+ setState({
688
+ status: "external",
689
+ input,
690
+ href,
691
+ suggestedLabel: "BB thread link",
692
+ resolutionError: "Could not resolve this BB thread. Retry the lookup or save the URL as an external reference.",
693
+ canRetry: true
694
+ });
695
+ });
696
+ return;
697
+ }
698
+ apiRef.current.search(parsed.query).then((response) => {
699
+ if (active)
700
+ setState({ status: "ready", input, threads: response.threads.slice(0, MAX_SEARCH_RESULTS) });
701
+ }, (cause) => {
702
+ if (active)
703
+ setState({
704
+ status: "error",
705
+ input,
706
+ message: cause instanceof Error ? cause.message : "Could not search BB threads."
707
+ });
708
+ });
709
+ }, 180);
710
+ return () => {
711
+ active = false;
712
+ window.clearTimeout(timer);
713
+ };
714
+ }, [connection, input, retry, origin, api]);
715
+ return state.input === input ? state : { status: "searching", input };
716
+ }
717
+ function useAttachments(api, connection, refreshToken) {
718
+ const apiRef = useRef(api);
719
+ apiRef.current = api;
720
+ const [snapshot, setSnapshot] = useState(null);
721
+ const snapshotRef = useRef(null);
722
+ snapshotRef.current = snapshot;
723
+ const [loading, setLoading] = useState(true);
724
+ const [readError, setReadError] = useState(null);
725
+ const [mutationError, setMutationError] = useState(null);
726
+ const [stale, setStale] = useState(false);
727
+ const [saving, setSaving] = useState(false);
728
+ const mounted = useRef(true);
729
+ const mutationInFlight = useRef(false);
730
+ const readControl = useRef({ generation: 0, inFlight: false, pending: false });
731
+ const applySnapshot = useCallback((next) => {
732
+ snapshotRef.current = next;
733
+ setSnapshot(next);
734
+ }, []);
735
+ const restartReads = useCallback(() => {
736
+ readControl.current = {
737
+ generation: readControl.current.generation + 1,
738
+ inFlight: false,
739
+ pending: false
740
+ };
741
+ }, []);
742
+ const refresh = useCallback(async () => {
743
+ const control = readControl.current;
744
+ control.pending = true;
745
+ if (!mounted.current || mutationInFlight.current || control.inFlight)
746
+ return;
747
+ control.inFlight = true;
748
+ try {
749
+ while (mounted.current && !mutationInFlight.current && readControl.current === control && control.pending) {
750
+ control.pending = false;
751
+ try {
752
+ const next = await apiRef.current.get();
753
+ if (mounted.current && readControl.current === control && !mutationInFlight.current) {
754
+ applySnapshot(next);
755
+ setReadError(null);
756
+ setStale(false);
757
+ setLoading(false);
758
+ }
759
+ } catch (cause) {
760
+ if (mounted.current && readControl.current === control && !mutationInFlight.current) {
761
+ setReadError(cause instanceof Error ? cause.message : "Could not read saved references.");
762
+ setLoading(false);
763
+ }
764
+ }
765
+ }
766
+ } finally {
767
+ if (readControl.current === control)
768
+ control.inFlight = false;
769
+ }
770
+ }, [applySnapshot]);
771
+ useEffect(() => {
772
+ mounted.current = true;
773
+ restartReads();
774
+ setLoading(snapshotRef.current == null);
775
+ refresh();
776
+ return () => {
777
+ mounted.current = false;
778
+ restartReads();
779
+ };
780
+ }, [refresh, restartReads]);
781
+ const previousToken = useRef(refreshToken);
782
+ useEffect(() => {
783
+ if (previousToken.current !== refreshToken) {
784
+ previousToken.current = refreshToken;
785
+ refresh();
786
+ }
787
+ }, [refresh, refreshToken]);
788
+ const previousConnection = useRef(connection);
789
+ useEffect(() => {
790
+ if (connection === "reconnecting") {
791
+ setStale(true);
792
+ restartReads();
793
+ previousConnection.current = connection;
794
+ return;
795
+ }
796
+ if (connection === "connected" && previousConnection.current !== "connected") {
797
+ restartReads();
798
+ refresh();
799
+ }
800
+ previousConnection.current = connection;
801
+ }, [connection, refresh, restartReads]);
802
+ const mutate = useCallback(async (transform) => {
803
+ if (mutationInFlight.current)
804
+ return false;
805
+ const current = snapshotRef.current;
806
+ if (current == null)
807
+ return false;
808
+ const targets = transform(current.targets);
809
+ if (targets == null)
810
+ return true;
811
+ mutationInFlight.current = true;
812
+ setSaving(true);
813
+ setMutationError(null);
814
+ restartReads();
815
+ try {
816
+ const result = await apiRef.current.replace(current.revision, targets);
817
+ if (!mounted.current)
818
+ return false;
819
+ applySnapshot(result);
820
+ if (result.outcome === "cas-mismatch") {
821
+ setMutationError("These saved links changed in another window. The latest list is shown below.");
822
+ return false;
823
+ }
824
+ return true;
825
+ } catch (cause) {
826
+ if (mounted.current) {
827
+ setMutationError(cause instanceof Error ? cause.message : "Could not save this thread link.");
828
+ }
829
+ return false;
830
+ } finally {
831
+ mutationInFlight.current = false;
832
+ if (mounted.current)
833
+ setSaving(false);
834
+ refresh();
835
+ }
836
+ }, [applySnapshot, refresh, restartReads]);
837
+ const add = useCallback((thread) => mutate((targets) => {
838
+ const target = threadResource2(thread);
839
+ const key = resourceKey(target);
840
+ return targets.some((candidate) => resourceKey(candidate) === key) ? null : [...targets, target];
841
+ }), [mutate]);
842
+ const addResource = useCallback((target) => mutate((targets) => {
843
+ const key = resourceKey(target);
844
+ return targets.some((candidate) => resourceKey(candidate) === key) ? null : [...targets, target];
845
+ }), [mutate]);
846
+ const remove = useCallback((target) => mutate((targets) => {
847
+ const key = resourceKey(target);
848
+ return targets.filter((candidate) => resourceKey(candidate) !== key);
849
+ }), [mutate]);
850
+ return { snapshot, loading, readError, mutationError, stale, saving, refresh, add, addResource, remove };
851
+ }
852
+ var PERSONAL_PROJECT_ID = "proj_personal";
853
+ function threadHref(projectId, threadId) {
854
+ const encodedProjectId = encodeURIComponent(projectId);
855
+ const encodedThreadId = encodeURIComponent(threadId);
856
+ return projectId === PERSONAL_PROJECT_ID ? `/threads/${encodedThreadId}` : `/projects/${encodedProjectId}/threads/${encodedThreadId}`;
857
+ }
858
+ function ThreadLink({ projectId, threadId, label, navigateThread }) {
859
+ return /* @__PURE__ */ jsx("a", {
860
+ className: "bb-reference-links__reference-link",
861
+ href: threadHref(projectId, threadId),
862
+ onClick: (event) => {
863
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
864
+ return;
865
+ event.preventDefault();
866
+ navigateThread(threadId);
867
+ },
868
+ children: label
869
+ });
870
+ }
871
+ function ReferenceLink({ target, navigateThread }) {
872
+ const thread = targetThread(target);
873
+ if (thread != null) {
874
+ return /* @__PURE__ */ jsx(ThreadLink, {
875
+ projectId: thread.projectId,
876
+ threadId: thread.threadId,
877
+ label: target.presentation.label,
878
+ navigateThread
879
+ });
880
+ }
881
+ const href = targetUrl(target);
882
+ if (href == null)
883
+ return /* @__PURE__ */ jsx("span", {
884
+ className: "bb-reference-links__reference-link",
885
+ children: target.presentation.label
886
+ });
887
+ return /* @__PURE__ */ jsx("a", {
888
+ className: "bb-reference-links__reference-link",
889
+ href,
890
+ children: target.presentation.label
891
+ });
892
+ }
893
+ function ThreadPicker({
894
+ api,
895
+ connection,
896
+ origin,
897
+ attached,
898
+ disabled,
899
+ onAdd,
900
+ onAddResource,
901
+ searchInputRef
902
+ }) {
903
+ const apiRef = useRef(api);
904
+ apiRef.current = api;
905
+ const searchId = `bb-reference-thread-search-${useId().replaceAll(":", "")}`;
906
+ const nameId = `${searchId}-external-name`;
907
+ const [query, setQuery] = useState("");
908
+ const queryRef = useRef(query);
909
+ queryRef.current = query;
910
+ const [searchRetry, setSearchRetry] = useState(0);
911
+ const search = useThreadSearch(query, searchRetry, api, connection, origin);
912
+ const [externalName, setExternalName] = useState("");
913
+ const [externalNameError, setExternalNameError] = useState(null);
914
+ const [selectingId, setSelectingId] = useState(null);
915
+ const [selectionError, setSelectionError] = useState(null);
916
+ const selectionSequence = useRef(0);
917
+ const [returnFocus, setReturnFocus] = useState(null);
918
+ useEffect(() => {
919
+ if (!returnFocus || disabled || selectingId !== null || query !== "")
920
+ return;
921
+ if (document.activeElement === document.body || document.activeElement === returnFocus.trigger)
922
+ searchInputRef.current?.focus();
923
+ setReturnFocus(null);
924
+ }, [returnFocus, disabled, selectingId, query, searchInputRef]);
925
+ useEffect(() => {
926
+ return () => {
927
+ selectionSequence.current += 1;
928
+ };
929
+ }, []);
930
+ useEffect(() => {
931
+ if (search.status === "external") {
932
+ setExternalName(search.suggestedLabel);
933
+ setExternalNameError(null);
934
+ }
935
+ }, [search.status === "external" ? search.href : null]);
936
+ const select = async (thread, restoreKeyboardFocus) => {
937
+ const sequence = ++selectionSequence.current;
938
+ const selectedQuery = queryRef.current;
939
+ const trigger = document.activeElement;
940
+ setSelectingId(thread.id);
941
+ setSelectionError(null);
942
+ try {
943
+ const fresh = await apiRef.current.thread(thread.id);
944
+ if (sequence !== selectionSequence.current)
945
+ return;
946
+ if (await onAdd(fresh) && queryRef.current === selectedQuery) {
947
+ setQuery("");
948
+ if (restoreKeyboardFocus)
949
+ setReturnFocus({ trigger });
950
+ }
951
+ } catch (cause) {
952
+ if (sequence === selectionSequence.current) {
953
+ setSelectionError(cause instanceof Error ? cause.message : "Could not attach this thread.");
954
+ }
955
+ } finally {
956
+ if (sequence === selectionSequence.current)
957
+ setSelectingId(null);
958
+ }
959
+ };
960
+ const attachExternal = async (restoreKeyboardFocus) => {
961
+ if (search.status !== "external")
962
+ return;
963
+ const label = externalName.trim();
964
+ const labelError = referenceLabelError(label);
965
+ if (labelError != null) {
966
+ setExternalNameError(labelError);
967
+ return;
968
+ }
969
+ const sequence = ++selectionSequence.current;
970
+ const selectedQuery = queryRef.current;
971
+ const trigger = document.activeElement;
972
+ setSelectingId(search.href);
973
+ setSelectionError(null);
974
+ const resource = {
975
+ provider: "url",
976
+ keys: { href: search.href },
977
+ presentation: { label, detail: new URL(search.href).hostname, url: search.href }
978
+ };
979
+ try {
980
+ if (await onAddResource(resource) && sequence === selectionSequence.current && queryRef.current === selectedQuery) {
981
+ setQuery("");
982
+ if (restoreKeyboardFocus)
983
+ setReturnFocus({ trigger });
984
+ }
985
+ } finally {
986
+ if (sequence === selectionSequence.current)
987
+ setSelectingId(null);
988
+ }
989
+ };
990
+ const results = search.status === "ready" ? search.threads : [];
991
+ const externalAttached = search.status === "external" && attached.has(resourceKey({
992
+ provider: "url",
993
+ keys: { href: search.href },
994
+ presentation: { label: externalName || search.suggestedLabel, url: search.href }
995
+ }));
996
+ const statusText = search.status === "searching" ? "Searching threads…" : search.status === "waiting" ? "Search will resume when the connection returns." : search.status === "ready" && search.threads.length === 0 ? "No matching threads." : search.status === "ready" ? `${search.threads.length} matching thread${search.threads.length === 1 ? "" : "s"}.` : "";
997
+ return /* @__PURE__ */ jsxs("div", {
998
+ className: "bb-reference-links__reference-picker",
999
+ children: [
1000
+ /* @__PURE__ */ jsx("label", {
1001
+ htmlFor: searchId,
1002
+ children: "Add a thread or link"
1003
+ }),
1004
+ /* @__PURE__ */ jsx("input", {
1005
+ ref: searchInputRef,
1006
+ id: searchId,
1007
+ type: "search",
1008
+ value: query,
1009
+ disabled,
1010
+ maxLength: MAX_REFERENCE_INPUT_LENGTH,
1011
+ onChange: (event) => {
1012
+ setQuery(event.target.value);
1013
+ setSelectionError(null);
1014
+ },
1015
+ placeholder: "Search threads or paste a URL…",
1016
+ "aria-invalid": search.status === "error" || undefined,
1017
+ "aria-describedby": `${searchId}-help${search.status === "error" ? ` ${searchId}-error` : ""}`
1018
+ }),
1019
+ /* @__PURE__ */ jsx("p", {
1020
+ id: `${searchId}-help`,
1021
+ className: "bb-reference-links__reference-help",
1022
+ children: "Search active and archived threads, or paste a link. BB thread links on this host resolve to their thread."
1023
+ }),
1024
+ /* @__PURE__ */ jsx("p", {
1025
+ className: "bb-reference-links__reference-status",
1026
+ role: "status",
1027
+ "aria-live": "polite",
1028
+ children: statusText
1029
+ }),
1030
+ search.status === "error" && /* @__PURE__ */ jsx("p", {
1031
+ id: `${searchId}-error`,
1032
+ className: "bb-reference-links__reference-error",
1033
+ role: "alert",
1034
+ children: search.message
1035
+ }),
1036
+ selectionError != null && /* @__PURE__ */ jsx("p", {
1037
+ className: "bb-reference-links__reference-error",
1038
+ role: "alert",
1039
+ children: selectionError
1040
+ }),
1041
+ results.length > 0 && /* @__PURE__ */ jsx("ul", {
1042
+ className: "bb-reference-links__reference-search-results",
1043
+ children: results.map((thread) => {
1044
+ const key = resourceKey(threadResource2(thread));
1045
+ const isAttached = attached.has(key);
1046
+ return /* @__PURE__ */ jsxs("li", {
1047
+ children: [
1048
+ /* @__PURE__ */ jsxs("span", {
1049
+ children: [
1050
+ /* @__PURE__ */ jsx("strong", {
1051
+ children: thread.title
1052
+ }),
1053
+ /* @__PURE__ */ jsxs("small", {
1054
+ id: `${searchId}-result-${thread.id}`,
1055
+ children: [
1056
+ thread.archived ? "Archived" : "Active",
1057
+ thread.detail == null ? ` · Project ${thread.projectId}` : ` · ${thread.detail}`
1058
+ ]
1059
+ })
1060
+ ]
1061
+ }),
1062
+ /* @__PURE__ */ jsx("button", {
1063
+ type: "button",
1064
+ className: "bb-reference-links__reference-add",
1065
+ disabled: disabled || isAttached || selectingId !== null,
1066
+ onClick: (event) => void select(thread, event.detail === 0),
1067
+ "aria-label": isAttached ? `${thread.title} is already linked` : `Link ${thread.title}`,
1068
+ "aria-describedby": `${searchId}-result-${thread.id}`,
1069
+ children: isAttached ? "Linked" : selectingId === thread.id ? "Adding…" : "Link"
1070
+ })
1071
+ ]
1072
+ }, key);
1073
+ })
1074
+ }),
1075
+ search.status === "external" && /* @__PURE__ */ jsxs("div", {
1076
+ className: "bb-reference-links__reference-external",
1077
+ children: [
1078
+ /* @__PURE__ */ jsxs("span", {
1079
+ children: [
1080
+ /* @__PURE__ */ jsx("strong", {
1081
+ children: "External link"
1082
+ }),
1083
+ /* @__PURE__ */ jsx("small", {
1084
+ children: search.href
1085
+ })
1086
+ ]
1087
+ }),
1088
+ search.resolutionError != null && /* @__PURE__ */ jsxs("p", {
1089
+ className: "bb-reference-links__reference-error",
1090
+ role: "alert",
1091
+ children: [
1092
+ search.resolutionError,
1093
+ search.canRetry === true && /* @__PURE__ */ jsxs(Fragment, {
1094
+ children: [
1095
+ " ",
1096
+ /* @__PURE__ */ jsx("button", {
1097
+ type: "button",
1098
+ className: "bb-reference-links__reference-retry",
1099
+ onClick: () => setSearchRetry((value) => value + 1),
1100
+ children: "Retry thread lookup"
1101
+ })
1102
+ ]
1103
+ })
1104
+ ]
1105
+ }),
1106
+ /* @__PURE__ */ jsx("label", {
1107
+ htmlFor: nameId,
1108
+ children: "Reference name"
1109
+ }),
1110
+ /* @__PURE__ */ jsxs("div", {
1111
+ children: [
1112
+ /* @__PURE__ */ jsx("input", {
1113
+ id: nameId,
1114
+ type: "text",
1115
+ value: externalName,
1116
+ disabled: disabled || selectingId !== null,
1117
+ "aria-invalid": externalNameError != null || undefined,
1118
+ "aria-describedby": externalNameError == null ? undefined : `${nameId}-error`,
1119
+ onChange: (event) => {
1120
+ setExternalName(event.target.value);
1121
+ setExternalNameError(null);
1122
+ }
1123
+ }),
1124
+ /* @__PURE__ */ jsx("button", {
1125
+ type: "button",
1126
+ className: "bb-reference-links__reference-add",
1127
+ disabled: disabled || selectingId !== null || externalAttached,
1128
+ onClick: (event) => void attachExternal(event.detail === 0),
1129
+ "aria-label": externalAttached ? `${externalName || search.suggestedLabel} is already linked` : `Link ${externalName || search.suggestedLabel}`,
1130
+ children: externalAttached ? "Linked" : selectingId === search.href ? "Adding…" : "Link"
1131
+ })
1132
+ ]
1133
+ }),
1134
+ externalNameError != null && /* @__PURE__ */ jsx("p", {
1135
+ id: `${nameId}-error`,
1136
+ className: "bb-reference-links__reference-error",
1137
+ role: "alert",
1138
+ children: externalNameError
1139
+ })
1140
+ ]
1141
+ })
1142
+ ]
1143
+ });
1144
+ }
1145
+ function attachmentStatusText(snapshot) {
1146
+ switch (snapshot.status.state) {
1147
+ case "synced":
1148
+ return "Saved locally and shared with Cross References.";
1149
+ case "pending":
1150
+ return "Saved locally. Sharing with Cross References will finish shortly.";
1151
+ case "degraded":
1152
+ return "Saved locally. Cross References is unavailable; sharing will retry when it returns.";
1153
+ case "blocked":
1154
+ return "Saved locally, but Cross References could not accept the latest update.";
1155
+ }
1156
+ }
1157
+ function LinkedReferencesEditor({ api, connection = "connected", refreshToken = 0, navigateThread, origin = window.location.origin, title = "Linked references", description, maxTargets = 256 }) {
1158
+ const { snapshot, loading, readError, mutationError, stale, saving, refresh, add, addResource, remove } = useAttachments(api, connection, refreshToken);
1159
+ const sectionRef = useRef(null);
1160
+ const searchInputRef = useRef(null);
1161
+ const [removalFocus, setRemovalFocus] = useState(null);
1162
+ const targets = snapshot?.targets ?? [];
1163
+ const attached = useMemo(() => new Set(targets.map(resourceKey)), [snapshot?.targets]);
1164
+ useEffect(() => {
1165
+ if (removalFocus == null)
1166
+ return;
1167
+ if (document.activeElement !== document.body && document.activeElement !== removalFocus.button) {
1168
+ setRemovalFocus(null);
1169
+ return;
1170
+ }
1171
+ const remaining = [...sectionRef.current?.querySelectorAll(".bb-reference-links__reference-remove") ?? []];
1172
+ remaining[Math.min(removalFocus.index, remaining.length - 1)]?.focus();
1173
+ if (remaining.length === 0)
1174
+ searchInputRef.current?.focus();
1175
+ setRemovalFocus(null);
1176
+ }, [removalFocus, targets.length]);
1177
+ const titleId = useId();
1178
+ return /* @__PURE__ */ jsxs("section", {
1179
+ ref: sectionRef,
1180
+ className: "bb-reference-links__references",
1181
+ "aria-labelledby": titleId,
1182
+ children: [
1183
+ /* @__PURE__ */ jsxs("header", {
1184
+ children: [
1185
+ /* @__PURE__ */ jsxs("div", {
1186
+ children: [
1187
+ /* @__PURE__ */ jsx("h2", {
1188
+ id: titleId,
1189
+ children: title
1190
+ }),
1191
+ description && /* @__PURE__ */ jsx("p", {
1192
+ children: description
1193
+ })
1194
+ ]
1195
+ }),
1196
+ /* @__PURE__ */ jsx("button", {
1197
+ type: "button",
1198
+ className: "bb-reference-links__reference-retry",
1199
+ disabled: saving,
1200
+ onClick: () => void refresh(),
1201
+ children: "Refresh links"
1202
+ }),
1203
+ /* @__PURE__ */ jsx("span", {
1204
+ className: "bb-reference-links__reference-count",
1205
+ "aria-label": `${targets.length} linked reference${targets.length === 1 ? "" : "s"}`,
1206
+ children: targets.length
1207
+ })
1208
+ ]
1209
+ }),
1210
+ loading && snapshot == null && /* @__PURE__ */ jsx("p", {
1211
+ className: "bb-reference-links__reference-status",
1212
+ role: "status",
1213
+ children: "Loading saved references…"
1214
+ }),
1215
+ snapshot != null && /* @__PURE__ */ jsx("p", {
1216
+ className: `bb-reference-links__reference-status bb-reference-links__reference-status--${snapshot.status.state}`,
1217
+ role: "status",
1218
+ title: snapshot.status.error ?? undefined,
1219
+ children: attachmentStatusText(snapshot)
1220
+ }),
1221
+ stale && /* @__PURE__ */ jsx("p", {
1222
+ className: "bb-reference-links__reference-status",
1223
+ role: "status",
1224
+ children: "The connection is recovering; this list may be briefly out of date."
1225
+ }),
1226
+ readError != null && /* @__PURE__ */ jsxs("p", {
1227
+ className: "bb-reference-links__reference-error",
1228
+ role: "alert",
1229
+ children: [
1230
+ readError,
1231
+ " ",
1232
+ /* @__PURE__ */ jsx("button", {
1233
+ type: "button",
1234
+ className: "bb-reference-links__reference-retry",
1235
+ disabled: saving,
1236
+ onClick: () => void refresh(),
1237
+ children: "Try again"
1238
+ })
1239
+ ]
1240
+ }),
1241
+ mutationError != null && /* @__PURE__ */ jsx("p", {
1242
+ className: "bb-reference-links__reference-error",
1243
+ role: "alert",
1244
+ children: mutationError
1245
+ }),
1246
+ targets.length === 0 && snapshot != null && /* @__PURE__ */ jsx("p", {
1247
+ className: "bb-reference-links__reference-empty",
1248
+ children: "No references linked yet."
1249
+ }),
1250
+ targets.length > 0 && /* @__PURE__ */ jsx("ul", {
1251
+ className: "bb-reference-links__reference-list",
1252
+ children: targets.map((target) => {
1253
+ return /* @__PURE__ */ jsxs("li", {
1254
+ children: [
1255
+ /* @__PURE__ */ jsxs("span", {
1256
+ children: [
1257
+ /* @__PURE__ */ jsx(ReferenceLink, {
1258
+ target,
1259
+ navigateThread
1260
+ }),
1261
+ /* @__PURE__ */ jsx("small", {
1262
+ children: target.presentation.detail ?? (targetThread(target) == null ? target.presentation.url : `Project ${targetThread(target).projectId}`)
1263
+ })
1264
+ ]
1265
+ }),
1266
+ /* @__PURE__ */ jsx("button", {
1267
+ type: "button",
1268
+ className: "bb-reference-links__reference-remove",
1269
+ disabled: saving,
1270
+ onClick: (event) => {
1271
+ const restoreKeyboardFocus = event.detail === 0;
1272
+ const button = event.currentTarget;
1273
+ const buttons = [...sectionRef.current?.querySelectorAll(".bb-reference-links__reference-remove") ?? []];
1274
+ const index = buttons.indexOf(button);
1275
+ remove(target).then((removed) => {
1276
+ if (!removed || !restoreKeyboardFocus)
1277
+ return;
1278
+ setRemovalFocus({ index, button });
1279
+ });
1280
+ },
1281
+ "aria-label": `Remove ${target.presentation.label}`,
1282
+ children: "Remove"
1283
+ })
1284
+ ]
1285
+ }, resourceKey(target));
1286
+ })
1287
+ }),
1288
+ /* @__PURE__ */ jsx(ThreadPicker, {
1289
+ api,
1290
+ connection,
1291
+ origin,
1292
+ attached,
1293
+ disabled: saving || snapshot == null || targets.length >= maxTargets,
1294
+ onAdd: add,
1295
+ onAddResource: addResource,
1296
+ searchInputRef
1297
+ })
1298
+ ]
1299
+ });
1300
+ }
1301
+ function LinkedReferences(props) {
1302
+ return /* @__PURE__ */ jsx(LinkedReferencesEditor, {
1303
+ ...props
1304
+ }, props.ownerKey);
1305
+ }
1306
+ export {
1307
+ LinkedReferences
1308
+ };