@devdogsuga/backstage 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/README.md +147 -0
- package/bin/backstage.mjs +28 -0
- package/dist/args-Cjr_Iqts.js +66 -0
- package/dist/build-info.json +3 -0
- package/dist/catalog-BOBE4ee7.js +1570 -0
- package/dist/cli-YLCe3Zrx.js +6300 -0
- package/dist/commands-Bk7IbhPD.js +220 -0
- package/dist/commands-D345F-Od.js +800 -0
- package/dist/commands-Dv5TXjvb.js +545 -0
- package/dist/dispatch-D048O65I.js +13 -0
- package/dist/launch.js +329 -0
- package/dist/options-BTjOf5KP.js +183 -0
- package/dist/peer-redirect-hooks.js +15 -0
- package/dist/telemetry-Bjoz29Hl.js +609 -0
- package/dist/telemetry.js +2 -0
- package/dist/ui-CdKo8mLw.js +66 -0
- package/env.ts +163 -0
- package/package.json +82 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
import { p as isNonInteractive } from "./telemetry-Bjoz29Hl.js";
|
|
2
|
+
import { a as explainError, o as unwrap, r as errorMessage, t as UsageError } from "./ui-CdKo8mLw.js";
|
|
3
|
+
import { n as positionals } from "./args-Cjr_Iqts.js";
|
|
4
|
+
import { t as DONE } from "./dispatch-D048O65I.js";
|
|
5
|
+
import { dirname, relative, resolve } from "node:path";
|
|
6
|
+
import { autocompleteMultiselect, log, multiselect, note } from "@clack/prompts";
|
|
7
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { APPS, AppIcon, Banner, EmailSignature, EventCard, FORMATS, OPAQUE_FORMATS, PageCard, THEME } from "@devdogsuga/brand";
|
|
10
|
+
import { EVENT_SEGMENT_VISUALS, meetingCardDetail, meetingLocation } from "@devdogsuga/brand/event";
|
|
11
|
+
import { getClubConfig } from "@devdogsuga/events";
|
|
12
|
+
import { render as render$1 } from "@devdogsuga/brand/render";
|
|
13
|
+
//#region src/graphics/events.ts
|
|
14
|
+
/**
|
|
15
|
+
* Builds the reader from `@devdogsuga/events`'s committed data file.
|
|
16
|
+
*
|
|
17
|
+
* `Meeting.id` is the card's `slug` and `Meeting.title` is its authored name
|
|
18
|
+
* (`nameOverride` on the platform's meetings table).
|
|
19
|
+
*/
|
|
20
|
+
function configEvents() {
|
|
21
|
+
return { async meetings() {
|
|
22
|
+
return [...getClubConfig().meetings].sort((a, b) => Date.parse(b.startsAt) - Date.parse(a.startsAt)).map((row) => {
|
|
23
|
+
const meeting = {
|
|
24
|
+
slug: row.id,
|
|
25
|
+
startsAt: new Date(row.startsAt),
|
|
26
|
+
endsAt: new Date(row.endsAt),
|
|
27
|
+
building: row.building,
|
|
28
|
+
location: row.location,
|
|
29
|
+
kind: row.kind,
|
|
30
|
+
cancelledAt: row.cancelledAt ? new Date(row.cancelledAt) : null,
|
|
31
|
+
cancellationReason: row.cancellationReason
|
|
32
|
+
};
|
|
33
|
+
const items = row.agenda.map((workshop) => ({
|
|
34
|
+
label: workshopLabel(workshop),
|
|
35
|
+
segment: "workshop"
|
|
36
|
+
}));
|
|
37
|
+
const detail = meetingCardDetail({
|
|
38
|
+
meeting,
|
|
39
|
+
title: meetingTitle(row.title, row.kind, meeting.startsAt, items.map((item) => item.label)),
|
|
40
|
+
agenda: items.map((item) => `${EVENT_SEGMENT_VISUALS[item.segment].label}: ${item.label}`),
|
|
41
|
+
location: meetingLocation(row.building, row.location)
|
|
42
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
slug: row.id,
|
|
45
|
+
hint: describeMeeting(meeting),
|
|
46
|
+
detail,
|
|
47
|
+
items: items.map((item) => {
|
|
48
|
+
const visual = EVENT_SEGMENT_VISUALS[item.segment];
|
|
49
|
+
return {
|
|
50
|
+
stem: slugPart(item.label),
|
|
51
|
+
detail: {
|
|
52
|
+
...detail,
|
|
53
|
+
title: item.label,
|
|
54
|
+
agenda: void 0,
|
|
55
|
+
badge: {
|
|
56
|
+
label: visual.label,
|
|
57
|
+
accent: visual.accent
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
})
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
} };
|
|
65
|
+
}
|
|
66
|
+
/** A readable path component for a workshop card. */
|
|
67
|
+
function slugPart(label) {
|
|
68
|
+
return label.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "agenda-item";
|
|
69
|
+
}
|
|
70
|
+
/** `Workshop.title` is non-nullable in the config schema (`workshopSchema`). */
|
|
71
|
+
function workshopLabel(row) {
|
|
72
|
+
return row.title;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The meeting's name, in descending order of how much somebody meant it.
|
|
76
|
+
*
|
|
77
|
+
* Mirrors `apps/platform/src/lib/meetingTitle.ts`: an authored name, then the
|
|
78
|
+
* kind, then the workshops it teaches, then the date. Two named workshops read
|
|
79
|
+
* as a heading; three do not, and the date carries it while the agenda below
|
|
80
|
+
* shows the detail.
|
|
81
|
+
*
|
|
82
|
+
* Duplicated rather than imported because it lives in the Next app, which this
|
|
83
|
+
* package cannot depend on. `graphics.test.ts` pins the chain so a change to one
|
|
84
|
+
* shows up as a failure rather than as a differently-titled poster.
|
|
85
|
+
*/
|
|
86
|
+
function meetingTitle(nameOverride, kind, startsAt, agenda) {
|
|
87
|
+
if (nameOverride !== null) return nameOverride;
|
|
88
|
+
if (kind !== null) return kind;
|
|
89
|
+
if (agenda.length === 1) return `Workshop: ${agenda[0]}`;
|
|
90
|
+
if (agenda.length === 2) return `Workshop: ${agenda[0]} & ${agenda[1]}`;
|
|
91
|
+
return startsAt.toLocaleDateString("en-US", {
|
|
92
|
+
timeZone: "America/New_York",
|
|
93
|
+
dateStyle: "long"
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** One line for the picker: when it is, and whether it is still on. */
|
|
97
|
+
function describeMeeting(meeting) {
|
|
98
|
+
const when = meeting.startsAt.toLocaleDateString("en-US", {
|
|
99
|
+
timeZone: "America/New_York",
|
|
100
|
+
dateStyle: "medium"
|
|
101
|
+
});
|
|
102
|
+
return meeting.cancelledAt === null ? when : `${when}, cancelled`;
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/graphics/select.ts
|
|
106
|
+
/**
|
|
107
|
+
* Resolves graphic patterns.
|
|
108
|
+
*
|
|
109
|
+
* Every graphic is `group/name`, and three shapes of pattern read against
|
|
110
|
+
* that: a lone star (or a star over a star) is everything, `group/` plus a star
|
|
111
|
+
* is a whole group, and anything else is an exact name. Deliberately not a
|
|
112
|
+
* general glob — `event/2026-` plus a star looks like it should work and would
|
|
113
|
+
* be one more thing to specify, and the picker covers the case where somebody
|
|
114
|
+
* does not know the exact name.
|
|
115
|
+
*
|
|
116
|
+
* A bare group is accepted as shorthand for `group/*`, because `graphics app`
|
|
117
|
+
* is what people type and refusing it teaches nothing.
|
|
118
|
+
*/
|
|
119
|
+
function matchGraphics(patterns, graphics) {
|
|
120
|
+
const matched = /* @__PURE__ */ new Map();
|
|
121
|
+
const unmatched = [];
|
|
122
|
+
const groups = new Set(graphics.map((graphic) => graphic.group));
|
|
123
|
+
for (const pattern of patterns) {
|
|
124
|
+
const hits = graphics.filter((graphic) => {
|
|
125
|
+
if (pattern === "*" || pattern === "*/*") return true;
|
|
126
|
+
if (pattern.endsWith("/*")) return graphic.group === pattern.slice(0, -2);
|
|
127
|
+
if (groups.has(pattern)) return graphic.group === pattern;
|
|
128
|
+
return graphic.name === pattern;
|
|
129
|
+
});
|
|
130
|
+
if (hits.length === 0) {
|
|
131
|
+
unmatched.push(pattern);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
for (const hit of hits) matched.set(hit.name, hit);
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
matched: [...matched.values()],
|
|
138
|
+
unmatched
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Pairs each graphic with each requested format it actually supports.
|
|
143
|
+
*
|
|
144
|
+
* The matrix is sparse, and silently dropping a pair is the wrong answer in one
|
|
145
|
+
* direction only: asking for `*` at every format should quietly skip the
|
|
146
|
+
* combinations that do not exist, while asking for one graphic at one format it
|
|
147
|
+
* cannot do should say so. The caller decides which case it is; this reports
|
|
148
|
+
* both halves.
|
|
149
|
+
*/
|
|
150
|
+
function pair(graphics, formats) {
|
|
151
|
+
const selections = [];
|
|
152
|
+
const unsupported = [];
|
|
153
|
+
for (const graphic of graphics) for (const name of formats) {
|
|
154
|
+
const format = FORMATS[name];
|
|
155
|
+
if (!format) continue;
|
|
156
|
+
if (graphic.formats.includes(name)) selections.push({
|
|
157
|
+
graphic,
|
|
158
|
+
format
|
|
159
|
+
});
|
|
160
|
+
else unsupported.push({
|
|
161
|
+
graphic: graphic.name,
|
|
162
|
+
format: name
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
selections,
|
|
167
|
+
unsupported
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/** Every format any of these graphics can be drawn at, in registry order. */
|
|
171
|
+
function formatsFor(graphics) {
|
|
172
|
+
const names = new Set(graphics.flatMap((graphic) => graphic.formats));
|
|
173
|
+
return Object.values(FORMATS).filter((format) => names.has(format.name));
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Splits `--flag=value` into two tokens.
|
|
177
|
+
*
|
|
178
|
+
* The rest of this CLI reads flags as `--flag value`, and `positionals()`
|
|
179
|
+
* depends on that to tell a flag's value from a subcommand. `--format=a,b`
|
|
180
|
+
* arrives as one token, so it would be read as a positional — that is, as a
|
|
181
|
+
* graphic name — and `graphics --format=og` would try to render a graphic called
|
|
182
|
+
* `--format=og` and report nothing matched. Normalising here lets both spellings
|
|
183
|
+
* work without teaching the shared parser about either.
|
|
184
|
+
*/
|
|
185
|
+
function normalizeArgv(argv) {
|
|
186
|
+
return argv.flatMap((token) => {
|
|
187
|
+
if (!token.startsWith("--")) return [token];
|
|
188
|
+
const eq = token.indexOf("=");
|
|
189
|
+
return eq === -1 ? [token] : [token.slice(0, eq), token.slice(eq + 1)];
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
/** A comma-separated flag value, trimmed and emptied of blanks. */
|
|
193
|
+
function commaList(value) {
|
|
194
|
+
return (value ?? "").split(",").map((part) => part.trim()).filter(Boolean);
|
|
195
|
+
}
|
|
196
|
+
//#endregion
|
|
197
|
+
//#region src/graphics/prompts.ts
|
|
198
|
+
/**
|
|
199
|
+
* What the command asks for when the command line did not say.
|
|
200
|
+
*
|
|
201
|
+
* Two questions, and each one is skipped entirely when a flag answered it, so
|
|
202
|
+
* a scripted invocation never blocks. That is the whole contract: this command
|
|
203
|
+
* has to be usable both as "make me the September 8 posters" typed by an
|
|
204
|
+
* officer who does not remember the format names, and as a line in a build.
|
|
205
|
+
*
|
|
206
|
+
* {@link requireInteractive} is what keeps the second half true. Without it a
|
|
207
|
+
* missing flag in CI would hang on a prompt nobody can answer until the job
|
|
208
|
+
* times out, which is the least debuggable failure available.
|
|
209
|
+
*/
|
|
210
|
+
/** Refuses, naming the flag, rather than prompting where nobody can answer. */
|
|
211
|
+
function requireInteractive(what, flag) {
|
|
212
|
+
if (!isNonInteractive()) return;
|
|
213
|
+
throw new UsageError(`No terminal to ask which ${what} to use. Pass ${flag}: this is not an interactive shell.`);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Which graphics.
|
|
217
|
+
*
|
|
218
|
+
* Autocomplete rather than a plain list: there are dozens once a semester of
|
|
219
|
+
* meetings is loaded, and the useful interaction is typing "sep" or "dogdays"
|
|
220
|
+
* rather than scrolling. Grouped by prefix in the label so the list reads as
|
|
221
|
+
* the `group/name` vocabulary the flags use.
|
|
222
|
+
*/
|
|
223
|
+
async function pickGraphics(graphics) {
|
|
224
|
+
requireInteractive("graphics", "a graphic name, or `*` for all of them");
|
|
225
|
+
const chosen = unwrap(await autocompleteMultiselect({
|
|
226
|
+
message: "Which images?",
|
|
227
|
+
options: graphics.map((graphic) => ({
|
|
228
|
+
value: graphic.name,
|
|
229
|
+
label: graphic.name,
|
|
230
|
+
hint: graphic.why
|
|
231
|
+
}))
|
|
232
|
+
}));
|
|
233
|
+
return graphics.filter((graphic) => chosen.includes(graphic.name));
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Which formats.
|
|
237
|
+
*
|
|
238
|
+
* Offered from the union of what the chosen graphics support, so the list never
|
|
239
|
+
* contains a size that would be skipped — asking somebody to pick `savvycal`
|
|
240
|
+
* for an event and then quietly not rendering it is worse than not offering it.
|
|
241
|
+
*/
|
|
242
|
+
async function pickFormats(graphics) {
|
|
243
|
+
requireInteractive("formats", "--format or --all-formats");
|
|
244
|
+
const available = formatsFor(graphics);
|
|
245
|
+
return unwrap(await multiselect({
|
|
246
|
+
message: "Which formats?",
|
|
247
|
+
options: available.map((format) => ({
|
|
248
|
+
value: format.name,
|
|
249
|
+
label: format.name,
|
|
250
|
+
hint: format.why
|
|
251
|
+
})),
|
|
252
|
+
initialValues: available.some((format) => format.name === "og") ? ["og"] : []
|
|
253
|
+
}));
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/graphics/registry.ts
|
|
257
|
+
/** The two renditions anything may need, because anything may reach the GDG platform. */
|
|
258
|
+
const GDG = ["gdgc-wide", "gdgc-square"];
|
|
259
|
+
const EVENT_FORMATS = [
|
|
260
|
+
"og",
|
|
261
|
+
...GDG,
|
|
262
|
+
"involvement-network"
|
|
263
|
+
];
|
|
264
|
+
const EMAIL = [
|
|
265
|
+
"email-1x",
|
|
266
|
+
"email-2x",
|
|
267
|
+
"email-3x"
|
|
268
|
+
];
|
|
269
|
+
const ICON_SIZES = [
|
|
270
|
+
16,
|
|
271
|
+
32,
|
|
272
|
+
48,
|
|
273
|
+
64,
|
|
274
|
+
96,
|
|
275
|
+
128,
|
|
276
|
+
180,
|
|
277
|
+
192,
|
|
278
|
+
256,
|
|
279
|
+
384,
|
|
280
|
+
512,
|
|
281
|
+
1024
|
|
282
|
+
];
|
|
283
|
+
/**
|
|
284
|
+
* The club lockup, on each of the two grounds it is ever set on.
|
|
285
|
+
*
|
|
286
|
+
* One subject, two grounds, and the rendition decides which template draws it:
|
|
287
|
+
* the email densities want the signature's tight horizontal strip, everything
|
|
288
|
+
* else wants the banner. That is why "the email signature" is not a graphic of
|
|
289
|
+
* its own: it is this lockup at `email-2x`, and its GDG export is the same
|
|
290
|
+
* lockup at `gdgc-square` rather than a second drawing that has to be kept in
|
|
291
|
+
* step with the first.
|
|
292
|
+
*/
|
|
293
|
+
function brandGraphics() {
|
|
294
|
+
return ["dark", "light"].map((ground) => ({
|
|
295
|
+
name: ground === "dark" ? "brand/club" : "brand/club-light",
|
|
296
|
+
group: "brand",
|
|
297
|
+
stem: ground === "dark" ? "club" : "club-light",
|
|
298
|
+
formats: [
|
|
299
|
+
"og",
|
|
300
|
+
...GDG,
|
|
301
|
+
...ground === "dark" ? ["savvycal"] : [],
|
|
302
|
+
...EMAIL
|
|
303
|
+
],
|
|
304
|
+
why: `The DevDogs lockup with the GDG chapter cobrand, on a ${ground} ground.`,
|
|
305
|
+
render: (format) => format.family === "email" ? EmailSignature({
|
|
306
|
+
width: format.width,
|
|
307
|
+
ground
|
|
308
|
+
}) : Banner({
|
|
309
|
+
width: format.width,
|
|
310
|
+
height: format.height,
|
|
311
|
+
ground
|
|
312
|
+
})
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
315
|
+
function appGraphics() {
|
|
316
|
+
return Object.keys(APPS).map((app) => {
|
|
317
|
+
const brand = APPS[app];
|
|
318
|
+
return {
|
|
319
|
+
name: `app/${app}`,
|
|
320
|
+
group: "app",
|
|
321
|
+
stem: app,
|
|
322
|
+
formats: [
|
|
323
|
+
...ICON_SIZES.map((size) => `icon-${size}`),
|
|
324
|
+
"icon-favicon",
|
|
325
|
+
"icon-apple",
|
|
326
|
+
"og",
|
|
327
|
+
...GDG
|
|
328
|
+
],
|
|
329
|
+
why: `${brand.name}: its mark as an icon, and its card as a link.`,
|
|
330
|
+
render: (format) => format.family === "icon" ? AppIcon({
|
|
331
|
+
app,
|
|
332
|
+
size: format.width,
|
|
333
|
+
background: OPAQUE_FORMATS.has(format.name) ? THEME.background : void 0
|
|
334
|
+
}) : PageCard({
|
|
335
|
+
width: format.width,
|
|
336
|
+
height: format.height,
|
|
337
|
+
title: brand.name,
|
|
338
|
+
description: brand.blurb,
|
|
339
|
+
eyebrow: brand.tagline,
|
|
340
|
+
accent: brand.ground,
|
|
341
|
+
footer: brand.host,
|
|
342
|
+
cobrand: true
|
|
343
|
+
})
|
|
344
|
+
};
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* One graphic per meeting, and one per agenda item when a night has several.
|
|
349
|
+
*
|
|
350
|
+
* These are the reason the two axes were split at all: "this meeting" x "the
|
|
351
|
+
* GDG platform's banner" is a pairing nobody could have enumerated in advance.
|
|
352
|
+
*/
|
|
353
|
+
function eventGraphics(events) {
|
|
354
|
+
return events.flatMap((event) => {
|
|
355
|
+
const meeting = {
|
|
356
|
+
name: `event/${event.slug}/meeting`,
|
|
357
|
+
group: "event",
|
|
358
|
+
stem: event.slug,
|
|
359
|
+
formats: EVENT_FORMATS,
|
|
360
|
+
why: `The ${event.slug} meeting: ${event.hint}.`,
|
|
361
|
+
render: (format) => EventCard({
|
|
362
|
+
width: format.width,
|
|
363
|
+
height: format.height,
|
|
364
|
+
cobrand: true,
|
|
365
|
+
...event.detail
|
|
366
|
+
})
|
|
367
|
+
};
|
|
368
|
+
if ((event.items?.length ?? 0) < 2) return [meeting];
|
|
369
|
+
const occurrences = /* @__PURE__ */ new Map();
|
|
370
|
+
return [meeting, ...event.items.map((item) => {
|
|
371
|
+
const occurrence = (occurrences.get(item.stem) ?? 0) + 1;
|
|
372
|
+
occurrences.set(item.stem, occurrence);
|
|
373
|
+
const path = occurrence === 1 ? item.stem : `${item.stem}-${occurrence}`;
|
|
374
|
+
return {
|
|
375
|
+
name: `event/${event.slug}/${path}`,
|
|
376
|
+
group: "event",
|
|
377
|
+
stem: `${event.slug}-${path}`,
|
|
378
|
+
formats: EVENT_FORMATS,
|
|
379
|
+
why: `${item.detail.badge?.label ?? "Agenda item"}: ${item.detail.title} (${event.hint}).`,
|
|
380
|
+
render: (format) => EventCard({
|
|
381
|
+
width: format.width,
|
|
382
|
+
height: format.height,
|
|
383
|
+
cobrand: true,
|
|
384
|
+
...item.detail
|
|
385
|
+
})
|
|
386
|
+
};
|
|
387
|
+
})];
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
/** Everything that does not depend on the schedule. */
|
|
391
|
+
function staticGraphics() {
|
|
392
|
+
return [...brandGraphics(), ...appGraphics()];
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Every leaf is unique, so a flat output directory cannot silently overwrite
|
|
396
|
+
* one graphic's file with another's.
|
|
397
|
+
*/
|
|
398
|
+
function assertUniqueStems(graphics) {
|
|
399
|
+
const seen = /* @__PURE__ */ new Map();
|
|
400
|
+
for (const graphic of graphics) {
|
|
401
|
+
const clash = seen.get(graphic.stem);
|
|
402
|
+
if (clash) throw new UsageError(`${graphic.name} and ${clash} share the leaf "${graphic.stem}", so a flat output directory would overwrite one with the other.`);
|
|
403
|
+
seen.set(graphic.stem, graphic.name);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
//#endregion
|
|
407
|
+
//#region src/graphics/commands.ts
|
|
408
|
+
/**
|
|
409
|
+
* `pnpm backstage graphics [graphic…] [--format …] [--out …]`
|
|
410
|
+
*
|
|
411
|
+
* Renders the club's pictures from the templates in `@devdogsuga/brand`. Two
|
|
412
|
+
* axes, asked for separately: WHICH picture (`event/2026-09-08`, `app/dogdays`,
|
|
413
|
+
* `brand/club`) and at WHAT SIZE (`gdgc-square`, `og`, `icon-512`). That split
|
|
414
|
+
* is the point of the command: an event poster for the GDG on Campus platform
|
|
415
|
+
* is exactly the pairing "this meeting" x "that platform's banner".
|
|
416
|
+
*
|
|
417
|
+
* Anything the command line leaves out, it asks for; anything it names, it does
|
|
418
|
+
* not. So an officer can type `pnpm backstage graphics` and be walked through
|
|
419
|
+
* it, and a script can pass every flag and never see a prompt.
|
|
420
|
+
*
|
|
421
|
+
* It needs no checkout and no credentials: the templates are in
|
|
422
|
+
* `@devdogsuga/brand`, the meetings in `@devdogsuga/events`, and the files go
|
|
423
|
+
* to `--out` or the current directory, flat, as `<stem>-<format>.png`.
|
|
424
|
+
*/
|
|
425
|
+
function flagValue(argv, flag) {
|
|
426
|
+
const index = argv.indexOf(flag);
|
|
427
|
+
if (index === -1) return void 0;
|
|
428
|
+
const value = argv[index + 1];
|
|
429
|
+
return value && !value.startsWith("--") ? value : void 0;
|
|
430
|
+
}
|
|
431
|
+
function parseGraphicsArgs(argv) {
|
|
432
|
+
const normalized = normalizeArgv(argv);
|
|
433
|
+
return {
|
|
434
|
+
patterns: positionals(normalized),
|
|
435
|
+
formats: commaList(flagValue(normalized, "--format")),
|
|
436
|
+
allFormats: normalized.includes("--all-formats"),
|
|
437
|
+
out: flagValue(normalized, "--out"),
|
|
438
|
+
noOutput: normalized.includes("--dry-run") || normalized.includes("--no-output")
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
/** `~/images` when the shell did not expand it, which quoting prevents. */
|
|
442
|
+
function expandHome(path) {
|
|
443
|
+
return path === "~" || path.startsWith("~/") ? resolve(homedir(), path.slice(2)) : path;
|
|
444
|
+
}
|
|
445
|
+
/** Whether anything asked for could be an event, so whether to read the schedule. */
|
|
446
|
+
function wantsEvents(patterns) {
|
|
447
|
+
if (patterns.length === 0) return true;
|
|
448
|
+
return patterns.some((pattern) => pattern === "*" || pattern === "*/*" || pattern === "event" || pattern.startsWith("event/"));
|
|
449
|
+
}
|
|
450
|
+
/** Whether a pattern names events specifically, rather than sweeping them up. */
|
|
451
|
+
function namesEvents(patterns) {
|
|
452
|
+
return patterns.some((pattern) => pattern === "event" || pattern.startsWith("event/"));
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Meetings, or a clear account of why there are none.
|
|
456
|
+
*
|
|
457
|
+
* A wildcard means "everything", and everything else is renderable, so an
|
|
458
|
+
* unreadable schedule is reported and skipped; `event/*` asked for exactly the
|
|
459
|
+
* thing that is missing, so it fails.
|
|
460
|
+
*/
|
|
461
|
+
async function loadEvents(patterns, deps) {
|
|
462
|
+
const required = namesEvents(patterns);
|
|
463
|
+
const reader = (deps.reader ?? configEvents)();
|
|
464
|
+
let meetings;
|
|
465
|
+
try {
|
|
466
|
+
meetings = await reader.meetings();
|
|
467
|
+
} catch (err) {
|
|
468
|
+
if (required) throw err;
|
|
469
|
+
return {
|
|
470
|
+
graphics: [],
|
|
471
|
+
skipped: errorMessage(err)
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
graphics: eventGraphics(meetings),
|
|
476
|
+
skipped: null
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
/** Where one selection is written: flat, always `<stem>-<format>.png`. */
|
|
480
|
+
function destinationOf({ graphic, format }, out) {
|
|
481
|
+
return resolve(out, `${graphic.stem}-${format.name}.png`);
|
|
482
|
+
}
|
|
483
|
+
async function runGraphics(argv, deps = {}) {
|
|
484
|
+
const options = parseGraphicsArgs(argv);
|
|
485
|
+
try {
|
|
486
|
+
await graphics(options, deps);
|
|
487
|
+
} catch (err) {
|
|
488
|
+
explainError("Could not render that.", err, ["pnpm backstage graphics # pick from a list", "pnpm backstage graphics 'event/*' --all-formats --out ~/images"]);
|
|
489
|
+
process.exitCode = 1;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
async function graphics(options, deps) {
|
|
493
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
494
|
+
const registry = staticGraphics();
|
|
495
|
+
let skipped = null;
|
|
496
|
+
if (wantsEvents(options.patterns)) {
|
|
497
|
+
const events = await loadEvents(options.patterns, deps);
|
|
498
|
+
registry.push(...events.graphics);
|
|
499
|
+
skipped = events.skipped;
|
|
500
|
+
}
|
|
501
|
+
assertUniqueStems(registry);
|
|
502
|
+
const chosen = options.patterns.length === 0 ? await pickGraphics(registry) : resolvePatterns(options.patterns, registry);
|
|
503
|
+
if (chosen.length === 0) throw new UsageError("Nothing to render.");
|
|
504
|
+
const requested = options.allFormats ? formatsFor(chosen).map((format) => format.name) : options.formats.length > 0 ? options.formats : await pickFormats(chosen);
|
|
505
|
+
const unknown = requested.filter((name) => !FORMATS[name]);
|
|
506
|
+
if (unknown.length > 0) throw new UsageError(`No format called ${unknown.join(", ")}. Try one of ${Object.keys(FORMATS).join(", ")}.`);
|
|
507
|
+
const { selections, unsupported } = pair(chosen, requested);
|
|
508
|
+
if (selections.length === 0 && unsupported.length > 0) throw new UsageError(unsupported.map((miss) => `${miss.graphic} has no ${miss.format} rendition`).join("; "));
|
|
509
|
+
if (selections.length === 0) throw new UsageError("Nothing to render.");
|
|
510
|
+
const out = resolve(cwd, options.out === void 0 || options.out === "" ? "." : expandHome(options.out));
|
|
511
|
+
const display = (file) => {
|
|
512
|
+
const rel = relative(cwd, file);
|
|
513
|
+
return rel.startsWith("..") ? file : rel;
|
|
514
|
+
};
|
|
515
|
+
if (skipped) log.warn(`event graphics skipped: ${skipped}`);
|
|
516
|
+
if (options.noOutput) {
|
|
517
|
+
note(selections.map((selection) => `${selection.graphic.name} ${selection.format.name}\n ${display(destinationOf(selection, out))}\n ${selection.format.why}`).join("\n"), `${selections.length} image${selections.length === 1 ? "" : "s"}`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
let bytes = 0;
|
|
521
|
+
for (const selection of selections) {
|
|
522
|
+
const file = destinationOf(selection, out);
|
|
523
|
+
const { png, width, height } = await render$1(selection.graphic.render(selection.format), selection.format);
|
|
524
|
+
await mkdir(dirname(file), { recursive: true });
|
|
525
|
+
await writeFile(file, png);
|
|
526
|
+
bytes += png.length;
|
|
527
|
+
log.success(`${display(file)} ${width}x${height} (${Math.round(png.length / 1024)} KB)`);
|
|
528
|
+
}
|
|
529
|
+
log.info(`${selections.length} image${selections.length === 1 ? "" : "s"}, ${Math.round(bytes / 1024)} KB total.`);
|
|
530
|
+
}
|
|
531
|
+
function resolvePatterns(patterns, registry) {
|
|
532
|
+
const { matched, unmatched } = matchGraphics(patterns, registry);
|
|
533
|
+
if (unmatched.length === 0) return matched;
|
|
534
|
+
const askedForEvents = unmatched.some((pattern) => pattern === "event" || pattern.startsWith("event/"));
|
|
535
|
+
const haveEvents = registry.some((graphic) => graphic.group === "event");
|
|
536
|
+
if (askedForEvents && !haveEvents) throw new UsageError("The published @devdogsuga/events has no meetings, so there are no event images to render.");
|
|
537
|
+
const groups = [...new Set(registry.map((graphic) => `${graphic.group}/*`))];
|
|
538
|
+
throw new UsageError(`Nothing called ${unmatched.join(", ")}. Names are group/name. Try ${groups.join(", ")}, or * for all of them.`);
|
|
539
|
+
}
|
|
540
|
+
const handleGraphics = async (rest) => {
|
|
541
|
+
await runGraphics(rest);
|
|
542
|
+
return DONE;
|
|
543
|
+
};
|
|
544
|
+
//#endregion
|
|
545
|
+
export { handleGraphics };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region ../cli-core/src/dispatch.ts
|
|
2
|
+
/**
|
|
3
|
+
* What a CLI's dispatcher and its domains agree on.
|
|
4
|
+
*
|
|
5
|
+
* A domain's `commands.ts` exports one `CommandHandler` per top-level command
|
|
6
|
+
* it owns; the CLI's `cli.ts` maps command names to those handlers and does
|
|
7
|
+
* nothing else with them. The wizard builds an argv and hands it to the same
|
|
8
|
+
* dispatcher, so a menu walk and a typed command line take the identical path.
|
|
9
|
+
*/
|
|
10
|
+
/** The `outro()` line for a command that finished. */
|
|
11
|
+
const DONE = "Done.";
|
|
12
|
+
//#endregion
|
|
13
|
+
export { DONE as t };
|