@omnitend/dashboard-for-laravel 0.43.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/base/DFormSelect.vue.d.ts +1 -1
- package/dist/components/extended/DXChoiceField.vue.d.ts +3 -3
- package/dist/components/extended/DXForm.vue.d.ts +66 -1
- package/dist/components/extended/DXFormErrorSummary.vue.d.ts +70 -0
- package/dist/components/extended/DXFormField.vue.d.ts +3 -0
- package/dist/components/extended/DXLoading.vue.d.ts +3 -0
- package/dist/components/extended/DXTable.vue.d.ts +20 -0
- package/dist/components/extended/DXTableEditorModal.vue.d.ts +14 -10
- package/dist/composables/useAsyncOptions.d.ts +14 -1
- package/dist/composables/useForm.d.ts +26 -0
- package/dist/dashboard-for-laravel.js +13605 -13058
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +8 -8
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +12 -0
- package/dist/utils/formErrorTargets.d.ts +86 -0
- package/docs/public/api-reference.json +148 -8
- package/docs/public/docs-map.md +3 -2
- package/docs/public/llms.txt +3 -2
- package/package.json +9 -8
- package/resources/css/theme.scss +176 -0
- package/resources/js/components/base/DFormSelect.vue +5 -2
- package/resources/js/components/extended/DXChoiceField.vue +42 -26
- package/resources/js/components/extended/DXField.vue +51 -17
- package/resources/js/components/extended/DXForm.vue +676 -28
- package/resources/js/components/extended/DXFormErrorSummary.vue +232 -0
- package/resources/js/components/extended/DXFormField.vue +11 -2
- package/resources/js/components/extended/DXLoading.vue +18 -11
- package/resources/js/components/extended/DXRepeater.vue +30 -4
- package/resources/js/components/extended/DXTable.vue +38 -9
- package/resources/js/components/extended/DXTableEditorModal.vue +29 -1
- package/resources/js/composables/useAsyncOptions.ts +30 -5
- package/resources/js/composables/useForm.ts +196 -18
- package/resources/js/composables/useResourceEditor.ts +140 -107
- package/resources/js/index.ts +15 -0
- package/resources/js/types/index.ts +13 -0
- package/resources/js/utils/formErrorTargets.ts +317 -0
- package/scripts/mcp-server.mjs +58 -6
package/resources/js/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export * from 'bootstrap-vue-next';
|
|
|
7
7
|
// Extended components (custom functionality beyond Bootstrap Vue Next)
|
|
8
8
|
export { default as DXDashboard } from "./components/extended/DXDashboard.vue";
|
|
9
9
|
export { default as DXForm } from "./components/extended/DXForm.vue";
|
|
10
|
+
export { default as DXFormErrorSummary } from "./components/extended/DXFormErrorSummary.vue";
|
|
10
11
|
export { default as DXField } from "./components/extended/DXField.vue";
|
|
11
12
|
export { default as DXFieldLabel } from "./components/extended/DXFieldLabel.vue";
|
|
12
13
|
export { default as DXModalActions } from "./components/extended/DXModalActions.vue";
|
|
@@ -130,6 +131,12 @@ export {
|
|
|
130
131
|
isFieldVisible,
|
|
131
132
|
isSubmittableField,
|
|
132
133
|
} from "./utils/formSchema";
|
|
134
|
+
// Which field a validation-error key belongs to (#194): the one rule the
|
|
135
|
+
// error summary, tab selection and scrolling share.
|
|
136
|
+
export {
|
|
137
|
+
resolveErrorTargets,
|
|
138
|
+
humaniseErrorKey,
|
|
139
|
+
} from "./utils/formErrorTargets";
|
|
133
140
|
|
|
134
141
|
// Types
|
|
135
142
|
export type {
|
|
@@ -168,8 +175,16 @@ export type {
|
|
|
168
175
|
FormSubmitOptions,
|
|
169
176
|
FormState,
|
|
170
177
|
UseFormReturn,
|
|
178
|
+
SubmitFailure,
|
|
171
179
|
} from "./composables/useForm";
|
|
172
180
|
|
|
181
|
+
export type {
|
|
182
|
+
ErrorTarget,
|
|
183
|
+
ResolveErrorTargetsOptions,
|
|
184
|
+
} from "./utils/formErrorTargets";
|
|
185
|
+
|
|
186
|
+
export type { ErrorSummarySelection } from "./components/extended/DXFormErrorSummary.vue";
|
|
187
|
+
|
|
173
188
|
export type {
|
|
174
189
|
FormFieldDefinition,
|
|
175
190
|
DefineFormReturn,
|
|
@@ -367,6 +367,18 @@ export interface FieldDefinition {
|
|
|
367
367
|
*/
|
|
368
368
|
submit?: boolean;
|
|
369
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Extra validation-error keys this field owns (#194), for a field whose
|
|
372
|
+
* server-side errors arrive under keys other than its own: a span or
|
|
373
|
+
* component field that edits nested data, say. Patterns, where `*`
|
|
374
|
+
* matches exactly one dot segment: `["lines.*", "lines.*.*"]` claims
|
|
375
|
+
* `lines.0` and `lines.0.price` but not `lines` or `lines.0.a.b`.
|
|
376
|
+
* Checked before the usual matching (the field's own key, then any key
|
|
377
|
+
* nested under it), so it decides which field an error belongs to: the
|
|
378
|
+
* label it is listed under in the error summary and the tab it selects.
|
|
379
|
+
*/
|
|
380
|
+
errorKeys?: string[];
|
|
381
|
+
|
|
370
382
|
/**
|
|
371
383
|
* Conditionally show or hide this field. When omitted the field is
|
|
372
384
|
* always visible. Boolean or a function of the form model; evaluated
|
|
@@ -427,6 +439,7 @@ export interface BaseFieldDef {
|
|
|
427
439
|
readonly?: MaybeFn<boolean>;
|
|
428
440
|
plaintext?: MaybeFn<boolean>;
|
|
429
441
|
submit?: boolean;
|
|
442
|
+
errorKeys?: string[];
|
|
430
443
|
when?: MaybeFn<boolean>;
|
|
431
444
|
show?: () => boolean;
|
|
432
445
|
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which field a validation-error key belongs to (#194).
|
|
3
|
+
*
|
|
4
|
+
* One pure function decides, so the error summary's rows, the tab a failed
|
|
5
|
+
* submit selects and the field it scrolls to can never disagree. No Vue: the
|
|
6
|
+
* caller injects visibility and label resolution, so DXForm can pass its own
|
|
7
|
+
* `when`/`context`-aware predicates and a standalone summary can use the
|
|
8
|
+
* defaults below.
|
|
9
|
+
*/
|
|
10
|
+
import type { FieldDefinition, FormTab } from "../types";
|
|
11
|
+
import type { ValidationErrors } from "../composables/useForm";
|
|
12
|
+
import {
|
|
13
|
+
isFieldVisible as isFieldVisibleFor,
|
|
14
|
+
resolvePredicate,
|
|
15
|
+
} from "./formSchema";
|
|
16
|
+
import { getByPath } from "./objectPath";
|
|
17
|
+
|
|
18
|
+
/** One error key and where it belongs. */
|
|
19
|
+
export interface ErrorTarget {
|
|
20
|
+
/** The key as the server sent it (`lines.0.price`). */
|
|
21
|
+
errorKey: string;
|
|
22
|
+
/** The server's messages for that key, as sent. */
|
|
23
|
+
messages: string[];
|
|
24
|
+
/**
|
|
25
|
+
* One display line per message: the message itself, or `"Label: message"`
|
|
26
|
+
* when the message does not already say which field it is about (see
|
|
27
|
+
* `resolveErrorTargets`).
|
|
28
|
+
*/
|
|
29
|
+
lines: string[];
|
|
30
|
+
/** The owning field's key, or `null` when no visible field owns it. */
|
|
31
|
+
fieldKey: string | null;
|
|
32
|
+
/** The tab holding the owning field, or `null` (flat form, or unowned). */
|
|
33
|
+
tabKey: string | null;
|
|
34
|
+
/** A readable name for the key: "Price (line 1)", "Delivery date". */
|
|
35
|
+
label: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ResolveErrorTargetsOptions {
|
|
39
|
+
/** The form's field definitions. */
|
|
40
|
+
fields?: FieldDefinition[];
|
|
41
|
+
/**
|
|
42
|
+
* The form's tabs. When given (non-empty), only fields listed in a
|
|
43
|
+
* visible tab can own an error, as only those render. A tab key with no
|
|
44
|
+
* field definition (consumer-rendered content) can still own its own key
|
|
45
|
+
* and keys nested under it.
|
|
46
|
+
*/
|
|
47
|
+
tabs?: FormTab[];
|
|
48
|
+
/** Model for the default predicates and labels (form data + context). */
|
|
49
|
+
model?: Record<string, any>;
|
|
50
|
+
/** Field visibility. Default: `when` and `show` against `model`. */
|
|
51
|
+
isFieldVisible?: (field: FieldDefinition) => boolean;
|
|
52
|
+
/** Tab visibility. Default: the tab's `when` against `model`. */
|
|
53
|
+
isTabVisible?: (tab: FormTab) => boolean;
|
|
54
|
+
/**
|
|
55
|
+
* A field's label. Default: its `label`, called with `labelModel` when it
|
|
56
|
+
* is a function. `labelModel` is `model` for a form field and the ROW
|
|
57
|
+
* (`model.lines[0]`) for a repeater sub-field, as DXRepeater renders it.
|
|
58
|
+
* An empty result, or a label function that throws, falls back to the
|
|
59
|
+
* humanised key, so a broken label can never hide an error.
|
|
60
|
+
*/
|
|
61
|
+
resolveLabel?: (
|
|
62
|
+
field: FieldDefinition,
|
|
63
|
+
labelModel: Record<string, any>,
|
|
64
|
+
) => string | null | undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface Candidate {
|
|
68
|
+
field: FieldDefinition;
|
|
69
|
+
tabKey: string | null;
|
|
70
|
+
/** Position for ordering: [tab index, position within the tab / form]. */
|
|
71
|
+
rank: [number, number];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Sorts after every owned key. */
|
|
75
|
+
const UNOWNED_RANK = Number.MAX_SAFE_INTEGER;
|
|
76
|
+
|
|
77
|
+
const NUMERIC_SEGMENT = /^\d+$/;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A message that names a raw key (`delivery_date`, `lines.0.price`) is not
|
|
81
|
+
* readable on its own, so it keeps its label prefix. A sentence-ending full
|
|
82
|
+
* stop or a decimal (`5.5`) is not a key.
|
|
83
|
+
*/
|
|
84
|
+
const RAW_KEY_IN_MESSAGE = /\w_\w|[A-Za-z0-9]\.[A-Za-z]|[A-Za-z]\.[0-9]/;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `delivery_date` → "Delivery date"; `lines.0.price` → "Lines price (line 1)".
|
|
88
|
+
* A trailing `id` word names the record, not the thing, so it is dropped
|
|
89
|
+
* (`supplier_id`, `supplierId` → "Supplier"); an `id` word that is the whole
|
|
90
|
+
* segment, or not at its end, reads "ID" (`id` → "ID"). Words that merely end
|
|
91
|
+
* in "id" (`paid`, `valid`) are untouched.
|
|
92
|
+
*/
|
|
93
|
+
export function humaniseErrorKey(key: string): string {
|
|
94
|
+
const words: string[] = [];
|
|
95
|
+
const lineNumbers: number[] = [];
|
|
96
|
+
for (const segment of key.split(".")) {
|
|
97
|
+
if (NUMERIC_SEGMENT.test(segment)) {
|
|
98
|
+
lineNumbers.push(Number(segment) + 1);
|
|
99
|
+
} else {
|
|
100
|
+
const segmentWords = segment
|
|
101
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
102
|
+
.split(/[_\-\s]+/)
|
|
103
|
+
.filter((word) => word !== "")
|
|
104
|
+
.map((word) => word.toLowerCase());
|
|
105
|
+
if (segmentWords.length > 1 && segmentWords[segmentWords.length - 1] === "id") {
|
|
106
|
+
segmentWords.pop();
|
|
107
|
+
}
|
|
108
|
+
words.push(...segmentWords.map((word) => (word === "id" ? "ID" : word)));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
let label = words.join(" ");
|
|
112
|
+
label = label.charAt(0).toUpperCase() + label.slice(1);
|
|
113
|
+
if (lineNumbers.length > 0) {
|
|
114
|
+
label = `${label} (line ${lineNumbers.join(", ")})`.trim();
|
|
115
|
+
}
|
|
116
|
+
return label === "" ? key : label;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `*` matches exactly one dot segment; everything else matches literally. */
|
|
120
|
+
function patternMatches(pattern: string, errorKey: string): boolean {
|
|
121
|
+
const patternSegments = pattern.split(".");
|
|
122
|
+
const keySegments = errorKey.split(".");
|
|
123
|
+
if (patternSegments.length !== keySegments.length) return false;
|
|
124
|
+
return patternSegments.every(
|
|
125
|
+
(segment, index) => segment === "*" || segment === keySegments[index],
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isNestedUnder(errorKey: string, fieldKey: string): boolean {
|
|
130
|
+
return errorKey.startsWith(`${fieldKey}.`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function defaultLabel(
|
|
134
|
+
field: FieldDefinition,
|
|
135
|
+
model: Record<string, any>,
|
|
136
|
+
): string | null | undefined {
|
|
137
|
+
return typeof field.label === "function" ? field.label(model) : field.label;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Resolve every key in `errors` to its owning field, tab and label, with the
|
|
142
|
+
* display line for each message. Rows come in tab order, then field order
|
|
143
|
+
* (the order of `tab.fieldKeys`, or of `fields` in a flat form), then the
|
|
144
|
+
* order the server sent the keys; keys no visible field owns come last.
|
|
145
|
+
*
|
|
146
|
+
* Ownership, first match wins: a field whose `errorKeys` pattern matches; a
|
|
147
|
+
* field whose key equals the error key; the field with the longest key that
|
|
148
|
+
* is a dot prefix of it (repeater rows, media maps). Hidden fields, and
|
|
149
|
+
* fields on hidden tabs or on no tab of a tabbed form, never own a key; it
|
|
150
|
+
* falls through to the next rule, or is unowned.
|
|
151
|
+
*
|
|
152
|
+
* Labels: the owning field's label; for `field.N.sub` the sub-field's label
|
|
153
|
+
* (when the field defines `fields` and one has that key) or the field's own,
|
|
154
|
+
* plus "(line N+1)"; for any other nested key (a media map's
|
|
155
|
+
* `image_media.<uuid>`) the field's label. An unowned key is humanised.
|
|
156
|
+
*
|
|
157
|
+
* Display lines: "Label: message", except that the label is left off when
|
|
158
|
+
* the message already contains it (case-insensitive) — unless the message
|
|
159
|
+
* contains a raw key (`_`, or a dot inside a word) or another row has the
|
|
160
|
+
* same message text, when the label is what tells the rows apart.
|
|
161
|
+
*/
|
|
162
|
+
export function resolveErrorTargets(
|
|
163
|
+
errors: ValidationErrors | null | undefined,
|
|
164
|
+
options: ResolveErrorTargetsOptions = {},
|
|
165
|
+
): ErrorTarget[] {
|
|
166
|
+
const fields = options.fields ?? [];
|
|
167
|
+
const model = options.model ?? {};
|
|
168
|
+
const isFieldVisible =
|
|
169
|
+
options.isFieldVisible ??
|
|
170
|
+
((field: FieldDefinition) => isFieldVisibleFor(field, model));
|
|
171
|
+
const isTabVisible =
|
|
172
|
+
options.isTabVisible ??
|
|
173
|
+
((tab: FormTab) => resolvePredicate(tab.when, model, true));
|
|
174
|
+
const resolveLabel = options.resolveLabel ?? defaultLabel;
|
|
175
|
+
|
|
176
|
+
const labelFor = (
|
|
177
|
+
field: FieldDefinition,
|
|
178
|
+
labelModel: Record<string, any> = model,
|
|
179
|
+
): string => {
|
|
180
|
+
let label: string | null | undefined;
|
|
181
|
+
try {
|
|
182
|
+
label = resolveLabel(field, labelModel);
|
|
183
|
+
} catch {
|
|
184
|
+
// A consumer's label function threw (a row missing a property
|
|
185
|
+
// it reads): the error must still be listed, under its key.
|
|
186
|
+
label = undefined;
|
|
187
|
+
}
|
|
188
|
+
return typeof label === "string" && label.trim() !== ""
|
|
189
|
+
? label
|
|
190
|
+
: humaniseErrorKey(field.key);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// ——— which fields can own an error, in display order
|
|
194
|
+
const candidates: Candidate[] = [];
|
|
195
|
+
const tabs = options.tabs ?? [];
|
|
196
|
+
if (tabs.length > 0) {
|
|
197
|
+
const fieldByKey = new Map(fields.map((field) => [field.key, field]));
|
|
198
|
+
const placed = new Set<string>();
|
|
199
|
+
tabs.forEach((tab, tabIndex) => {
|
|
200
|
+
if (!isTabVisible(tab)) return;
|
|
201
|
+
tab.fieldKeys.forEach((key, position) => {
|
|
202
|
+
if (placed.has(key)) return;
|
|
203
|
+
const field = fieldByKey.get(key);
|
|
204
|
+
// A key with no definition is consumer-rendered content: the
|
|
205
|
+
// tab decides whether it shows.
|
|
206
|
+
if (field && !isFieldVisible(field)) return;
|
|
207
|
+
placed.add(key);
|
|
208
|
+
candidates.push({
|
|
209
|
+
field: field ?? ({ key, type: "text" } as FieldDefinition),
|
|
210
|
+
tabKey: tab.key,
|
|
211
|
+
rank: [tabIndex, position],
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
} else {
|
|
216
|
+
fields.forEach((field, index) => {
|
|
217
|
+
if (!isFieldVisible(field)) return;
|
|
218
|
+
candidates.push({ field, tabKey: null, rank: [0, index] });
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const ownerOf = (errorKey: string): Candidate | null => {
|
|
223
|
+
const byPattern = candidates.find((candidate) =>
|
|
224
|
+
(candidate.field.errorKeys ?? []).some((pattern) =>
|
|
225
|
+
patternMatches(pattern, errorKey),
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
if (byPattern) return byPattern;
|
|
229
|
+
const exact = candidates.find(
|
|
230
|
+
(candidate) => candidate.field.key === errorKey,
|
|
231
|
+
);
|
|
232
|
+
if (exact) return exact;
|
|
233
|
+
let longest: Candidate | null = null;
|
|
234
|
+
for (const candidate of candidates) {
|
|
235
|
+
if (
|
|
236
|
+
isNestedUnder(errorKey, candidate.field.key) &&
|
|
237
|
+
(!longest || candidate.field.key.length > longest.field.key.length)
|
|
238
|
+
) {
|
|
239
|
+
longest = candidate;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return longest;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const ownedLabel = (errorKey: string, field: FieldDefinition): string => {
|
|
246
|
+
if (!isNestedUnder(errorKey, field.key)) return labelFor(field);
|
|
247
|
+
const [first, second] = errorKey.slice(field.key.length + 1).split(".");
|
|
248
|
+
if (!NUMERIC_SEGMENT.test(first)) return labelFor(field);
|
|
249
|
+
const subField =
|
|
250
|
+
second !== undefined
|
|
251
|
+
? field.fields?.find((candidate) => candidate.key === second)
|
|
252
|
+
: undefined;
|
|
253
|
+
// DXRepeater renders a sub-field with the ROW as its model, so its
|
|
254
|
+
// label resolves against the row here too.
|
|
255
|
+
const row = getByPath(model, `${field.key}.${first}`);
|
|
256
|
+
const rowModel = row !== null && typeof row === "object" ? row : {};
|
|
257
|
+
const base = subField ? labelFor(subField, rowModel) : labelFor(field);
|
|
258
|
+
return `${base} (line ${Number(first) + 1})`;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// ——— one target per error key with at least one message
|
|
262
|
+
interface Pending {
|
|
263
|
+
target: Omit<ErrorTarget, "lines">;
|
|
264
|
+
rank: [number, number];
|
|
265
|
+
order: number;
|
|
266
|
+
}
|
|
267
|
+
const pending: Pending[] = [];
|
|
268
|
+
Object.entries(errors ?? {}).forEach(([errorKey, value], order) => {
|
|
269
|
+
const messages = (Array.isArray(value) ? value : [value]).filter(
|
|
270
|
+
(message): message is string =>
|
|
271
|
+
typeof message === "string" && message !== "",
|
|
272
|
+
);
|
|
273
|
+
if (messages.length === 0) return;
|
|
274
|
+
const owner = ownerOf(errorKey);
|
|
275
|
+
pending.push({
|
|
276
|
+
target: {
|
|
277
|
+
errorKey,
|
|
278
|
+
messages,
|
|
279
|
+
fieldKey: owner ? owner.field.key : null,
|
|
280
|
+
tabKey: owner ? owner.tabKey : null,
|
|
281
|
+
label: owner
|
|
282
|
+
? ownedLabel(errorKey, owner.field)
|
|
283
|
+
: humaniseErrorKey(errorKey),
|
|
284
|
+
},
|
|
285
|
+
rank: owner ? owner.rank : [UNOWNED_RANK, UNOWNED_RANK],
|
|
286
|
+
order,
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
pending.sort(
|
|
291
|
+
(left, right) =>
|
|
292
|
+
left.rank[0] - right.rank[0] ||
|
|
293
|
+
left.rank[1] - right.rank[1] ||
|
|
294
|
+
left.order - right.order,
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// ——— display lines, with the label prefix where it is needed
|
|
298
|
+
const messageCounts = new Map<string, number>();
|
|
299
|
+
for (const { target } of pending) {
|
|
300
|
+
for (const message of target.messages) {
|
|
301
|
+
messageCounts.set(message, (messageCounts.get(message) ?? 0) + 1);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const needsLabel = (message: string, label: string): boolean =>
|
|
305
|
+
(messageCounts.get(message) ?? 0) > 1 ||
|
|
306
|
+
RAW_KEY_IN_MESSAGE.test(message) ||
|
|
307
|
+
!message.toLowerCase().includes(label.toLowerCase());
|
|
308
|
+
|
|
309
|
+
return pending.map(({ target }) => ({
|
|
310
|
+
...target,
|
|
311
|
+
lines: target.messages.map((message) =>
|
|
312
|
+
needsLabel(message, target.label)
|
|
313
|
+
? `${target.label}: ${message}`
|
|
314
|
+
: message,
|
|
315
|
+
),
|
|
316
|
+
}));
|
|
317
|
+
}
|
package/scripts/mcp-server.mjs
CHANGED
|
@@ -7,15 +7,67 @@
|
|
|
7
7
|
* Works with Claude Desktop, Claude Code, and any MCP-compatible client.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
11
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
12
|
-
import {
|
|
13
|
-
CallToolRequestSchema,
|
|
14
|
-
ListToolsRequestSchema,
|
|
15
|
-
} from '@modelcontextprotocol/sdk/types.js';
|
|
16
10
|
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
17
11
|
import { join, dirname } from 'path';
|
|
18
12
|
import { fileURLToPath } from 'url';
|
|
13
|
+
import { createRequire } from 'module';
|
|
14
|
+
|
|
15
|
+
// The MCP SDK is an OPTIONAL peer dependency (#181): consumers of the
|
|
16
|
+
// component library should not install express/hono/ajv just to get Vue
|
|
17
|
+
// components. Load it dynamically so a missing SDK produces an instruction
|
|
18
|
+
// rather than an ERR_MODULE_NOT_FOUND stack trace.
|
|
19
|
+
//
|
|
20
|
+
// Absence is decided by RESOLVING the SDK's own entry point (which loads
|
|
21
|
+
// nothing), not by matching an import error: an error raised while the SDK
|
|
22
|
+
// loads (a missing transitive dependency, a syntax error) also mentions the
|
|
23
|
+
// SDK's path, and must surface as itself rather than as "install the SDK".
|
|
24
|
+
const SDK_ENTRY = '@modelcontextprotocol/sdk/server/index.js';
|
|
25
|
+
|
|
26
|
+
function sdkIsInstalled() {
|
|
27
|
+
// Synchronous import.meta.resolve needs Node 18.19 / 20.6; it resolves
|
|
28
|
+
// exactly as the import below will (the `import` condition).
|
|
29
|
+
if (typeof import.meta.resolve === 'function') {
|
|
30
|
+
try {
|
|
31
|
+
import.meta.resolve(SDK_ENTRY);
|
|
32
|
+
return true;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error?.code === 'ERR_MODULE_NOT_FOUND') return false;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Older Node 18/20: resolve through CommonJS. The SDK's `exports` map has a
|
|
39
|
+
// `require` condition for every subpath ("./*" -> "./dist/cjs/*"). Only
|
|
40
|
+
// "the package itself was not found" counts as absent: CommonJS reports a
|
|
41
|
+
// missing package with the specifier we passed, while a missing file
|
|
42
|
+
// inside an installed SDK names an absolute path and propagates, as does
|
|
43
|
+
// ERR_PACKAGE_PATH_NOT_EXPORTED.
|
|
44
|
+
try {
|
|
45
|
+
createRequire(import.meta.url).resolve(SDK_ENTRY);
|
|
46
|
+
return true;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (
|
|
49
|
+
error?.code === 'MODULE_NOT_FOUND' &&
|
|
50
|
+
String(error.message).startsWith(`Cannot find module '${SDK_ENTRY}'`)
|
|
51
|
+
) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!sdkIsInstalled()) {
|
|
59
|
+
console.error(
|
|
60
|
+
'dashboard-docs-mcp needs @modelcontextprotocol/sdk, which is an optional peer dependency.\n' +
|
|
61
|
+
'Install @modelcontextprotocol/sdk to use dashboard-docs-mcp:\n\n' +
|
|
62
|
+
' npm install --save-dev @modelcontextprotocol/sdk',
|
|
63
|
+
);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
|
|
67
|
+
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
|
68
|
+
const { CallToolRequestSchema, ListToolsRequestSchema } = await import(
|
|
69
|
+
'@modelcontextprotocol/sdk/types.js'
|
|
70
|
+
);
|
|
19
71
|
|
|
20
72
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
73
|
const rootDir = join(__dirname, '..');
|