@50arms/plugin-sdk 0.0.0-stage → 0.2.51
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/README.md +119 -2
- package/dist/card-kit.d.ts +1935 -0
- package/dist/cli.js +6091 -0
- package/dist/publish.d.ts +246 -0
- package/dist/publish.js +6066 -0
- package/dist/vite.d.ts +133 -0
- package/dist/vite.js +303 -0
- package/dist/widgets.d.ts +142 -0
- package/dist/widgets.js +14 -0
- package/package.json +81 -4
package/dist/vite.d.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Plugin } from 'vite';
|
|
2
|
+
|
|
3
|
+
/** The names of the surface-wide settings, one per member of {@link SurfaceWideSettings} (a new member fails `tsc` here). */
|
|
4
|
+
declare const SURFACE_WIDE_SETTING_KEYS: readonly ["date_range", "keyword"];
|
|
5
|
+
type SurfaceWideSettingKey = (typeof SURFACE_WIDE_SETTING_KEYS)[number];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* DX-6233 / DX-6269 (PLAN-96 G-6) - the schema a Widget's config and settings are declared in, and the check of a value against it. Import-free of Node: the
|
|
9
|
+
* app imports this module at runtime (`@backend/widget-surfaces/value-schema.js`) to draw a placement's form and to check what is stored, and the server
|
|
10
|
+
* imports the same module to refuse a write the schema rejects, so the two can never disagree about what a Widget accepts.
|
|
11
|
+
*
|
|
12
|
+
* Every field carries its default, so the schema is also where a Widget's defaults live. A value is checked as a (possibly partial) set of the schema's
|
|
13
|
+
* fields: a key the schema does not name, or a value of the wrong type, is an issue, never read as a default.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* DX-6254 - the words that name a field, or one option of a field: a built-in Widget's message id, which the app looks up in the person's language, or the text a
|
|
17
|
+
* plugin's author wrote (a plugin brings no message catalog). The Configure form shows these, never a field's key or an option's stored id.
|
|
18
|
+
*/
|
|
19
|
+
type FieldText = {
|
|
20
|
+
readonly messageId: string;
|
|
21
|
+
} | {
|
|
22
|
+
readonly text: string;
|
|
23
|
+
};
|
|
24
|
+
type ValueField = {
|
|
25
|
+
readonly type: "string";
|
|
26
|
+
readonly label: FieldText;
|
|
27
|
+
readonly default: string;
|
|
28
|
+
readonly maxLength?: number;
|
|
29
|
+
readonly enum?: readonly string[];
|
|
30
|
+
/** The words for each option of `enum`, by option id; present exactly when `enum` is, naming every option and no other. The stored value is the id. */
|
|
31
|
+
readonly optionLabels?: Readonly<Record<string, FieldText>>;
|
|
32
|
+
} | {
|
|
33
|
+
readonly type: "number";
|
|
34
|
+
readonly label: FieldText;
|
|
35
|
+
readonly default: number;
|
|
36
|
+
readonly min?: number;
|
|
37
|
+
readonly max?: number;
|
|
38
|
+
readonly integer?: boolean;
|
|
39
|
+
} | {
|
|
40
|
+
readonly type: "boolean";
|
|
41
|
+
readonly label: FieldText;
|
|
42
|
+
readonly default: boolean;
|
|
43
|
+
};
|
|
44
|
+
type ValueSchema = Readonly<Record<string, ValueField>>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* DX-6269 (PLAN-96 G-6) - the layout rules of a widget surface, as pure functions: snapping to whole cells, a Widget's size limits, collision, clamping and the
|
|
48
|
+
* first free space. Import-free of Node. The app runs them live while a placement is dragged or resized (so what the person sees while dragging is what is
|
|
49
|
+
* saved), and the server runs the same functions to check every layout write and to place a new or changed placement, so the two never disagree.
|
|
50
|
+
*
|
|
51
|
+
* Positions are 0-based cells; the grid is `GRID_COLUMNS` wide with unlimited rows. A layout never has two boxes overlapping: moving, resizing or changing a
|
|
52
|
+
* box pushes whatever it now overlaps down to just below it, and whatever that one now overlaps in turn. The box being edited never moves for another.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
interface Size {
|
|
56
|
+
w: number;
|
|
57
|
+
h: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* DX-6269 (PLAN-96 G-6; DX-6233 finding (d)) - a Widget's DECLARATION: the part of a Widget that is data - its key, the sizes it may take, its config and
|
|
62
|
+
* settings schemas, the surface-wide settings it reads and the host context it needs. The component that draws it, its title and its description are the
|
|
63
|
+
* app's (`frontend/src/lib/widgets/catalog.ts`). Import-free of Node, so the app, the server and DX-6254's plugin SDK all read the one declaration: the app
|
|
64
|
+
* to draw the Widget and its forms, the server to refuse a placement write the declaration rejects.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
type CellSize = Size;
|
|
68
|
+
interface WidgetDeclaration<C extends ValueSchema = ValueSchema, S extends ValueSchema = ValueSchema> {
|
|
69
|
+
/** Stable identity, stored on every placement: lowercase words joined by `-` for a built-in, `plugin:<plugin key>:<widget key>` for a plugin's (`pluginWidgetKey`). */
|
|
70
|
+
readonly key: string;
|
|
71
|
+
/** The size a new placement gets, and the smallest and largest one may take, in cells. */
|
|
72
|
+
readonly size: {
|
|
73
|
+
readonly default: CellSize;
|
|
74
|
+
readonly min: CellSize;
|
|
75
|
+
readonly max: CellSize;
|
|
76
|
+
};
|
|
77
|
+
readonly config: C;
|
|
78
|
+
readonly settings: S;
|
|
79
|
+
readonly readsSurfaceSettings: readonly SurfaceWideSettingKey[];
|
|
80
|
+
/** The host context keys the Widget cannot render without. */
|
|
81
|
+
readonly needsHostContext: readonly string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* DX-5170 / DX-4679 section 2 - the shape of `danx-plugin.json`, the manifest `@50arms/plugin-sdk` writes beside a built plugin and the
|
|
86
|
+
* upload validator (DX-5171) checks before a revision is stored: the entry module and every listed file present with the stated size and
|
|
87
|
+
* sha256, nothing unlisted, and `runtime` naming the majors the plugin was built against (`runtime-contract.ts`).
|
|
88
|
+
*
|
|
89
|
+
* Pure types and constants; the writer is the SDK's, the checks are the server's.
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
/** DX-6254: one Widget a plugin declares. Its words are the author's own text: a plugin brings no message catalog. */
|
|
93
|
+
interface PluginManifestWidget {
|
|
94
|
+
/** The name of the entry's export that draws the Widget, recorded at build only when the entry chunk really exports it. */
|
|
95
|
+
readonly export: string;
|
|
96
|
+
readonly title: string;
|
|
97
|
+
/** One sentence for the Widget Library: what the Widget shows. */
|
|
98
|
+
readonly description: string;
|
|
99
|
+
/** The built-in Widgets' own declaration, with `key` the Widget's key inside the plugin; a placement stores `plugin:<plugin key>:<key>`. */
|
|
100
|
+
readonly declaration: WidgetDeclaration;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The entry module every plugin build produces; the manifest names it and the app `import()`s it. */
|
|
104
|
+
declare const PLUGIN_ENTRY_FILE = "index.js";
|
|
105
|
+
interface DanxPluginOptions {
|
|
106
|
+
/** The module exporting `CardView` and/or `CardListView`, relative to the project root. */
|
|
107
|
+
readonly entry?: string;
|
|
108
|
+
/**
|
|
109
|
+
* DX-6178: the card types the plugin's `CardView` is made for (card type keys, e.g. `["Bug"]`). Each becomes that type's DEFAULT view once the plugin's revision is approved
|
|
110
|
+
* and active, and the team can always set the type back to it. Leave it out for a plugin made for no particular type.
|
|
111
|
+
*/
|
|
112
|
+
readonly cardTypes?: readonly string[];
|
|
113
|
+
/**
|
|
114
|
+
* DX-6254: the Widgets the plugin adds to the Widget Library. Each names the entry's export that draws it (a component taking `WidgetProps`), its title and
|
|
115
|
+
* description as the Library shows them, and its declaration (`declareWidget` from `@50arms/plugin-sdk/widgets`, `key` being the Widget's own key, lowercase
|
|
116
|
+
* words). The build refuses a Widget whose declaration cannot work or whose export the entry does not have.
|
|
117
|
+
*/
|
|
118
|
+
readonly widgets?: readonly PluginManifestWidget[];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The Vite preset for a 50 Arms card-type plugin: an ES-module library build whose output the app loads with `import()`.
|
|
122
|
+
*
|
|
123
|
+
* - The specifiers in `src/plugins/runtime-contract.ts` (`react`, `react/jsx-runtime`, `react-dom`, `@50arms/ui`, the card kit) stay
|
|
124
|
+
* EXTERNAL: the page's import map answers them with the app's own modules, so the plugin shares its single React and design system.
|
|
125
|
+
* - Anything else is bundled. Bundling a copy of a shared package (`react-dom/client` or `react-router` are not in the contract) would run a second copy
|
|
126
|
+
* beside the app's, so that fails the build instead of shipping.
|
|
127
|
+
* - Code-split chunks load by relative import from the entry's folder; CSS is emitted as one `index.css`.
|
|
128
|
+
* - `danx-plugin.json` is written beside the output: every file's size and sha256, the entry, the view modules it exports, its Widgets, the stylesheets and the runtime majors.
|
|
129
|
+
*/
|
|
130
|
+
declare function danxPlugin(options?: DanxPluginOptions): Plugin;
|
|
131
|
+
|
|
132
|
+
export { PLUGIN_ENTRY_FILE, danxPlugin };
|
|
133
|
+
export type { DanxPluginOptions };
|
package/dist/vite.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// ../../src/plugins/runtime-contract.ts
|
|
2
|
+
var CARD_KIT_MAJOR = 1;
|
|
3
|
+
var CARD_KIT_LIBRARY = "@danxbot/card-kit";
|
|
4
|
+
var PLUGIN_RUNTIME_LIBRARIES = [
|
|
5
|
+
{ name: "react", major: 19 },
|
|
6
|
+
{ name: "react-dom", major: 19 },
|
|
7
|
+
{ name: "@50arms/ui", major: 35 },
|
|
8
|
+
{ name: CARD_KIT_LIBRARY, major: CARD_KIT_MAJOR }
|
|
9
|
+
];
|
|
10
|
+
var PLUGIN_RUNTIME_SPECIFIERS = [
|
|
11
|
+
{ specifier: "react", library: "react" },
|
|
12
|
+
{ specifier: "react/jsx-runtime", library: "react" },
|
|
13
|
+
{ specifier: "react-dom", library: "react-dom" },
|
|
14
|
+
{ specifier: "@50arms/ui", library: "@50arms/ui" },
|
|
15
|
+
{ specifier: `${CARD_KIT_LIBRARY}@${CARD_KIT_MAJOR}`, library: CARD_KIT_LIBRARY }
|
|
16
|
+
];
|
|
17
|
+
var PLUGIN_RUNTIME_LEGACY_ALIASES = [{ specifier: "@danxbot/ui", library: "@50arms/ui", major: 33 }];
|
|
18
|
+
var PLUGIN_NEVER_BUNDLED_PACKAGES = [
|
|
19
|
+
...PLUGIN_RUNTIME_LIBRARIES.map((library) => library.name).filter((name) => name !== CARD_KIT_LIBRARY),
|
|
20
|
+
// A source still importing the old name, built with the current SDK, would bundle a second copy of the design system; the build refuses it.
|
|
21
|
+
...PLUGIN_RUNTIME_LEGACY_ALIASES.map((alias) => alias.specifier),
|
|
22
|
+
"scheduler",
|
|
23
|
+
"react-router"
|
|
24
|
+
];
|
|
25
|
+
function pluginRuntimeContract() {
|
|
26
|
+
return {
|
|
27
|
+
specifiers: PLUGIN_RUNTIME_SPECIFIERS.map((entry) => entry.specifier),
|
|
28
|
+
majors: Object.fromEntries(PLUGIN_RUNTIME_LIBRARIES.map((library) => [library.name, library.major]))
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/manifest.ts
|
|
33
|
+
import { createHash } from "node:crypto";
|
|
34
|
+
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
35
|
+
import { join, relative, sep } from "node:path";
|
|
36
|
+
|
|
37
|
+
// ../../src/plugins/plugin-manifest.ts
|
|
38
|
+
var PLUGIN_KEY_PATTERN = "^[a-z0-9][a-z0-9_-]{0,63}$";
|
|
39
|
+
var PLUGIN_MANIFEST_FILE = "danx-plugin.json";
|
|
40
|
+
var CARD_VIEW_EXPORT = "CardView";
|
|
41
|
+
var CARD_LIST_VIEW_EXPORT = "CardListView";
|
|
42
|
+
var PLUGIN_VIEW_EXPORTS = [CARD_VIEW_EXPORT, CARD_LIST_VIEW_EXPORT];
|
|
43
|
+
|
|
44
|
+
// src/manifest.ts
|
|
45
|
+
function filesUnder(root, dir = root) {
|
|
46
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? filesUnder(root, join(dir, entry.name)) : [join(dir, entry.name)]).sort();
|
|
47
|
+
}
|
|
48
|
+
function describeFile(root, file) {
|
|
49
|
+
const bytes = readFileSync(file);
|
|
50
|
+
return {
|
|
51
|
+
path: relative(root, file).split(sep).join("/"),
|
|
52
|
+
size: bytes.length,
|
|
53
|
+
sha256: createHash("sha256").update(bytes).digest("hex")
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function buildManifest(outDir, entry, exports, cardTypes, widgets) {
|
|
57
|
+
const files = filesUnder(outDir).map((file) => describeFile(outDir, file)).filter((file) => file.path !== PLUGIN_MANIFEST_FILE);
|
|
58
|
+
if (!files.some((file) => file.path === entry)) throw new Error(`danx-plugin: the build produced no entry module ${entry} in ${outDir}`);
|
|
59
|
+
return {
|
|
60
|
+
entry,
|
|
61
|
+
styles: files.filter((file) => file.path.endsWith(".css")).map((file) => file.path),
|
|
62
|
+
runtime: pluginRuntimeContract().majors,
|
|
63
|
+
exports: [...exports],
|
|
64
|
+
widgets: [...widgets],
|
|
65
|
+
// DX-6178: the card types the plugin's card view is made for, written only when the author named some.
|
|
66
|
+
...cardTypes.length === 0 ? {} : { cardTypes: [...cardTypes] },
|
|
67
|
+
files
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function writeManifest(outDir, entry, exports, cardTypes, widgets) {
|
|
71
|
+
const manifest = buildManifest(outDir, entry, exports, cardTypes, widgets);
|
|
72
|
+
writeFileSync(join(outDir, PLUGIN_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}
|
|
73
|
+
`);
|
|
74
|
+
return manifest;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ../../src/widget-surfaces/model.ts
|
|
78
|
+
var GRID_COLUMNS = 24;
|
|
79
|
+
var DATE_RANGE_MS = { "24h": 864e5, "7d": 7 * 864e5, "30d": 30 * 864e5 };
|
|
80
|
+
var SURFACE_WIDE_SETTING_KEYS = ["date_range", "keyword"];
|
|
81
|
+
|
|
82
|
+
// ../../src/widget-surfaces/value-schema.ts
|
|
83
|
+
function fieldIssue(field, schema, value) {
|
|
84
|
+
switch (schema.type) {
|
|
85
|
+
case "string":
|
|
86
|
+
if (typeof value !== "string") return { field, rule: "notText" };
|
|
87
|
+
if (schema.maxLength !== void 0 && value.length > schema.maxLength) return { field, rule: "tooLong", limit: schema.maxLength };
|
|
88
|
+
if (schema.enum !== void 0 && !schema.enum.includes(value)) return { field, rule: "notOneOf", options: schema.enum };
|
|
89
|
+
return null;
|
|
90
|
+
case "number":
|
|
91
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return { field, rule: "notNumber" };
|
|
92
|
+
if (schema.integer === true && !Number.isInteger(value)) return { field, rule: "notWhole" };
|
|
93
|
+
if (schema.min !== void 0 && value < schema.min) return { field, rule: "tooSmall", limit: schema.min };
|
|
94
|
+
if (schema.max !== void 0 && value > schema.max) return { field, rule: "tooLarge", limit: schema.max };
|
|
95
|
+
return null;
|
|
96
|
+
case "boolean":
|
|
97
|
+
return typeof value === "boolean" ? null : { field, rule: "notBoolean" };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function valueIssues(schema, values) {
|
|
101
|
+
if (typeof values !== "object" || values === null || Array.isArray(values)) return [{ field: "", rule: "notObject" }];
|
|
102
|
+
const issues = [];
|
|
103
|
+
for (const [name, value] of Object.entries(values)) {
|
|
104
|
+
const field = schema[name];
|
|
105
|
+
if (field === void 0) issues.push({ field: name, rule: "unknownField" });
|
|
106
|
+
else {
|
|
107
|
+
const issue = fieldIssue(name, field, value);
|
|
108
|
+
if (issue !== null) issues.push(issue);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return issues;
|
|
112
|
+
}
|
|
113
|
+
var blank = (text) => ("messageId" in text ? text.messageId : text.text).trim() === "";
|
|
114
|
+
function labelIssues(schema) {
|
|
115
|
+
const issues = [];
|
|
116
|
+
for (const [name, field] of Object.entries(schema)) {
|
|
117
|
+
if (blank(field.label)) issues.push(`field "${name}" has an empty label`);
|
|
118
|
+
if (field.type !== "string") continue;
|
|
119
|
+
const options = field.enum ?? [];
|
|
120
|
+
const labelled = Object.keys(field.optionLabels ?? {});
|
|
121
|
+
if (field.enum !== void 0 && field.optionLabels === void 0) issues.push(`field "${name}" offers options and labels none of them`);
|
|
122
|
+
if (field.enum === void 0 && field.optionLabels !== void 0) issues.push(`field "${name}" labels options it does not offer`);
|
|
123
|
+
for (const option of options) if (!labelled.includes(option)) issues.push(`field "${name}" has no label for option "${option}"`);
|
|
124
|
+
for (const option of labelled) if (!options.includes(option) && field.enum !== void 0) issues.push(`field "${name}" labels "${option}", which is not one of its options`);
|
|
125
|
+
for (const [option, text] of Object.entries(field.optionLabels ?? {})) if (blank(text)) issues.push(`field "${name}" has an empty label for option "${option}"`);
|
|
126
|
+
}
|
|
127
|
+
return issues;
|
|
128
|
+
}
|
|
129
|
+
function defaultsOf(schema) {
|
|
130
|
+
return Object.fromEntries(Object.entries(schema).map(([name, field]) => [name, field.default]));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ../../src/widget-surfaces/declaration.ts
|
|
134
|
+
var WIDGET_KEY_SHAPE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
135
|
+
var PLUGIN_KEY_SHAPE = new RegExp(PLUGIN_KEY_PATTERN);
|
|
136
|
+
var WIDGET_KEY_MAX_LENGTH = 120;
|
|
137
|
+
var PLUGIN_WIDGET_PREFIX = "plugin:";
|
|
138
|
+
function pluginWidgetKey(pluginKey, widgetKey) {
|
|
139
|
+
return `${PLUGIN_WIDGET_PREFIX}${pluginKey}:${widgetKey}`;
|
|
140
|
+
}
|
|
141
|
+
function declarationBodyIssues(def) {
|
|
142
|
+
const issues = [];
|
|
143
|
+
const { min, max, default: initial } = def.size;
|
|
144
|
+
for (const [label, size] of [["min", min], ["default", initial], ["max", max]]) {
|
|
145
|
+
if (!Number.isInteger(size.w) || !Number.isInteger(size.h) || size.w < 1 || size.h < 1) issues.push(`${label} size ${size.w}x${size.h} is not whole cells`);
|
|
146
|
+
if (size.w > GRID_COLUMNS) issues.push(`${label} width ${size.w} is wider than the ${GRID_COLUMNS}-column grid`);
|
|
147
|
+
}
|
|
148
|
+
if (initial.w < min.w || initial.h < min.h || initial.w > max.w || initial.h > max.h) issues.push("the default size is outside the min and max sizes");
|
|
149
|
+
for (const scope of ["config", "settings"]) {
|
|
150
|
+
for (const issue of valueIssues(def[scope], defaultsOf(def[scope]))) issues.push(`${scope} default: ${JSON.stringify(issue)}`);
|
|
151
|
+
for (const issue of labelIssues(def[scope])) issues.push(`${scope}: ${issue}`);
|
|
152
|
+
}
|
|
153
|
+
for (const key of def.readsSurfaceSettings) {
|
|
154
|
+
if (!SURFACE_WIDE_SETTING_KEYS.includes(key)) issues.push(`"${key}" is not a surface-wide setting`);
|
|
155
|
+
}
|
|
156
|
+
if (new Set(def.readsSurfaceSettings).size !== def.readsSurfaceSettings.length) issues.push("readsSurfaceSettings names a setting twice");
|
|
157
|
+
return issues;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ../../src/plugins/manifest-widgets.ts
|
|
161
|
+
var EXPORT_SHAPE = /^[A-Z][A-Za-z0-9]*$/;
|
|
162
|
+
var MAX_WIDGET_TITLE_LENGTH = 80;
|
|
163
|
+
var MAX_WIDGET_DESCRIPTION_LENGTH = 300;
|
|
164
|
+
var MAX_PLUGIN_WIDGETS = 32;
|
|
165
|
+
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
166
|
+
var keysAre = (value, required, optional = []) => required.every((key) => key in value) && Object.keys(value).every((key) => required.includes(key) || optional.includes(key));
|
|
167
|
+
var isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
168
|
+
var isFieldText = (value) => isObject(value) && (keysAre(value, ["messageId"]) && typeof value.messageId === "string" || keysAre(value, ["text"]) && typeof value.text === "string");
|
|
169
|
+
var isSize = (value) => isObject(value) && keysAre(value, ["w", "h"]) && typeof value.w === "number" && typeof value.h === "number";
|
|
170
|
+
var isOptionalNumber = (value) => value === void 0 || typeof value === "number";
|
|
171
|
+
function fieldShapeIsValid(field) {
|
|
172
|
+
if (!isObject(field) || !isFieldText(field.label)) return false;
|
|
173
|
+
switch (field.type) {
|
|
174
|
+
case "string":
|
|
175
|
+
return keysAre(field, ["type", "label", "default"], ["maxLength", "enum", "optionLabels"]) && typeof field.default === "string" && isOptionalNumber(field.maxLength) && (field.enum === void 0 || isStringArray(field.enum)) && (field.optionLabels === void 0 || isObject(field.optionLabels) && Object.values(field.optionLabels).every(isFieldText));
|
|
176
|
+
case "number":
|
|
177
|
+
return keysAre(field, ["type", "label", "default"], ["min", "max", "integer"]) && typeof field.default === "number" && isOptionalNumber(field.min) && isOptionalNumber(field.max) && (field.integer === void 0 || typeof field.integer === "boolean");
|
|
178
|
+
case "boolean":
|
|
179
|
+
return keysAre(field, ["type", "label", "default"]) && typeof field.default === "boolean";
|
|
180
|
+
default:
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function declarationShapeIssues(value) {
|
|
185
|
+
if (!isObject(value) || !keysAre(value, ["key", "size", "config", "settings", "readsSurfaceSettings", "needsHostContext"])) {
|
|
186
|
+
return ["the declaration must hold exactly key, size, config, settings, readsSurfaceSettings and needsHostContext"];
|
|
187
|
+
}
|
|
188
|
+
const issues = [];
|
|
189
|
+
if (typeof value.key !== "string") issues.push("the key must be text");
|
|
190
|
+
if (!isObject(value.size) || !keysAre(value.size, ["default", "min", "max"]) || ![value.size.default, value.size.min, value.size.max].every(isSize)) issues.push("size must hold default, min and max, each {w, h} in cells");
|
|
191
|
+
for (const scope of ["config", "settings"]) {
|
|
192
|
+
const schema = value[scope];
|
|
193
|
+
if (!isObject(schema)) issues.push(`${scope} must be an object of fields`);
|
|
194
|
+
else for (const [name, field] of Object.entries(schema)) if (!fieldShapeIsValid(field)) issues.push(`${scope} field "${name}" is not a string, number or boolean field with a label and a default`);
|
|
195
|
+
}
|
|
196
|
+
if (!isStringArray(value.readsSurfaceSettings)) issues.push("readsSurfaceSettings must be a list of setting names");
|
|
197
|
+
if (!isStringArray(value.needsHostContext)) issues.push("needsHostContext must be a list of host context keys");
|
|
198
|
+
return issues;
|
|
199
|
+
}
|
|
200
|
+
function manifestWidgetIssues(widgets, pluginKey) {
|
|
201
|
+
if (!Array.isArray(widgets)) return ["widgets must be a list"];
|
|
202
|
+
if (widgets.length > MAX_PLUGIN_WIDGETS) return [`a plugin declares at most ${MAX_PLUGIN_WIDGETS} Widgets`];
|
|
203
|
+
const issues = [];
|
|
204
|
+
const exports = /* @__PURE__ */ new Set();
|
|
205
|
+
const keys = /* @__PURE__ */ new Set();
|
|
206
|
+
widgets.forEach((widget, index) => {
|
|
207
|
+
if (!isObject(widget) || !keysAre(widget, ["export", "title", "description", "declaration"])) {
|
|
208
|
+
issues.push(`widget ${index + 1}: must hold exactly export, title, description and declaration`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const name = typeof widget.export === "string" ? widget.export : `widget ${index + 1}`;
|
|
212
|
+
const say = (issue) => issues.push(`${name}: ${issue}`);
|
|
213
|
+
if (typeof widget.export !== "string" || !EXPORT_SHAPE.test(widget.export) || PLUGIN_VIEW_EXPORTS.includes(widget.export)) {
|
|
214
|
+
say(`the export must be a component name (a capital letter, then letters and digits), and not ${PLUGIN_VIEW_EXPORTS.join(" or ")}`);
|
|
215
|
+
} else if (exports.has(widget.export)) say("two Widgets name this export");
|
|
216
|
+
else exports.add(widget.export);
|
|
217
|
+
for (const [field, max] of [["title", MAX_WIDGET_TITLE_LENGTH], ["description", MAX_WIDGET_DESCRIPTION_LENGTH]]) {
|
|
218
|
+
const text = widget[field];
|
|
219
|
+
if (typeof text !== "string" || text.trim() === "" || text.length > max) say(`the ${field} must be text of 1 to ${max} characters`);
|
|
220
|
+
}
|
|
221
|
+
const shape = declarationShapeIssues(widget.declaration);
|
|
222
|
+
if (shape.length > 0) {
|
|
223
|
+
for (const issue of shape) say(issue);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const declaration = widget.declaration;
|
|
227
|
+
if (!WIDGET_KEY_SHAPE.test(declaration.key)) say(`the key "${declaration.key}" is not lowercase words joined by "-"`);
|
|
228
|
+
else if (keys.has(declaration.key)) say(`two Widgets have the key "${declaration.key}"`);
|
|
229
|
+
else keys.add(declaration.key);
|
|
230
|
+
if (pluginKey !== null && pluginWidgetKey(pluginKey, declaration.key).length > WIDGET_KEY_MAX_LENGTH) say(`its stored key ${pluginWidgetKey(pluginKey, declaration.key)} is longer than ${WIDGET_KEY_MAX_LENGTH} characters`);
|
|
231
|
+
for (const issue of declarationBodyIssues(declaration)) say(issue);
|
|
232
|
+
});
|
|
233
|
+
return issues;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// src/vite.ts
|
|
237
|
+
var PLUGIN_ENTRY_FILE = "index.js";
|
|
238
|
+
var STYLESHEET_FILE = "index.css";
|
|
239
|
+
function bundledNeverPackage(moduleId) {
|
|
240
|
+
const normalised = moduleId.replaceAll("\\", "/");
|
|
241
|
+
return PLUGIN_NEVER_BUNDLED_PACKAGES.find((name) => normalised.includes(`/node_modules/${name}/`));
|
|
242
|
+
}
|
|
243
|
+
function danxPlugin(options = {}) {
|
|
244
|
+
const entry = options.entry ?? "src/index.tsx";
|
|
245
|
+
const widgets = options.widgets ?? [];
|
|
246
|
+
let viewExports = [];
|
|
247
|
+
return {
|
|
248
|
+
name: "danx-plugin",
|
|
249
|
+
config() {
|
|
250
|
+
return {
|
|
251
|
+
// The runtime-contract specifiers include `react/jsx-runtime`, never `react/jsx-dev-runtime`.
|
|
252
|
+
esbuild: { jsx: "automatic", jsxDev: false },
|
|
253
|
+
// Library mode leaves `process.env.NODE_ENV` for the consumer; a browser has no `process`.
|
|
254
|
+
define: { "process.env.NODE_ENV": JSON.stringify("production") },
|
|
255
|
+
build: {
|
|
256
|
+
target: "es2022",
|
|
257
|
+
sourcemap: true,
|
|
258
|
+
cssCodeSplit: false,
|
|
259
|
+
lib: { entry, formats: ["es"], fileName: () => PLUGIN_ENTRY_FILE },
|
|
260
|
+
rollupOptions: {
|
|
261
|
+
external: PLUGIN_RUNTIME_SPECIFIERS.map((shared) => shared.specifier),
|
|
262
|
+
output: {
|
|
263
|
+
chunkFileNames: "chunks/[name]-[hash].js",
|
|
264
|
+
assetFileNames: (asset) => asset.names.some((name) => name.endsWith(".css")) ? STYLESHEET_FILE : "assets/[name]-[hash][extname]"
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
},
|
|
270
|
+
generateBundle(_options, bundle) {
|
|
271
|
+
const entryChunk = Object.values(bundle).find((output) => output.type === "chunk" && output.isEntry);
|
|
272
|
+
if (entryChunk?.type !== "chunk") throw new Error(`danx-plugin: the build produced no entry chunk for ${entry}`);
|
|
273
|
+
viewExports = PLUGIN_VIEW_EXPORTS.filter((name) => entryChunk.exports.includes(name));
|
|
274
|
+
if (viewExports.length === 0 && widgets.length === 0) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`danx-plugin: ${entry} must export ${PLUGIN_VIEW_EXPORTS.join(" and/or ")}, or the plugin must declare Widgets, but it exports ${entryChunk.exports.length === 0 ? "nothing" : entryChunk.exports.join(", ")}.`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
const issues = manifestWidgetIssues(widgets, null);
|
|
280
|
+
if (issues.length > 0) throw new Error(`danx-plugin: the plugin's Widgets cannot be used: ${issues.join("; ")}`);
|
|
281
|
+
const missing = widgets.filter((widget) => !entryChunk.exports.includes(widget.export)).map((widget) => widget.export);
|
|
282
|
+
if (missing.length > 0) throw new Error(`danx-plugin: ${entry} does not export ${missing.join(", ")}, which the plugin's Widgets name as their components.`);
|
|
283
|
+
for (const output of Object.values(bundle)) {
|
|
284
|
+
if (output.type !== "chunk") continue;
|
|
285
|
+
for (const id of output.moduleIds) {
|
|
286
|
+
const duplicate = bundledNeverPackage(id);
|
|
287
|
+
if (duplicate === void 0) continue;
|
|
288
|
+
throw new Error(
|
|
289
|
+
`danx-plugin: ${output.fileName} would bundle a copy of ${duplicate}, which the app already serves (${id}). Import only the specifiers the runtime contract lists (${PLUGIN_RUNTIME_SPECIFIERS.map((shared) => shared.specifier).join(", ")}); the card kit also re-exports Link and useNavigate in place of react-router.`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
writeBundle(output) {
|
|
295
|
+
if (output.dir === void 0) throw new Error("danx-plugin: the build has no output directory to write the manifest into");
|
|
296
|
+
writeManifest(output.dir, PLUGIN_ENTRY_FILE, viewExports, options.cardTypes ?? [], widgets);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
export {
|
|
301
|
+
PLUGIN_ENTRY_FILE,
|
|
302
|
+
danxPlugin
|
|
303
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
declare const DATE_RANGES: readonly ["24h", "7d", "30d"];
|
|
2
|
+
type DateRange = (typeof DATE_RANGES)[number];
|
|
3
|
+
/** Every surface-wide setting a surface stores a default for. */
|
|
4
|
+
interface SurfaceWideSettings {
|
|
5
|
+
date_range: DateRange;
|
|
6
|
+
keyword: string;
|
|
7
|
+
}
|
|
8
|
+
/** The names of the surface-wide settings, one per member of {@link SurfaceWideSettings} (a new member fails `tsc` here). */
|
|
9
|
+
declare const SURFACE_WIDE_SETTING_KEYS: readonly ["date_range", "keyword"];
|
|
10
|
+
type SurfaceWideSettingKey = (typeof SURFACE_WIDE_SETTING_KEYS)[number];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* DX-6233 / DX-6269 (PLAN-96 G-6) - the schema a Widget's config and settings are declared in, and the check of a value against it. Import-free of Node: the
|
|
14
|
+
* app imports this module at runtime (`@backend/widget-surfaces/value-schema.js`) to draw a placement's form and to check what is stored, and the server
|
|
15
|
+
* imports the same module to refuse a write the schema rejects, so the two can never disagree about what a Widget accepts.
|
|
16
|
+
*
|
|
17
|
+
* Every field carries its default, so the schema is also where a Widget's defaults live. A value is checked as a (possibly partial) set of the schema's
|
|
18
|
+
* fields: a key the schema does not name, or a value of the wrong type, is an issue, never read as a default.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* DX-6254 - the words that name a field, or one option of a field: a built-in Widget's message id, which the app looks up in the person's language, or the text a
|
|
22
|
+
* plugin's author wrote (a plugin brings no message catalog). The Configure form shows these, never a field's key or an option's stored id.
|
|
23
|
+
*/
|
|
24
|
+
type FieldText = {
|
|
25
|
+
readonly messageId: string;
|
|
26
|
+
} | {
|
|
27
|
+
readonly text: string;
|
|
28
|
+
};
|
|
29
|
+
type ValueField = {
|
|
30
|
+
readonly type: "string";
|
|
31
|
+
readonly label: FieldText;
|
|
32
|
+
readonly default: string;
|
|
33
|
+
readonly maxLength?: number;
|
|
34
|
+
readonly enum?: readonly string[];
|
|
35
|
+
/** The words for each option of `enum`, by option id; present exactly when `enum` is, naming every option and no other. The stored value is the id. */
|
|
36
|
+
readonly optionLabels?: Readonly<Record<string, FieldText>>;
|
|
37
|
+
} | {
|
|
38
|
+
readonly type: "number";
|
|
39
|
+
readonly label: FieldText;
|
|
40
|
+
readonly default: number;
|
|
41
|
+
readonly min?: number;
|
|
42
|
+
readonly max?: number;
|
|
43
|
+
readonly integer?: boolean;
|
|
44
|
+
} | {
|
|
45
|
+
readonly type: "boolean";
|
|
46
|
+
readonly label: FieldText;
|
|
47
|
+
readonly default: boolean;
|
|
48
|
+
};
|
|
49
|
+
type ValueSchema = Readonly<Record<string, ValueField>>;
|
|
50
|
+
type FieldValue<F extends ValueField> = F extends {
|
|
51
|
+
type: "string";
|
|
52
|
+
} ? string : F extends {
|
|
53
|
+
type: "number";
|
|
54
|
+
} ? number : boolean;
|
|
55
|
+
type ValuesOf<S extends ValueSchema> = {
|
|
56
|
+
[K in keyof S]: FieldValue<S[K]>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* DX-6269 (PLAN-96 G-6) - the layout rules of a widget surface, as pure functions: snapping to whole cells, a Widget's size limits, collision, clamping and the
|
|
61
|
+
* first free space. Import-free of Node. The app runs them live while a placement is dragged or resized (so what the person sees while dragging is what is
|
|
62
|
+
* saved), and the server runs the same functions to check every layout write and to place a new or changed placement, so the two never disagree.
|
|
63
|
+
*
|
|
64
|
+
* Positions are 0-based cells; the grid is `GRID_COLUMNS` wide with unlimited rows. A layout never has two boxes overlapping: moving, resizing or changing a
|
|
65
|
+
* box pushes whatever it now overlaps down to just below it, and whatever that one now overlaps in turn. The box being edited never moves for another.
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
interface Size {
|
|
69
|
+
w: number;
|
|
70
|
+
h: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* DX-6269 (PLAN-96 G-6; DX-6233 finding (d)) - a Widget's DECLARATION: the part of a Widget that is data - its key, the sizes it may take, its config and
|
|
75
|
+
* settings schemas, the surface-wide settings it reads and the host context it needs. The component that draws it, its title and its description are the
|
|
76
|
+
* app's (`frontend/src/lib/widgets/catalog.ts`). Import-free of Node, so the app, the server and DX-6254's plugin SDK all read the one declaration: the app
|
|
77
|
+
* to draw the Widget and its forms, the server to refuse a placement write the declaration rejects.
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
type CellSize = Size;
|
|
81
|
+
interface WidgetDeclaration<C extends ValueSchema = ValueSchema, S extends ValueSchema = ValueSchema> {
|
|
82
|
+
/** Stable identity, stored on every placement: lowercase words joined by `-` for a built-in, `plugin:<plugin key>:<widget key>` for a plugin's (`pluginWidgetKey`). */
|
|
83
|
+
readonly key: string;
|
|
84
|
+
/** The size a new placement gets, and the smallest and largest one may take, in cells. */
|
|
85
|
+
readonly size: {
|
|
86
|
+
readonly default: CellSize;
|
|
87
|
+
readonly min: CellSize;
|
|
88
|
+
readonly max: CellSize;
|
|
89
|
+
};
|
|
90
|
+
readonly config: C;
|
|
91
|
+
readonly settings: S;
|
|
92
|
+
readonly readsSurfaceSettings: readonly SurfaceWideSettingKey[];
|
|
93
|
+
/** The host context keys the Widget cannot render without. */
|
|
94
|
+
readonly needsHostContext: readonly string[];
|
|
95
|
+
}
|
|
96
|
+
/** Identity function that lets TypeScript infer the schemas, so a component's `config` and `settings` props are typed from them. */
|
|
97
|
+
declare function declareWidget<C extends ValueSchema, S extends ValueSchema>(declaration: WidgetDeclaration<C, S>): WidgetDeclaration<C, S>;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* DX-6254 (moved from the app's `catalog.ts`, DX-6233) - what a host hands a Widget's component: the contract between the app and every Widget, a built-in's or a
|
|
101
|
+
* plugin's. Types only and import-free of Node, so the app and `@50arms/plugin-sdk/widgets` read this one module.
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
/** The extra facts about where a host is that a host passes to a Widget (for a plan page, `{ plan: "96" }`); the Dashboard passes none. */
|
|
105
|
+
type HostContext = Readonly<Record<string, string>>;
|
|
106
|
+
interface WidgetProps<C extends ValueSchema, S extends ValueSchema> {
|
|
107
|
+
/** The Widget's config defaults, then the placement's config. */
|
|
108
|
+
config: ValuesOf<C>;
|
|
109
|
+
/** The Widget's defaults, then the placement's defaults (everyone's), then the viewer's own override. */
|
|
110
|
+
settings: ValuesOf<S>;
|
|
111
|
+
/** Only the surface-wide settings the Widget declares it reads (`readsSurfaceSettings`); never the others. */
|
|
112
|
+
surface: Partial<SurfaceWideSettings>;
|
|
113
|
+
host: HostContext;
|
|
114
|
+
placement: {
|
|
115
|
+
readonly id: number;
|
|
116
|
+
readonly name: string | null;
|
|
117
|
+
};
|
|
118
|
+
/** Saves the viewer's own override of some of the Widget's settings for this placement. Rejects with the server's refusal. */
|
|
119
|
+
saveSettings: (patch: Partial<ValuesOf<S>>) => Promise<void>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* DX-5170 / DX-4679 section 2 - the shape of `danx-plugin.json`, the manifest `@50arms/plugin-sdk` writes beside a built plugin and the
|
|
124
|
+
* upload validator (DX-5171) checks before a revision is stored: the entry module and every listed file present with the stated size and
|
|
125
|
+
* sha256, nothing unlisted, and `runtime` naming the majors the plugin was built against (`runtime-contract.ts`).
|
|
126
|
+
*
|
|
127
|
+
* Pure types and constants; the writer is the SDK's, the checks are the server's.
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/** DX-6254: one Widget a plugin declares. Its words are the author's own text: a plugin brings no message catalog. */
|
|
131
|
+
interface PluginManifestWidget {
|
|
132
|
+
/** The name of the entry's export that draws the Widget, recorded at build only when the entry chunk really exports it. */
|
|
133
|
+
readonly export: string;
|
|
134
|
+
readonly title: string;
|
|
135
|
+
/** One sentence for the Widget Library: what the Widget shows. */
|
|
136
|
+
readonly description: string;
|
|
137
|
+
/** The built-in Widgets' own declaration, with `key` the Widget's key inside the plugin; a placement stores `plugin:<plugin key>:<key>`. */
|
|
138
|
+
readonly declaration: WidgetDeclaration;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export { declareWidget };
|
|
142
|
+
export type { CellSize, FieldText, HostContext, PluginManifestWidget as PluginWidget, SurfaceWideSettings, ValueField, ValueSchema, ValuesOf, WidgetDeclaration, WidgetProps };
|
package/dist/widgets.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// ../../src/widget-surfaces/model.ts
|
|
2
|
+
var DATE_RANGE_MS = { "24h": 864e5, "7d": 7 * 864e5, "30d": 30 * 864e5 };
|
|
3
|
+
|
|
4
|
+
// ../../src/plugins/plugin-manifest.ts
|
|
5
|
+
var PLUGIN_KEY_PATTERN = "^[a-z0-9][a-z0-9_-]{0,63}$";
|
|
6
|
+
|
|
7
|
+
// ../../src/widget-surfaces/declaration.ts
|
|
8
|
+
function declareWidget(declaration) {
|
|
9
|
+
return declaration;
|
|
10
|
+
}
|
|
11
|
+
var PLUGIN_KEY_SHAPE = new RegExp(PLUGIN_KEY_PATTERN);
|
|
12
|
+
export {
|
|
13
|
+
declareWidget
|
|
14
|
+
};
|