@bettercms-ai/convert 0.25.1 → 0.26.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/CHANGELOG.md +14 -0
- package/dist/cli.js +105 -26
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +103 -24
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -957,7 +957,7 @@ declare function attrsFor(path: string, kind: string, props?: AttrBinding[], sco
|
|
|
957
957
|
declare function propsAttribute(props: AttrBinding[]): string;
|
|
958
958
|
|
|
959
959
|
/** Bumped whenever the emitted source changes. `isKnownHelper` upgrades anything older. */
|
|
960
|
-
declare const HELPER_VERSION =
|
|
960
|
+
declare const HELPER_VERSION = 14;
|
|
961
961
|
/** Where each dialect's helper lives. Null = this dialect reads nothing at build time. */
|
|
962
962
|
declare const HELPER_PATH: Record<Dialect, string | null>;
|
|
963
963
|
/** The directory the snapshots live in, at the repository root. */
|
|
@@ -1203,6 +1203,8 @@ interface ImageTarget {
|
|
|
1203
1203
|
/** The brief's `original` — the BUILT url. */
|
|
1204
1204
|
original: string;
|
|
1205
1205
|
slug: string;
|
|
1206
|
+
/** The file already declares this path: only a missing READ is added, and nothing is refused. */
|
|
1207
|
+
declared?: boolean;
|
|
1206
1208
|
}
|
|
1207
1209
|
interface ImageSplice {
|
|
1208
1210
|
start: number;
|
package/dist/index.js
CHANGED
|
@@ -4086,6 +4086,13 @@ var IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif", ".svg
|
|
|
4086
4086
|
var ASSET_COMPONENTS = /* @__PURE__ */ new Set(["Image", "Picture"]);
|
|
4087
4087
|
var ASSET_MODULE = "astro:assets";
|
|
4088
4088
|
function baseName(url) {
|
|
4089
|
+
const inner = /[?&](?:href|url)=([^&#]+)/.exec(url)?.[1];
|
|
4090
|
+
if (inner) {
|
|
4091
|
+
try {
|
|
4092
|
+
url = decodeURIComponent(inner);
|
|
4093
|
+
} catch {
|
|
4094
|
+
}
|
|
4095
|
+
}
|
|
4089
4096
|
const file = url.split(/[?#]/)[0].split("/").pop() ?? "";
|
|
4090
4097
|
const base = file.split(".")[0] ?? "";
|
|
4091
4098
|
return base === "" ? null : base.toLowerCase();
|
|
@@ -4111,6 +4118,29 @@ function passedTo(content, parsed, binding) {
|
|
|
4111
4118
|
for (const root of parsed.roots) visit(root);
|
|
4112
4119
|
return out;
|
|
4113
4120
|
}
|
|
4121
|
+
function declaredAt(content, parsed, binding, path) {
|
|
4122
|
+
const out = [];
|
|
4123
|
+
const names = [`"${path}"`, `'${path}'`];
|
|
4124
|
+
const visit = (node) => {
|
|
4125
|
+
if (node.type === "expr") {
|
|
4126
|
+
for (const child of node.children) visit(child);
|
|
4127
|
+
return;
|
|
4128
|
+
}
|
|
4129
|
+
if (node.type !== "element" || node.raw) return;
|
|
4130
|
+
const bindings = node.attrs.find((a) => a.name === "bcmsBindings");
|
|
4131
|
+
const declared = bindings?.expressionRange ? content.slice(bindings.expressionRange.start, bindings.expressionRange.end) : "";
|
|
4132
|
+
if (/^[A-Z]/.test(node.tag) && !ASSET_COMPONENTS.has(node.tag) && names.some((name) => declared.includes(name))) {
|
|
4133
|
+
for (const attr of node.attrs) {
|
|
4134
|
+
if (!attr.expressionRange) continue;
|
|
4135
|
+
const inner = content.slice(attr.expressionRange.start, attr.expressionRange.end).replace(/^\{|\}$/g, "").trim();
|
|
4136
|
+
if (inner === binding) out.push({ node, prop: attr.name, attr });
|
|
4137
|
+
}
|
|
4138
|
+
}
|
|
4139
|
+
for (const child of node.children) visit(child);
|
|
4140
|
+
};
|
|
4141
|
+
for (const root of parsed.roots) visit(root);
|
|
4142
|
+
return out;
|
|
4143
|
+
}
|
|
4114
4144
|
function assetCandidates(parsed, dialect, original) {
|
|
4115
4145
|
const wanted = baseName(original);
|
|
4116
4146
|
if (wanted === null || parsed.error) return [];
|
|
@@ -4158,6 +4188,18 @@ async function bindImages(ctx, targets) {
|
|
|
4158
4188
|
const rendered = binding === null ? [] : renderedBy(ctx.content, ctx.parsed, ctx.dialect, binding);
|
|
4159
4189
|
const free = rendered.filter((hit2) => !claimed.has(hit2.node.attrInsertAt));
|
|
4160
4190
|
const args = (b) => [ctx.snapshotName(target.slug), JSON.stringify(target.path), b, ...ctx.slug ? [ctx.slug] : []];
|
|
4191
|
+
if (free.length === 0 && binding !== null) {
|
|
4192
|
+
const declared = declaredAt(ctx.content, ctx.parsed, binding, target.path).filter((c) => !claimed.has(c.attr.range.start));
|
|
4193
|
+
if (declared.length === 1) {
|
|
4194
|
+
const call = declared[0];
|
|
4195
|
+
claimed.add(call.attr.range.start);
|
|
4196
|
+
out.splices.push({ start: call.attr.range.start, end: call.attr.range.end, text: rule.attr(call.prop, `${ctx.image}(${args(binding).join(", ")})`) });
|
|
4197
|
+
out.slugs.push(target.slug);
|
|
4198
|
+
out.bound.push(target.key);
|
|
4199
|
+
continue;
|
|
4200
|
+
}
|
|
4201
|
+
}
|
|
4202
|
+
if (target.declared) continue;
|
|
4161
4203
|
if (free.length === 0 && binding !== null && ctx.drill) {
|
|
4162
4204
|
const calls = passedTo(ctx.content, ctx.parsed, binding).filter((c) => !claimed.has(c.node.attrInsertAt));
|
|
4163
4205
|
const call = calls.length === 1 ? calls[0] : null;
|
|
@@ -4199,7 +4241,7 @@ async function bindImages(ctx, targets) {
|
|
|
4199
4241
|
|
|
4200
4242
|
// src/helper.ts
|
|
4201
4243
|
import { createHash as createHash2 } from "crypto";
|
|
4202
|
-
var HELPER_VERSION =
|
|
4244
|
+
var HELPER_VERSION = 14;
|
|
4203
4245
|
var HEADER = "// @bettercms-ai/convert helper v";
|
|
4204
4246
|
var HELPER_PATH = Object.fromEntries(
|
|
4205
4247
|
Object.keys(DIALECT_RULES).map((d) => [d, DIALECT_RULES[d].helper])
|
|
@@ -4273,11 +4315,11 @@ function render(value: unknown): string | undefined {
|
|
|
4273
4315
|
*
|
|
4274
4316
|
* Nowhere else does anything change: with no variable (a build, \`astro dev\`, a browser, an edge
|
|
4275
4317
|
* runtime) every snapshot is the one the bundle imported, exactly as before. A file that cannot be
|
|
4276
|
-
* read keeps the last good copy
|
|
4318
|
+
* read keeps the last good copy. Its modification time is asked on EVERY read (a stat, microseconds;
|
|
4319
|
+
* the file is parsed again only when it changed), so a publish shows on the very next request.
|
|
4277
4320
|
*/
|
|
4278
|
-
type BcmsLiveContent = { stamp: number; bySlug: Map<string, unknown>; layout: unknown };
|
|
4321
|
+
type BcmsLiveContent = { helper: string; stamp: number; bySlug: Map<string, unknown>; entries: Record<string, unknown>; layout: unknown };
|
|
4279
4322
|
let bcmsLive: BcmsLiveContent | null = null;
|
|
4280
|
-
let bcmsLiveCheckedAt = 0;
|
|
4281
4323
|
|
|
4282
4324
|
function liveContent(): BcmsLiveContent | null {
|
|
4283
4325
|
const proc = (globalThis as {
|
|
@@ -4285,9 +4327,6 @@ function liveContent(): BcmsLiveContent | null {
|
|
|
4285
4327
|
}).process;
|
|
4286
4328
|
const file = proc?.env?.BCMS_CONTENT_FILE;
|
|
4287
4329
|
if (!file || typeof proc?.getBuiltinModule !== "function") return null;
|
|
4288
|
-
const now = Date.now();
|
|
4289
|
-
if (now - bcmsLiveCheckedAt < 1000) return bcmsLive;
|
|
4290
|
-
bcmsLiveCheckedAt = now;
|
|
4291
4330
|
try {
|
|
4292
4331
|
const fs = proc.getBuiltinModule("node:fs") as {
|
|
4293
4332
|
statSync(path: string): { mtimeMs: number };
|
|
@@ -4301,13 +4340,30 @@ function liveContent(): BcmsLiveContent | null {
|
|
|
4301
4340
|
for (const rows of Object.values(collections)) {
|
|
4302
4341
|
if (!Array.isArray(rows)) continue;
|
|
4303
4342
|
for (const row of rows) {
|
|
4304
|
-
|
|
4343
|
+
// A published row carries its values under \`data\` (the delivery API's shape); the snapshot a
|
|
4344
|
+
// template reads is the values themselves. Read flat, as before, where a row has none.
|
|
4345
|
+
if (isRecord(row) && typeof row.slug === "string" && !bySlug.has(row.slug)) {
|
|
4346
|
+
bySlug.set(row.slug, isRecord(row.data) ? { ...row.data, slug: row.slug } : row);
|
|
4347
|
+
}
|
|
4305
4348
|
}
|
|
4306
4349
|
}
|
|
4350
|
+
// A dynamic route reads \`page.entries[slug]\`: every published row, by its slug.
|
|
4351
|
+
const entries: Record<string, unknown> = Object.fromEntries(bySlug);
|
|
4352
|
+
for (const row of bySlug.values()) if (isRecord(row) && !("entries" in row)) row.entries = entries;
|
|
4307
4353
|
const pages = isRecord(snapshot) && Array.isArray(snapshot.pages) ? snapshot.pages : [];
|
|
4308
4354
|
const withLayout = pages.find((page: unknown) => isRecord(page) && isRecord(page.layout));
|
|
4309
|
-
|
|
4310
|
-
|
|
4355
|
+
// The site's Layout first; a page's own (its overrides resolved in) only where there is no other.
|
|
4356
|
+
const delivered = isRecord(snapshot) && isRecord(snapshot.layout) ? snapshot.layout : isRecord(withLayout) ? withLayout.layout : undefined;
|
|
4357
|
+
// A template addresses a Layout value as \`<section id>:<field id>\`; the published Layout holds
|
|
4358
|
+
// each Section's values under \`sections.<slug>.fields\`. Both spellings are answered.
|
|
4359
|
+
const layout: Record<string, unknown> | undefined = isRecord(delivered) ? { ...delivered } : undefined;
|
|
4360
|
+
for (const section of layout && isRecord(delivered) && isRecord(delivered.sections) ? Object.values(delivered.sections) : []) {
|
|
4361
|
+
if (!isRecord(section) || typeof section.id !== "string" || !isRecord(section.fields)) continue;
|
|
4362
|
+
for (const [field, value] of Object.entries(section.fields)) layout![section.id + ":" + field] = value;
|
|
4363
|
+
}
|
|
4364
|
+
// \`helper\` names this reader in the built bundle: the platform reads it to know the app shows a
|
|
4365
|
+
// publish on its next request, and an image BetterCMS copied beside it (@see bcmsImage).
|
|
4366
|
+
bcmsLive = { helper: "bcms-helper@${HELPER_VERSION}", stamp, bySlug, entries, layout };
|
|
4311
4367
|
} catch {
|
|
4312
4368
|
// Unreadable, or caught mid-write: keep serving the last good copy.
|
|
4313
4369
|
}
|
|
@@ -4319,7 +4375,10 @@ function live(page: unknown): unknown {
|
|
|
4319
4375
|
const content = liveContent();
|
|
4320
4376
|
if (!content) return page;
|
|
4321
4377
|
if (page === layoutSnapshot) return isRecord(content.layout) ? content.layout : page;
|
|
4322
|
-
if (isRecord(page) && typeof page.slug === "string")
|
|
4378
|
+
if (isRecord(page) && typeof page.slug === "string") {
|
|
4379
|
+
// A collection's page has no row of its own: its entries are the published ones all the same.
|
|
4380
|
+
return content.bySlug.get(page.slug) ?? ("entries" in page ? page : { ...page, entries: content.entries });
|
|
4381
|
+
}
|
|
4323
4382
|
return page;
|
|
4324
4383
|
}
|
|
4325
4384
|
|
|
@@ -4333,7 +4392,10 @@ export function bcms(page: unknown, path: string, fallback: string, slug?: strin
|
|
|
4333
4392
|
|
|
4334
4393
|
/** One field of the shared layout \u2014 the header and footer every route renders. */
|
|
4335
4394
|
export function bcmsLayout(path: string, fallback: string): string {
|
|
4336
|
-
|
|
4395
|
+
const layout = live(layoutSnapshot);
|
|
4396
|
+
// The whole address first: a field id may carry a dot of its own (\`derived.nav-cta\`).
|
|
4397
|
+
const whole = isRecord(layout) ? layout[path] : undefined;
|
|
4398
|
+
return render(whole !== undefined ? whole : readPath(layout, path)) ?? fallback;
|
|
4337
4399
|
}
|
|
4338
4400
|
|
|
4339
4401
|
/**
|
|
@@ -4410,13 +4472,24 @@ export function bcmsTuples(page: unknown, paths: string[][], fallback: string[][
|
|
|
4410
4472
|
* release write (\`writeImage\`) puts the editor's image on the \`data-bcms-field\` element, which
|
|
4411
4473
|
* is where every published value lands. A plain string fallback (\`<img src="/hero.png">\`) has
|
|
4412
4474
|
* no such rule and takes the CMS url directly.
|
|
4475
|
+
*
|
|
4476
|
+
* \u{1F534} ON A HOSTED SERVER THE EDITOR'S IMAGE IS A LOCAL FILE. A page the app renders per request has no
|
|
4477
|
+
* release write: what this returns is the page. BetterCMS copies a replaced image beside the app and
|
|
4478
|
+
* names the copy on the published value (\`local\`, with its size), and it is returned in the shape
|
|
4479
|
+
* of an import (\`{ src, width, height, format }\`), so \`<Image>\` optimises it like the site's own.
|
|
4480
|
+
* Absent (a build, or the site's own picture) the import stands.
|
|
4413
4481
|
*/
|
|
4414
4482
|
export function bcmsImage<T>(page: unknown, path: string, fallback: T, slug?: string): T | string {
|
|
4415
4483
|
page = live(page);
|
|
4416
|
-
if (fallback !== null && typeof fallback === "object") return fallback;
|
|
4417
4484
|
const entries = isRecord(page) ? page.entries : undefined;
|
|
4418
4485
|
const root = slug !== undefined && isRecord(entries) ? entries[slug] : page;
|
|
4419
|
-
|
|
4486
|
+
const value = readPath(root, path);
|
|
4487
|
+
if (fallback !== null && typeof fallback === "object") {
|
|
4488
|
+
if (!isRecord(value) || typeof value.local !== "string" || typeof value.width !== "number" || typeof value.height !== "number") return fallback;
|
|
4489
|
+
const format = /\\.([a-z0-9]+)$/i.exec(value.local)?.[1]?.toLowerCase() ?? "";
|
|
4490
|
+
return { src: value.local, width: value.width, height: value.height, format: format === "jpg" ? "jpeg" : format } as unknown as T;
|
|
4491
|
+
}
|
|
4492
|
+
return render(value) ?? fallback;
|
|
4420
4493
|
}
|
|
4421
4494
|
|
|
4422
4495
|
/**
|
|
@@ -4749,7 +4822,8 @@ var HISTORICAL = {
|
|
|
4749
4822
|
9: "d6441db2ff4578c3c5d4b7d461f3374b89247ef6ba9e0d535791e840d803e4f3",
|
|
4750
4823
|
10: "ffcb59862972b15a67c5951e9f3b94ce5b8b50345e6aac2bfa5789cfc3136910",
|
|
4751
4824
|
11: "ceb2a932d4c2761a5045ff35032e0b45130a0b5034547ff37579c72c9ce3594e",
|
|
4752
|
-
12: "10f71724484c84af12aa3eb90db44bf6acbf71fcf8ddff5b6361c1190ba16528"
|
|
4825
|
+
12: "10f71724484c84af12aa3eb90db44bf6acbf71fcf8ddff5b6361c1190ba16528",
|
|
4826
|
+
13: "cd8bdd74dbc944f2331503bdf8e995cb7f74f0d5b307d08aee4690e1695ae798"
|
|
4753
4827
|
},
|
|
4754
4828
|
"src/lib/bcms-content.ts": {
|
|
4755
4829
|
1: "ae72ae14d706943d481c880d70da5789b4816ba1ba319a10fbc63ded4d0b42f2",
|
|
@@ -4763,7 +4837,8 @@ var HISTORICAL = {
|
|
|
4763
4837
|
9: "eee9fb1635ae7d6f8f33676a1f390600ca24a62ee992775252273c73a011c494",
|
|
4764
4838
|
10: "35bb7c0e30f5c41fe68e52259ec4764de6de2734064fa0e1521f3338eef0a09e",
|
|
4765
4839
|
11: "818784f2088150d64d91b42eac944dc9503f104d32a0e7a18c753d2982a5adc0",
|
|
4766
|
-
12: "79dafc525a541feae55ada5dbe2ea966cac82acd741a4b41d61fe9e0cdfaa208"
|
|
4840
|
+
12: "79dafc525a541feae55ada5dbe2ea966cac82acd741a4b41d61fe9e0cdfaa208",
|
|
4841
|
+
13: "06ee70c9f0bb25e03f3930d2250da8dae172ade457f27ebba20382b6482ad3f4"
|
|
4767
4842
|
},
|
|
4768
4843
|
"composables/bcms-content.ts": {
|
|
4769
4844
|
1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
|
|
@@ -4777,7 +4852,8 @@ var HISTORICAL = {
|
|
|
4777
4852
|
9: "d6441db2ff4578c3c5d4b7d461f3374b89247ef6ba9e0d535791e840d803e4f3",
|
|
4778
4853
|
10: "ffcb59862972b15a67c5951e9f3b94ce5b8b50345e6aac2bfa5789cfc3136910",
|
|
4779
4854
|
11: "ceb2a932d4c2761a5045ff35032e0b45130a0b5034547ff37579c72c9ce3594e",
|
|
4780
|
-
12: "10f71724484c84af12aa3eb90db44bf6acbf71fcf8ddff5b6361c1190ba16528"
|
|
4855
|
+
12: "10f71724484c84af12aa3eb90db44bf6acbf71fcf8ddff5b6361c1190ba16528",
|
|
4856
|
+
13: "cd8bdd74dbc944f2331503bdf8e995cb7f74f0d5b307d08aee4690e1695ae798"
|
|
4781
4857
|
}
|
|
4782
4858
|
};
|
|
4783
4859
|
var sha256 = (value) => createHash2("sha256").update(value, "utf8").digest("hex");
|
|
@@ -10175,10 +10251,13 @@ async function convertSources(briefIn, sources, options) {
|
|
|
10175
10251
|
async function convertFile(file, content, targets, parsed, declared, llmFallback, drill) {
|
|
10176
10252
|
const outcomes = /* @__PURE__ */ new Map();
|
|
10177
10253
|
const pendingTargets = [];
|
|
10254
|
+
let unreadImages = false;
|
|
10178
10255
|
const dialect = dialectOf(file, content);
|
|
10179
10256
|
for (const target of targets) {
|
|
10180
10257
|
if (parsed && dialect && convertedHere(content, parsed, dialect, target, file)) {
|
|
10181
10258
|
outcomes.set(target.key, "declared");
|
|
10259
|
+
if (target.kind === "image" && !content.includes("bcmsImage(")) unreadImages = true;
|
|
10260
|
+
else if (target.kind === "image" && !new RegExp(`bcmsImage\\([^)]*["']${target.path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`).test(content)) unreadImages = true;
|
|
10182
10261
|
} else pendingTargets.push(target);
|
|
10183
10262
|
}
|
|
10184
10263
|
const empty = {
|
|
@@ -10193,7 +10272,7 @@ async function convertFile(file, content, targets, parsed, declared, llmFallback
|
|
|
10193
10272
|
snapshots: [],
|
|
10194
10273
|
notes: []
|
|
10195
10274
|
};
|
|
10196
|
-
if (pendingTargets.length === 0) return empty;
|
|
10275
|
+
if (pendingTargets.length === 0 && !unreadImages) return empty;
|
|
10197
10276
|
if (!dialect) {
|
|
10198
10277
|
if (isTolerantDialect(file)) {
|
|
10199
10278
|
const already = declaredByScan(content);
|
|
@@ -10394,15 +10473,15 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
|
|
|
10394
10473
|
continue;
|
|
10395
10474
|
}
|
|
10396
10475
|
const declares = (d) => d.scope === target.scope && (d.path === target.path || d.path === shapeOfPath(target.path));
|
|
10397
|
-
const
|
|
10476
|
+
const declaredAt2 = new Set(declarations.filter(declares).map((d) => d.at));
|
|
10398
10477
|
const claimedAt = new Set(declarations.filter((d) => !declares(d) && d.via === "field").map((d) => d.at));
|
|
10399
10478
|
const writesField = (site) => site.where !== "attr" || target.kind === "image";
|
|
10400
10479
|
const fresh = eligible.filter(
|
|
10401
|
-
(site) => !
|
|
10480
|
+
(site) => !declaredAt2.has(site.attrInsertAt) && !(writesField(site) && claimedAt.has(site.attrInsertAt))
|
|
10402
10481
|
);
|
|
10403
10482
|
located += eligible.length;
|
|
10404
10483
|
const blocked = eligible.filter(
|
|
10405
|
-
(site) => !
|
|
10484
|
+
(site) => !declaredAt2.has(site.attrInsertAt) && writesField(site) && claimedAt.has(site.attrInsertAt)
|
|
10406
10485
|
);
|
|
10407
10486
|
if (blocked.length > 0) {
|
|
10408
10487
|
outcomes.set(target.key, "AMBIGUOUS_LITERAL");
|
|
@@ -10411,7 +10490,7 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
|
|
|
10411
10490
|
if (fresh.length === 0) {
|
|
10412
10491
|
outcomes.set(
|
|
10413
10492
|
target.key,
|
|
10414
|
-
eligible.some((site) =>
|
|
10493
|
+
eligible.some((site) => declaredAt2.has(site.attrInsertAt)) ? "declared" : "AMBIGUOUS_LITERAL"
|
|
10415
10494
|
);
|
|
10416
10495
|
continue;
|
|
10417
10496
|
}
|
|
@@ -10597,8 +10676,8 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
|
|
|
10597
10676
|
},
|
|
10598
10677
|
targets.filter((t) => {
|
|
10599
10678
|
const outcome = outcomes.get(t.key);
|
|
10600
|
-
return t.kind === "image" && t.scope === "page" && (outcome === void 0 || UNPLACED.has(outcome));
|
|
10601
|
-
}).map((t) => ({ key: t.key, path: t.path, kind: t.kind, original: t.fallback, slug: t.slug }))
|
|
10679
|
+
return t.kind === "image" && t.scope === "page" && (outcome === void 0 || outcome === "declared" || UNPLACED.has(outcome));
|
|
10680
|
+
}).map((t) => ({ key: t.key, path: t.path, kind: t.kind, original: t.fallback, slug: t.slug, declared: outcomes.get(t.key) === "declared" }))
|
|
10602
10681
|
);
|
|
10603
10682
|
for (const key of images.bound) outcomes.set(key, "rewritten");
|
|
10604
10683
|
for (const refusal of images.refused) outcomes.set(refusal.key, refusal.reason);
|