@nectar-js/nectar 0.1.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/LICENSE +21 -0
- package/README.md +53 -0
- package/dist/cli-Ce-ZUj6M.js +2225 -0
- package/dist/cli-Ce-ZUj6M.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +8 -0
- package/dist/cli.js.map +1 -0
- package/dist/index-DGMxBsub.d.ts +757 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +105 -0
- package/dist/index.js.map +1 -0
- package/dist/plugins-CGvM19v9.js +663 -0
- package/dist/plugins-CGvM19v9.js.map +1 -0
- package/dist/registration-CaE0QBT6.js +545 -0
- package/dist/registration-CaE0QBT6.js.map +1 -0
- package/dist/runtime-CZJeZvSL.js +891 -0
- package/dist/runtime-CZJeZvSL.js.map +1 -0
- package/dist/start.d.ts +8 -0
- package/dist/start.js +15 -0
- package/dist/start.js.map +1 -0
- package/dist/testing.d.ts +111 -0
- package/dist/testing.js +320 -0
- package/dist/testing.js.map +1 -0
- package/package.json +45 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import { createRequire, registerHooks } from "node:module";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
//#region src/version.ts
|
|
7
|
+
/** From package.json, which sits one level up from both `src/` and `dist/`. */
|
|
8
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region src/components/customId.ts
|
|
11
|
+
/** Discord rejects custom IDs longer than this. */
|
|
12
|
+
const MAX_CUSTOM_ID_LENGTH = 100;
|
|
13
|
+
const PREFIX = "n:";
|
|
14
|
+
var CustomIdTooLongError = class extends Error {
|
|
15
|
+
customId;
|
|
16
|
+
routeId;
|
|
17
|
+
constructor(customId, routeId) {
|
|
18
|
+
super(`Custom ID for ${routeId} is ${customId.length} characters, Discord allows 100. Encode a shorter identifier instead of the full value.`);
|
|
19
|
+
this.customId = customId;
|
|
20
|
+
this.routeId = routeId;
|
|
21
|
+
this.name = "CustomIdTooLongError";
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Builds `n:<shortId>:<v1>:<v2>...`. Values are escaped so they may contain `:` and `\`.
|
|
26
|
+
* Throws when the result is longer than Discord allows; it never truncates.
|
|
27
|
+
*/
|
|
28
|
+
function encodeCustomId(shortId, values, routeId = shortId) {
|
|
29
|
+
let out = PREFIX + shortId;
|
|
30
|
+
for (const value of values) out += `:${escapeValue(value)}`;
|
|
31
|
+
if (out.length > 100) throw new CustomIdTooLongError(out, routeId);
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Splits a raw custom ID back into its short ID and positional values.
|
|
36
|
+
* IDs without the Nectar prefix are reported as `not-nectar` so hand-built components pass through.
|
|
37
|
+
*/
|
|
38
|
+
function decodeCustomId(raw) {
|
|
39
|
+
if (!raw.startsWith(PREFIX)) return {
|
|
40
|
+
ok: false,
|
|
41
|
+
reason: "not-nectar"
|
|
42
|
+
};
|
|
43
|
+
const shortId = raw.slice(2, 8);
|
|
44
|
+
if (!/^[0-9a-z]{6}$/.test(shortId)) return {
|
|
45
|
+
ok: false,
|
|
46
|
+
reason: "malformed"
|
|
47
|
+
};
|
|
48
|
+
const values = [];
|
|
49
|
+
let index = 8;
|
|
50
|
+
if (index === raw.length) return {
|
|
51
|
+
ok: true,
|
|
52
|
+
shortId,
|
|
53
|
+
values
|
|
54
|
+
};
|
|
55
|
+
if (raw[index] !== ":") return {
|
|
56
|
+
ok: false,
|
|
57
|
+
reason: "malformed"
|
|
58
|
+
};
|
|
59
|
+
index++;
|
|
60
|
+
let current = "";
|
|
61
|
+
while (index < raw.length) {
|
|
62
|
+
const char = raw[index];
|
|
63
|
+
if (char === "\\") {
|
|
64
|
+
const next = raw[index + 1];
|
|
65
|
+
if (next !== "\\" && next !== ":") return {
|
|
66
|
+
ok: false,
|
|
67
|
+
reason: "malformed"
|
|
68
|
+
};
|
|
69
|
+
current += next;
|
|
70
|
+
index += 2;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (char === ":") {
|
|
74
|
+
values.push(current);
|
|
75
|
+
current = "";
|
|
76
|
+
index++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
current += char;
|
|
80
|
+
index++;
|
|
81
|
+
}
|
|
82
|
+
values.push(current);
|
|
83
|
+
return {
|
|
84
|
+
ok: true,
|
|
85
|
+
shortId,
|
|
86
|
+
values
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function escapeValue(value) {
|
|
90
|
+
return value.replaceAll("\\", "\\\\").replaceAll(":", "\\:");
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/compiler/diagnostics.ts
|
|
94
|
+
var Diagnostics = class {
|
|
95
|
+
items = [];
|
|
96
|
+
error(code, message, location = {}) {
|
|
97
|
+
this.push("error", code, message, location);
|
|
98
|
+
}
|
|
99
|
+
warn(code, message, location = {}) {
|
|
100
|
+
this.push("warning", code, message, location);
|
|
101
|
+
}
|
|
102
|
+
get hasErrors() {
|
|
103
|
+
return this.items.some((d) => d.severity === "error");
|
|
104
|
+
}
|
|
105
|
+
push(severity, code, message, location) {
|
|
106
|
+
const item = {
|
|
107
|
+
code,
|
|
108
|
+
severity,
|
|
109
|
+
message
|
|
110
|
+
};
|
|
111
|
+
if (location.file !== void 0) item.file = location.file;
|
|
112
|
+
if (location.route !== void 0) item.route = location.route;
|
|
113
|
+
this.items.push(item);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/compiler/load.ts
|
|
118
|
+
/**
|
|
119
|
+
* Imports an application module by absolute path.
|
|
120
|
+
*
|
|
121
|
+
* Relies on Node's native TypeScript type stripping (unflagged since 22.18), so handler
|
|
122
|
+
* files must use erasable syntax only: no enums, namespaces, or parameter properties.
|
|
123
|
+
*
|
|
124
|
+
* With reloading enabled (see `enableModuleReloading`) the URL carries a version query, so a
|
|
125
|
+
* changed file evaluates again on the next import instead of coming back from the ESM cache.
|
|
126
|
+
*/
|
|
127
|
+
async function loadModule(file) {
|
|
128
|
+
const url = pathToFileURL(file).href;
|
|
129
|
+
return await (reloading === null ? import(url) : import(versioned(url)));
|
|
130
|
+
}
|
|
131
|
+
let reloading = null;
|
|
132
|
+
/**
|
|
133
|
+
* Turns on cache busting for project files. Used by `nectar dev` only.
|
|
134
|
+
*
|
|
135
|
+
* Every import of a file under `root` gets `?nectar=<content hash>-<generation>` appended, the
|
|
136
|
+
* direct ones here and the transitive ones through a resolve hook. A handler whose content
|
|
137
|
+
* changed therefore gets a new URL and a fresh evaluation; its unchanged imports keep their
|
|
138
|
+
* URL and are shared. Old instances stay in the ESM cache until the process exits.
|
|
139
|
+
*/
|
|
140
|
+
function enableModuleReloading(root) {
|
|
141
|
+
if (reloading !== null) return;
|
|
142
|
+
reloading = {
|
|
143
|
+
root: path.resolve(root),
|
|
144
|
+
generation: 0
|
|
145
|
+
};
|
|
146
|
+
registerHooks({ resolve(specifier, context, next) {
|
|
147
|
+
const result = next(specifier, context);
|
|
148
|
+
return {
|
|
149
|
+
...result,
|
|
150
|
+
url: versioned(result.url)
|
|
151
|
+
};
|
|
152
|
+
} });
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Makes every project module evaluate again on its next import. For changes to files the
|
|
156
|
+
* compiler does not track (helpers a handler imports), since nothing knows who imports them.
|
|
157
|
+
*/
|
|
158
|
+
function invalidateModuleGraph() {
|
|
159
|
+
if (reloading !== null) reloading.generation += 1;
|
|
160
|
+
}
|
|
161
|
+
function versioned(url) {
|
|
162
|
+
if (reloading === null || !url.startsWith("file:") || url.includes("?") || url.includes("#")) return url;
|
|
163
|
+
const file = fileURLToPath(url);
|
|
164
|
+
if (!!path.relative(reloading.root, file).startsWith("..") || file.split(path.sep).includes("node_modules")) return url;
|
|
165
|
+
let hash;
|
|
166
|
+
try {
|
|
167
|
+
hash = createHash("sha1").update(readFileSync(file)).digest("base64url").slice(0, 10);
|
|
168
|
+
} catch {
|
|
169
|
+
return url;
|
|
170
|
+
}
|
|
171
|
+
return `${url}?nectar=${hash}-${reloading.generation}`;
|
|
172
|
+
}
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region src/compiler/segments.ts
|
|
175
|
+
const STATIC_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
176
|
+
const PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
177
|
+
/**
|
|
178
|
+
* Parses one directory name into a route segment.
|
|
179
|
+
*
|
|
180
|
+
* `name` static
|
|
181
|
+
* `[name]` dynamic
|
|
182
|
+
* `[...name]` catch-all
|
|
183
|
+
* `(name)` group, organizational only
|
|
184
|
+
*/
|
|
185
|
+
function parseSegment(dirName) {
|
|
186
|
+
if (dirName.startsWith("[") || dirName.endsWith("]")) {
|
|
187
|
+
if (!dirName.startsWith("[") || !dirName.endsWith("]")) return fail(`"${dirName}" has an unmatched bracket. Dynamic segments look like [name].`);
|
|
188
|
+
const inner = dirName.slice(1, -1);
|
|
189
|
+
const isCatchAll = inner.startsWith("...");
|
|
190
|
+
const name = isCatchAll ? inner.slice(3) : inner;
|
|
191
|
+
if (!PARAM_NAME.test(name)) return fail(`"${dirName}" is not a valid parameter name. Use letters, digits, and underscores, and do not start with a digit.`);
|
|
192
|
+
return ok({
|
|
193
|
+
type: isCatchAll ? "catchAll" : "dynamic",
|
|
194
|
+
name
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (dirName.startsWith("(") || dirName.endsWith(")")) {
|
|
198
|
+
if (!dirName.startsWith("(") || !dirName.endsWith(")")) return fail(`"${dirName}" has an unmatched parenthesis. Route groups look like (name).`);
|
|
199
|
+
const name = dirName.slice(1, -1);
|
|
200
|
+
if (!STATIC_NAME.test(name)) return fail(`"${dirName}" is not a valid group name. Use letters, digits, hyphens, and underscores.`);
|
|
201
|
+
return ok({
|
|
202
|
+
type: "group",
|
|
203
|
+
name
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (!STATIC_NAME.test(dirName)) return fail(`"${dirName}" is not a valid segment name. Use letters, digits, hyphens, and underscores, and start with a letter or digit.`);
|
|
207
|
+
return ok({
|
|
208
|
+
type: "static",
|
|
209
|
+
name: dirName
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/** Renders a segment back into its directory form. Groups render as their directory name. */
|
|
213
|
+
function formatSegment(segment) {
|
|
214
|
+
switch (segment.type) {
|
|
215
|
+
case "static": return segment.name;
|
|
216
|
+
case "dynamic": return `[${segment.name}]`;
|
|
217
|
+
case "catchAll": return `[...${segment.name}]`;
|
|
218
|
+
case "group": return `(${segment.name})`;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function ok(segment) {
|
|
222
|
+
return {
|
|
223
|
+
ok: true,
|
|
224
|
+
segment
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function fail(reason) {
|
|
228
|
+
return {
|
|
229
|
+
ok: false,
|
|
230
|
+
reason
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region src/components/params.ts
|
|
235
|
+
/**
|
|
236
|
+
* Reads the validators `defineComponent` attached to a handler and checks them against the
|
|
237
|
+
* route's parameters. Throws with a developer-facing message when the shape is wrong; the
|
|
238
|
+
* compiler reports it as a diagnostic and the runtime as a load error.
|
|
239
|
+
*/
|
|
240
|
+
function paramValidatorsOf(handler, route) {
|
|
241
|
+
const declared = handler.params;
|
|
242
|
+
if (declared === void 0) return {};
|
|
243
|
+
if (typeof declared !== "object" || declared === null || Array.isArray(declared)) throw new Error(`\`params\` must be an object of validators, got ${typeof declared}.`);
|
|
244
|
+
const validators = {};
|
|
245
|
+
for (const [name, validator] of Object.entries(declared)) {
|
|
246
|
+
if (!route.params.includes(name)) throw new Error(`\`params\` validates "${name}", which is not a parameter of this route. ${route.params.length === 0 ? "It has none." : `It has: ${route.params.join(", ")}.`}`);
|
|
247
|
+
if (!isValidator(validator)) throw new Error(`\`params.${name}\` must be a function or a Standard Schema, got ${typeof validator}.`);
|
|
248
|
+
validators[name] = validator;
|
|
249
|
+
}
|
|
250
|
+
return validators;
|
|
251
|
+
}
|
|
252
|
+
function isValidator(value) {
|
|
253
|
+
if (typeof value === "function") return true;
|
|
254
|
+
if (typeof value !== "object" || value === null) return false;
|
|
255
|
+
const standard = value["~standard"];
|
|
256
|
+
return typeof standard === "object" && standard !== null && typeof standard.validate === "function";
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Runs every validator against the decoded parameters. Resolves to the first parameter that
|
|
260
|
+
* failed, or `null` when all passed. A validator that throws counts as a failure; the
|
|
261
|
+
* caller decides what to log, so the value never leaves this function.
|
|
262
|
+
*/
|
|
263
|
+
async function findInvalidParam(validators, params) {
|
|
264
|
+
for (const [name, validator] of Object.entries(validators)) {
|
|
265
|
+
const value = params[name];
|
|
266
|
+
if (value === void 0) return name;
|
|
267
|
+
try {
|
|
268
|
+
if (typeof validator === "function") {
|
|
269
|
+
if (await validator(value) === false) return name;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const result = await validator["~standard"].validate(value);
|
|
273
|
+
if (result.issues !== void 0 && result.issues.length > 0) return name;
|
|
274
|
+
} catch {
|
|
275
|
+
return name;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
//#endregion
|
|
281
|
+
//#region src/components/compile.ts
|
|
282
|
+
const SELECT_KINDS = /* @__PURE__ */ new Set([
|
|
283
|
+
"string",
|
|
284
|
+
"user",
|
|
285
|
+
"role",
|
|
286
|
+
"channel",
|
|
287
|
+
"mentionable"
|
|
288
|
+
]);
|
|
289
|
+
/** Validates the component routes of a route table and resolves their select kinds. */
|
|
290
|
+
async function compileComponents(table) {
|
|
291
|
+
const diagnostics = new Diagnostics();
|
|
292
|
+
const candidates = table.routes.filter((r) => r.category === "component");
|
|
293
|
+
const results = await Promise.all(candidates.map(async (route) => {
|
|
294
|
+
const own = new Diagnostics();
|
|
295
|
+
return {
|
|
296
|
+
route: await compileRoute(route, own),
|
|
297
|
+
diagnostics: own
|
|
298
|
+
};
|
|
299
|
+
}));
|
|
300
|
+
const routes = [];
|
|
301
|
+
for (const result of results) {
|
|
302
|
+
diagnostics.items.push(...result.diagnostics.items);
|
|
303
|
+
if (result.route !== null) routes.push(result.route);
|
|
304
|
+
}
|
|
305
|
+
detectShortIdCollisions(routes, diagnostics);
|
|
306
|
+
detectDuplicatePatterns(routes, diagnostics);
|
|
307
|
+
return {
|
|
308
|
+
routes,
|
|
309
|
+
diagnostics
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Encodes a custom ID for a compiled route. Throws when a parameter is missing, a value is not
|
|
314
|
+
* a string, or the result exceeds Discord's limit.
|
|
315
|
+
*/
|
|
316
|
+
function customIdFor(route, params = {}) {
|
|
317
|
+
const values = [];
|
|
318
|
+
for (const name of route.params) {
|
|
319
|
+
const value = params[name];
|
|
320
|
+
if (name === route.catchAll) {
|
|
321
|
+
if (value === void 0) continue;
|
|
322
|
+
if (typeof value === "string") {
|
|
323
|
+
values.push(value);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
values.push(...value);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (typeof value !== "string") throw new TypeError(`Route ${route.id} needs a string for parameter "${name}", got ${describe$1(value)}.`);
|
|
330
|
+
values.push(value);
|
|
331
|
+
}
|
|
332
|
+
for (const name of Object.keys(params)) if (!route.params.includes(name)) throw new TypeError(`Route ${route.id} has no parameter "${name}". ${route.params.length === 0 ? "It takes none." : `It takes: ${route.params.join(", ")}.`}`);
|
|
333
|
+
return encodeCustomId(route.shortId, values, route.id);
|
|
334
|
+
}
|
|
335
|
+
async function compileRoute(route, diagnostics) {
|
|
336
|
+
const kind = route.kind;
|
|
337
|
+
const last = route.segments.at(-1);
|
|
338
|
+
const catchAll = last?.type === "catchAll" ? last.name : null;
|
|
339
|
+
const overhead = 8 + route.params.length;
|
|
340
|
+
if (catchAll !== null) diagnostics.warn("catch-all-route", `${formatSegment(last)} accepts any number of values. Every value counts against Discord's 100 character custom ID limit, and generation throws when it is exceeded.`, {
|
|
341
|
+
file: route.file,
|
|
342
|
+
route: route.id
|
|
343
|
+
});
|
|
344
|
+
let module;
|
|
345
|
+
try {
|
|
346
|
+
module = await loadModule(route.file);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
diagnostics.error("module-load-failed", `Could not import this file: ${error instanceof Error ? error.message : String(error)}`, {
|
|
349
|
+
file: route.file,
|
|
350
|
+
route: route.id
|
|
351
|
+
});
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
if (!checkDeclaredRoute(module, route, diagnostics)) return null;
|
|
355
|
+
try {
|
|
356
|
+
paramValidatorsOf(module.default, route);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
diagnostics.error("invalid-param-validator", `${error instanceof Error ? error.message : String(error)} Pass validators as defineComponent's third argument: { params: { name: (value) => ... } }.`, {
|
|
359
|
+
file: route.file,
|
|
360
|
+
route: route.id
|
|
361
|
+
});
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
let selectKind = null;
|
|
365
|
+
if (kind === "select") {
|
|
366
|
+
selectKind = validateSelectKind(module, route, diagnostics);
|
|
367
|
+
if (selectKind === null) return null;
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
...route,
|
|
371
|
+
category: "component",
|
|
372
|
+
kind,
|
|
373
|
+
selectKind,
|
|
374
|
+
catchAll,
|
|
375
|
+
overhead
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
/** A handler made with `defineComponent(path, ...)` must name the route its file sits in. */
|
|
379
|
+
function checkDeclaredRoute(module, route, diagnostics, expected = route.path) {
|
|
380
|
+
const handler = module.default;
|
|
381
|
+
if (typeof handler !== "function") return true;
|
|
382
|
+
const declared = handler.route;
|
|
383
|
+
if (declared === void 0 || declared === expected) return true;
|
|
384
|
+
diagnostics.error("route-mismatch", `This file is the route "${expected}" but its handler declares "${String(declared)}". Update the string or move the file.`, {
|
|
385
|
+
file: route.file,
|
|
386
|
+
route: route.id
|
|
387
|
+
});
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
function validateSelectKind(module, route, diagnostics) {
|
|
391
|
+
const kind = module.kind;
|
|
392
|
+
if (kind === void 0) {
|
|
393
|
+
diagnostics.error("missing-select-kind", "select.ts must export `kind`: \"string\", \"user\", \"role\", \"channel\", or \"mentionable\".", {
|
|
394
|
+
file: route.file,
|
|
395
|
+
route: route.id
|
|
396
|
+
});
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
if (typeof kind !== "string" || !SELECT_KINDS.has(kind)) {
|
|
400
|
+
diagnostics.error("invalid-select-kind", `\`kind\` is ${describe$1(kind)}. Expected "string", "user", "role", "channel", or "mentionable".`, {
|
|
401
|
+
file: route.file,
|
|
402
|
+
route: route.id
|
|
403
|
+
});
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
return kind;
|
|
407
|
+
}
|
|
408
|
+
function detectShortIdCollisions(routes, diagnostics) {
|
|
409
|
+
const seen = /* @__PURE__ */ new Map();
|
|
410
|
+
for (const route of routes) {
|
|
411
|
+
const existing = seen.get(route.shortId);
|
|
412
|
+
if (existing === void 0 || existing.id === route.id) {
|
|
413
|
+
seen.set(route.shortId, route);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
diagnostics.error("short-id-collision", `${route.id} and ${existing.id} hash to the same short ID "${route.shortId}", so their custom IDs would be indistinguishable. Rename one of the directories.`, {
|
|
417
|
+
file: route.file,
|
|
418
|
+
route: route.id
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Two routes of the same kind whose paths differ only in parameter names, like
|
|
424
|
+
* `tickets/[id]/close` and `tickets/[ticketId]/close`, would both claim the same custom IDs.
|
|
425
|
+
*/
|
|
426
|
+
function detectDuplicatePatterns(routes, diagnostics) {
|
|
427
|
+
const seen = /* @__PURE__ */ new Map();
|
|
428
|
+
for (const route of routes) {
|
|
429
|
+
const shape = route.segments.filter((s) => s.type !== "group").map((s) => s.type === "static" ? s.name : s.type === "dynamic" ? "[]" : "[...]").join("/");
|
|
430
|
+
const key = `${route.kind}#${shape}`;
|
|
431
|
+
const existing = seen.get(key);
|
|
432
|
+
if (existing === void 0) {
|
|
433
|
+
seen.set(key, route);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (existing.id === route.id) continue;
|
|
437
|
+
diagnostics.error("duplicate-component-pattern", `${route.id} has the same shape as ${existing.id} (${relative(existing.file)}). Parameter names do not make routes distinct.`, {
|
|
438
|
+
file: route.file,
|
|
439
|
+
route: route.id
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function describe$1(value) {
|
|
444
|
+
return typeof value === "string" ? JSON.stringify(value) : typeof value;
|
|
445
|
+
}
|
|
446
|
+
function relative(file) {
|
|
447
|
+
return path.relative(process.cwd(), file).split(path.sep).join("/");
|
|
448
|
+
}
|
|
449
|
+
//#endregion
|
|
450
|
+
//#region src/components/registry.ts
|
|
451
|
+
/**
|
|
452
|
+
* Component routes the running app knows about, keyed by path. The runtime fills this from
|
|
453
|
+
* the manifest before any handler runs, so `customId()` never needs the manifest itself.
|
|
454
|
+
*/
|
|
455
|
+
const routes = /* @__PURE__ */ new Map();
|
|
456
|
+
function registerComponentRoutes(list) {
|
|
457
|
+
routes.clear();
|
|
458
|
+
for (const route of list) routes.set(route.path, route);
|
|
459
|
+
}
|
|
460
|
+
function encodeComponentRoute(path, params) {
|
|
461
|
+
const route = routes.get(path);
|
|
462
|
+
if (route === void 0) throw new Error(routes.size === 0 ? `customId("${path}") was called before the runtime registered any routes. Call it from a handler, or from code that runs after start().` : `No component route "${path}". Check the directory name under components/.`);
|
|
463
|
+
return customIdFor(route, params);
|
|
464
|
+
}
|
|
465
|
+
//#endregion
|
|
466
|
+
//#region src/manifest/emit.ts
|
|
467
|
+
const MANIFEST_FILE = "manifest.json";
|
|
468
|
+
/** Serializes a route graph. `outDir` is where the manifest will live; paths are made relative to it. */
|
|
469
|
+
function toManifest(graph, outDir) {
|
|
470
|
+
const rel = (file) => posix(path.relative(graph.appDir, file));
|
|
471
|
+
const base = (route) => {
|
|
472
|
+
const chains = graph.chains.get(route.file) ?? {
|
|
473
|
+
middleware: [],
|
|
474
|
+
errors: []
|
|
475
|
+
};
|
|
476
|
+
return {
|
|
477
|
+
id: route.id,
|
|
478
|
+
category: route.category,
|
|
479
|
+
path: route.path,
|
|
480
|
+
file: rel(route.file),
|
|
481
|
+
middleware: chains.middleware.map(rel),
|
|
482
|
+
errors: chains.errors.map(rel),
|
|
483
|
+
plugins: graph.plugins.get(route.file) ?? []
|
|
484
|
+
};
|
|
485
|
+
};
|
|
486
|
+
const routes = [];
|
|
487
|
+
for (const command of graph.commands) for (const route of Object.values(command.handlers)) routes.push({
|
|
488
|
+
...base(route),
|
|
489
|
+
kind: "command"
|
|
490
|
+
});
|
|
491
|
+
for (const entry of graph.autocomplete) routes.push({
|
|
492
|
+
...base(entry.route),
|
|
493
|
+
kind: "autocomplete",
|
|
494
|
+
options: entry.options
|
|
495
|
+
});
|
|
496
|
+
for (const route of graph.components) routes.push({
|
|
497
|
+
...base(route),
|
|
498
|
+
kind: route.kind,
|
|
499
|
+
shortId: route.shortId,
|
|
500
|
+
params: route.params,
|
|
501
|
+
catchAll: route.catchAll,
|
|
502
|
+
selectKind: route.selectKind,
|
|
503
|
+
overhead: route.overhead
|
|
504
|
+
});
|
|
505
|
+
for (const event of graph.events) for (const handler of event.handlers) routes.push({
|
|
506
|
+
...base(handler.route),
|
|
507
|
+
kind: "event",
|
|
508
|
+
event: event.name,
|
|
509
|
+
once: handler.once,
|
|
510
|
+
order: handler.order
|
|
511
|
+
});
|
|
512
|
+
routes.sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id));
|
|
513
|
+
return {
|
|
514
|
+
version: 1,
|
|
515
|
+
nectar: version,
|
|
516
|
+
appDir: posix(path.relative(path.resolve(outDir), graph.appDir)),
|
|
517
|
+
routes,
|
|
518
|
+
commands: graph.commands.map((c) => ({
|
|
519
|
+
name: c.name,
|
|
520
|
+
type: c.type,
|
|
521
|
+
payload: c.payload,
|
|
522
|
+
handlers: Object.fromEntries(Object.entries(c.handlers).map(([k, r]) => [k, r.id]))
|
|
523
|
+
})),
|
|
524
|
+
events: graph.events.map((e) => ({
|
|
525
|
+
name: e.name,
|
|
526
|
+
mode: e.mode,
|
|
527
|
+
handlers: e.handlers.map((h) => h.route.id)
|
|
528
|
+
}))
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
/** Writes `manifest.json` into `outDir` with sorted keys, so identical graphs give identical bytes. */
|
|
532
|
+
function writeManifest(manifest, outDir) {
|
|
533
|
+
mkdirSync(outDir, { recursive: true });
|
|
534
|
+
const file = path.join(outDir, MANIFEST_FILE);
|
|
535
|
+
writeFileSync(file, `${stableStringify(manifest)}\n`);
|
|
536
|
+
return file;
|
|
537
|
+
}
|
|
538
|
+
function stableStringify(value) {
|
|
539
|
+
return JSON.stringify(value, (_key, v) => isPlainObject(v) ? sortKeys(v) : v, 2);
|
|
540
|
+
}
|
|
541
|
+
function sortKeys(object) {
|
|
542
|
+
const out = {};
|
|
543
|
+
for (const key of Object.keys(object).sort()) out[key] = object[key];
|
|
544
|
+
return out;
|
|
545
|
+
}
|
|
546
|
+
function isPlainObject(value) {
|
|
547
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
548
|
+
}
|
|
549
|
+
function posix(file) {
|
|
550
|
+
return file.split(path.sep).join("/");
|
|
551
|
+
}
|
|
552
|
+
//#endregion
|
|
553
|
+
//#region src/plugins/transform.ts
|
|
554
|
+
/** A frozen copy of the graph in manifest shape, with absolute file paths. */
|
|
555
|
+
function pluginGraph(graph) {
|
|
556
|
+
const manifest = toManifest(graph, graph.appDir);
|
|
557
|
+
const absolute = (file) => path.join(graph.appDir, ...file.split("/"));
|
|
558
|
+
return deepFreeze(structuredClone({
|
|
559
|
+
appDir: graph.appDir,
|
|
560
|
+
routes: manifest.routes.map((route) => ({
|
|
561
|
+
...route,
|
|
562
|
+
file: absolute(route.file),
|
|
563
|
+
middleware: route.middleware.map(absolute),
|
|
564
|
+
errors: route.errors.map(absolute)
|
|
565
|
+
})),
|
|
566
|
+
commands: manifest.commands,
|
|
567
|
+
events: manifest.events
|
|
568
|
+
}));
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Runs every plugin's `transform` in config order and applies the returned changes to the
|
|
572
|
+
* graph. Problems become diagnostics; a plugin never mutates the graph directly.
|
|
573
|
+
*/
|
|
574
|
+
async function applyPlugins(graph, plugins) {
|
|
575
|
+
for (const plugin of plugins) {
|
|
576
|
+
if (plugin.transform === void 0) continue;
|
|
577
|
+
let changes;
|
|
578
|
+
try {
|
|
579
|
+
changes = await plugin.transform(pluginGraph(graph)) ?? [];
|
|
580
|
+
} catch (error) {
|
|
581
|
+
graph.diagnostics.error("plugin-failed", `Plugin "${plugin.name}" threw while transforming routes: ${describe(error)}`);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (!Array.isArray(changes)) {
|
|
585
|
+
graph.diagnostics.error("plugin-invalid-change", `Plugin "${plugin.name}" returned ${typeof changes} from transform. Return an array of changes, or nothing.`);
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
for (const change of changes) apply(graph, plugin.name, change);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
function apply(graph, plugin, change) {
|
|
592
|
+
const type = isRecord(change) ? change.type : void 0;
|
|
593
|
+
if (type !== "middleware" && type !== "diagnostic") {
|
|
594
|
+
graph.diagnostics.error("plugin-invalid-change", `Plugin "${plugin}" returned a change of type ${JSON.stringify(type)}. Known types: middleware, diagnostic.`);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
if (change.type === "diagnostic") {
|
|
598
|
+
const { severity, code, message, file, route } = change;
|
|
599
|
+
const where = {
|
|
600
|
+
...file === void 0 ? {} : { file },
|
|
601
|
+
...route === void 0 ? {} : { route }
|
|
602
|
+
};
|
|
603
|
+
if (severity === "error") graph.diagnostics.error(code, message, where);
|
|
604
|
+
else graph.diagnostics.warn(code, message, where);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const routes = graph.routes.filter((r) => r.id === change.route && (change.kind === void 0 || r.kind === change.kind));
|
|
608
|
+
const target = change.kind === void 0 ? change.route : `${change.route} (${change.kind})`;
|
|
609
|
+
if (routes.length === 0) {
|
|
610
|
+
graph.diagnostics.error("plugin-unknown-route", `Plugin "${plugin}" adds middleware to route "${target}", which does not exist.`);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (routes.some((r) => r.category === "event")) {
|
|
614
|
+
graph.diagnostics.error("plugin-invalid-change", `Plugin "${plugin}" adds middleware to route "${target}", but event handlers don't run middleware.`, { route: change.route });
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (typeof change.file !== "string" || !path.isAbsolute(change.file) || !existsSync(change.file)) {
|
|
618
|
+
graph.diagnostics.error("plugin-missing-file", `Plugin "${plugin}" adds middleware from ${JSON.stringify(change.file)}, which is not an absolute path to an existing file.`, { route: change.route });
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const file = path.normalize(change.file);
|
|
622
|
+
for (const route of routes) {
|
|
623
|
+
const chains = graph.chains.get(route.file);
|
|
624
|
+
if (chains === void 0 || chains.middleware.includes(file)) continue;
|
|
625
|
+
if (change.position === "inner") chains.middleware.push(file);
|
|
626
|
+
else chains.middleware.unshift(file);
|
|
627
|
+
const touched = graph.plugins.get(route.file) ?? [];
|
|
628
|
+
if (!touched.includes(plugin)) graph.plugins.set(route.file, [...touched, plugin]);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
function deepFreeze(value) {
|
|
632
|
+
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
|
|
633
|
+
Object.freeze(value);
|
|
634
|
+
for (const inner of Object.values(value)) deepFreeze(inner);
|
|
635
|
+
}
|
|
636
|
+
return value;
|
|
637
|
+
}
|
|
638
|
+
function isRecord(value) {
|
|
639
|
+
return typeof value === "object" && value !== null;
|
|
640
|
+
}
|
|
641
|
+
function describe(error) {
|
|
642
|
+
return error instanceof Error ? error.message : String(error);
|
|
643
|
+
}
|
|
644
|
+
//#endregion
|
|
645
|
+
//#region src/plugins/index.ts
|
|
646
|
+
function definePlugin(plugin) {
|
|
647
|
+
return plugin;
|
|
648
|
+
}
|
|
649
|
+
/** A plugin misbehaved: threw from a hook, or provided something that clashes. */
|
|
650
|
+
var PluginError = class extends Error {
|
|
651
|
+
plugin;
|
|
652
|
+
detail;
|
|
653
|
+
constructor(plugin, detail) {
|
|
654
|
+
super(`Plugin "${plugin}": ${detail}`);
|
|
655
|
+
this.plugin = plugin;
|
|
656
|
+
this.detail = detail;
|
|
657
|
+
this.name = "PluginError";
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
//#endregion
|
|
661
|
+
export { MAX_CUSTOM_ID_LENGTH as C, CustomIdTooLongError as S, version as T, parseSegment as _, MANIFEST_FILE as a, loadModule as b, writeManifest as c, checkDeclaredRoute as d, compileComponents as f, formatSegment as g, paramValidatorsOf as h, pluginGraph as i, encodeComponentRoute as l, findInvalidParam as m, definePlugin as n, stableStringify as o, customIdFor as p, applyPlugins as r, toManifest as s, PluginError as t, registerComponentRoutes as u, enableModuleReloading as v, decodeCustomId as w, Diagnostics as x, invalidateModuleGraph as y };
|
|
662
|
+
|
|
663
|
+
//# sourceMappingURL=plugins-CGvM19v9.js.map
|