emdash-header-footer-code 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.
@@ -0,0 +1,282 @@
1
+ import { Badge, Button, Dialog, DropdownMenu, Empty, Input, Select, Switch, Table, cn } from "@cloudflare/kumo";
2
+ import * as React from "react";
3
+ import { PLACEMENTS } from "../core/types.js";
4
+ import type { SnippetSummary } from "./api.js";
5
+ import {
6
+ PLACEMENT_LABEL,
7
+ type PlacementFilter,
8
+ filterSnippets,
9
+ formatTimestamp,
10
+ relativeTime,
11
+ userLabel,
12
+ whereSummary,
13
+ } from "./format.js";
14
+ import { CodeIcon, CopyIcon, DotsIcon, PencilIcon, PlusIcon, SearchIcon, TrashIcon } from "./icons.js";
15
+
16
+ const FILTER_ITEMS: Record<PlacementFilter, string> = {
17
+ all: "All placements",
18
+ ...Object.fromEntries(PLACEMENTS.map((p) => [p, PLACEMENT_LABEL[p]])),
19
+ } as Record<PlacementFilter, string>;
20
+
21
+ /** Clicks on these (or inside them) never open the row's editor. */
22
+ const INTERACTIVE = "button, a, input, label, select, textarea, [role='switch'], [role='menuitem']";
23
+
24
+ /** Re-render every minute so relative times stay current. */
25
+ function useNow(): number {
26
+ const [now, setNow] = React.useState(() => Date.now());
27
+ React.useEffect(() => {
28
+ const t = setInterval(() => setNow(Date.now()), 60_000);
29
+ return () => clearInterval(t);
30
+ }, []);
31
+ return now;
32
+ }
33
+
34
+ export function SnippetList(props: {
35
+ snippets: SnippetSummary[];
36
+ canManage: boolean;
37
+ onNew: () => void;
38
+ onToggle: (id: string, enabled: boolean) => void;
39
+ onEdit: (id: string) => void;
40
+ onDuplicate: (id: string) => void;
41
+ onDelete: (id: string) => Promise<void>;
42
+ }) {
43
+ const { snippets, canManage } = props;
44
+ const now = useNow();
45
+ const [query, setQuery] = React.useState("");
46
+ const [placement, setPlacement] = React.useState<PlacementFilter>("all");
47
+ const [confirming, setConfirming] = React.useState<SnippetSummary | null>(null);
48
+ const [deleting, setDeleting] = React.useState(false);
49
+
50
+ const visible = filterSnippets(snippets, { query, placement });
51
+
52
+ async function confirmDelete() {
53
+ if (!confirming) return;
54
+ setDeleting(true);
55
+ try {
56
+ await props.onDelete(confirming.id);
57
+ } finally {
58
+ setDeleting(false);
59
+ setConfirming(null);
60
+ }
61
+ }
62
+
63
+ if (snippets.length === 0) {
64
+ return (
65
+ <Empty
66
+ icon={<CodeIcon size={40} className="text-kumo-subtle" />}
67
+ title="No snippets yet"
68
+ description={
69
+ canManage
70
+ ? "Add analytics, verification tags, chat widgets or custom CSS and JavaScript to your pages."
71
+ : "An administrator hasn't added any snippets."
72
+ }
73
+ contents={
74
+ canManage ? (
75
+ <Button variant="primary" icon={<PlusIcon />} onClick={props.onNew}>
76
+ New snippet
77
+ </Button>
78
+ ) : undefined
79
+ }
80
+ />
81
+ );
82
+ }
83
+
84
+ return (
85
+ <>
86
+ <div className="flex flex-col gap-3 border-b border-kumo-line p-4 sm:flex-row sm:items-center">
87
+ <div className="relative w-full sm:w-72">
88
+ <span className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-kumo-subtle">
89
+ <SearchIcon />
90
+ </span>
91
+ <Input
92
+ type="search"
93
+ data-testid="hfc-search"
94
+ aria-label="Search snippets by name"
95
+ placeholder="Search by name…"
96
+ className="w-full ps-9"
97
+ value={query}
98
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
99
+ />
100
+ </div>
101
+ <div data-testid="hfc-filter-placement" className="w-full sm:w-48">
102
+ <Select
103
+ aria-label="Filter by placement"
104
+ className="w-full"
105
+ value={placement}
106
+ onValueChange={(v) => setPlacement((v as PlacementFilter | null) ?? "all")}
107
+ items={FILTER_ITEMS}
108
+ />
109
+ </div>
110
+ <p className="whitespace-nowrap text-sm text-kumo-subtle sm:ms-auto">
111
+ {visible.length === snippets.length
112
+ ? `${snippets.length} ${snippets.length === 1 ? "snippet" : "snippets"}`
113
+ : `${visible.length} of ${snippets.length}`}
114
+ </p>
115
+ </div>
116
+
117
+ {visible.length === 0 ? (
118
+ <Empty
119
+ size="sm"
120
+ icon={<SearchIcon size={32} className="text-kumo-subtle" />}
121
+ title="No matches"
122
+ description="No snippets match your search or filter."
123
+ contents={
124
+ <Button
125
+ variant="secondary"
126
+ onClick={() => {
127
+ setQuery("");
128
+ setPlacement("all");
129
+ }}
130
+ >
131
+ Clear filters
132
+ </Button>
133
+ }
134
+ />
135
+ ) : (
136
+ <div className="overflow-x-auto">
137
+ <Table>
138
+ <Table.Header>
139
+ <Table.Row>
140
+ <Table.Head>Name</Table.Head>
141
+ {/* Below lg (e.g. ~820px next to the sidebar) Placement, Priority and Updated give way so
142
+ Enabled and the actions fit; the where-summary line still starts with the placement. */}
143
+ <Table.Head className="hidden lg:table-cell">Placement</Table.Head>
144
+ <Table.Head className="hidden lg:table-cell">Priority</Table.Head>
145
+ <Table.Head>Enabled</Table.Head>
146
+ <Table.Head className="hidden lg:table-cell">Updated</Table.Head>
147
+ {canManage && (
148
+ <Table.Head>
149
+ <span className="sr-only">Actions</span>
150
+ </Table.Head>
151
+ )}
152
+ </Table.Row>
153
+ </Table.Header>
154
+ <Table.Body>
155
+ {visible.map((s) => (
156
+ <Table.Row
157
+ key={s.id}
158
+ data-testid={`hfc-row-${s.id}`}
159
+ className={cn(canManage && "hfc-row-clickable", !s.enabled && "hfc-row-off")}
160
+ onClick={
161
+ canManage
162
+ ? (e: React.MouseEvent<HTMLTableRowElement>) => {
163
+ const target = e.target as Element;
164
+ // Portaled menus bubble React events here too; only real in-row clicks count.
165
+ if (!e.currentTarget.contains(target) || target.closest(INTERACTIVE)) return;
166
+ props.onEdit(s.id);
167
+ }
168
+ : undefined
169
+ }
170
+ >
171
+ <Table.Cell className="hfc-dim">
172
+ <div className="font-medium text-kumo-default">{s.name}</div>
173
+ <div className="hfc-where truncate text-sm text-kumo-subtle" title={whereSummary(s)}>
174
+ {whereSummary(s)}
175
+ </div>
176
+ </Table.Cell>
177
+ <Table.Cell className="hfc-dim hidden lg:table-cell">
178
+ <Badge variant="secondary">{PLACEMENT_LABEL[s.placement]}</Badge>
179
+ </Table.Cell>
180
+ <Table.Cell className="hfc-dim hidden tabular-nums lg:table-cell">{s.priority}</Table.Cell>
181
+ <Table.Cell className="whitespace-nowrap">
182
+ {canManage ? (
183
+ <Switch
184
+ size="sm"
185
+ data-testid={`hfc-row-toggle-${s.id}`}
186
+ checked={s.enabled}
187
+ onCheckedChange={(v: boolean) => props.onToggle(s.id, v)}
188
+ label={s.enabled ? "On" : "Off"}
189
+ aria-label={`${s.enabled ? "On" : "Off"} — ${s.name}`}
190
+ />
191
+ ) : (
192
+ <Badge variant={s.enabled ? "success" : "secondary"}>{s.enabled ? "On" : "Off"}</Badge>
193
+ )}
194
+ </Table.Cell>
195
+ <Table.Cell className="hfc-dim hidden whitespace-nowrap lg:table-cell">
196
+ <div className="text-sm text-kumo-default" title={formatTimestamp(s.updatedAt)}>
197
+ {relativeTime(s.updatedAt, now)}
198
+ </div>
199
+ <div className="text-sm text-kumo-subtle">{userLabel(s.updatedBy)}</div>
200
+ </Table.Cell>
201
+ {canManage && (
202
+ <Table.Cell>
203
+ <div className="flex items-center justify-end gap-1">
204
+ <Button
205
+ variant="secondary"
206
+ size="sm"
207
+ icon={<PencilIcon size={14} />}
208
+ data-testid={`hfc-row-edit-${s.id}`}
209
+ onClick={() => props.onEdit(s.id)}
210
+ >
211
+ Edit
212
+ </Button>
213
+ <DropdownMenu>
214
+ <DropdownMenu.Trigger
215
+ render={
216
+ <Button
217
+ variant="ghost"
218
+ size="sm"
219
+ shape="square"
220
+ icon={<DotsIcon />}
221
+ aria-label={`More actions for ${s.name}`}
222
+ data-testid={`hfc-row-menu-${s.id}`}
223
+ />
224
+ }
225
+ />
226
+ <DropdownMenu.Content align="end">
227
+ <DropdownMenu.Item
228
+ icon={<CopyIcon size={14} className="me-2" />}
229
+ data-testid={`hfc-row-duplicate-${s.id}`}
230
+ onClick={() => props.onDuplicate(s.id)}
231
+ >
232
+ Duplicate
233
+ </DropdownMenu.Item>
234
+ <DropdownMenu.Separator />
235
+ <DropdownMenu.Item
236
+ variant="danger"
237
+ icon={<TrashIcon size={14} className="me-2" />}
238
+ data-testid={`hfc-row-delete-${s.id}`}
239
+ onClick={() => setConfirming(s)}
240
+ >
241
+ Delete
242
+ </DropdownMenu.Item>
243
+ </DropdownMenu.Content>
244
+ </DropdownMenu>
245
+ </div>
246
+ </Table.Cell>
247
+ )}
248
+ </Table.Row>
249
+ ))}
250
+ </Table.Body>
251
+ </Table>
252
+ </div>
253
+ )}
254
+
255
+ <Dialog.Root
256
+ role="alertdialog"
257
+ open={confirming !== null}
258
+ onOpenChange={(o) => !o && !deleting && setConfirming(null)}
259
+ >
260
+ <Dialog className="max-w-md p-6" size="sm">
261
+ <Dialog.Title className="text-lg font-semibold">Delete snippet?</Dialog.Title>
262
+ <Dialog.Description className="text-kumo-subtle">
263
+ “{confirming?.name}” will stop rendering on every page. This cannot be undone.
264
+ </Dialog.Description>
265
+ <div className="mt-6 flex justify-end gap-2">
266
+ <Button variant="secondary" disabled={deleting} onClick={() => setConfirming(null)}>
267
+ Cancel
268
+ </Button>
269
+ <Button
270
+ variant="destructive"
271
+ data-testid="hfc-confirm-delete"
272
+ loading={deleting}
273
+ onClick={() => void confirmDelete()}
274
+ >
275
+ Delete
276
+ </Button>
277
+ </div>
278
+ </Dialog>
279
+ </Dialog.Root>
280
+ </>
281
+ );
282
+ }
@@ -0,0 +1,285 @@
1
+ import {
2
+ Badge,
3
+ Banner,
4
+ Button,
5
+ Dialog,
6
+ LayerCard,
7
+ Loader,
8
+ Switch,
9
+ Tabs,
10
+ Toasty,
11
+ useKumoToastManager,
12
+ } from "@cloudflare/kumo";
13
+ import * as React from "react";
14
+ import { emptyDraft } from "../core/fields.js";
15
+ import { api, type ListResponse } from "./api.js";
16
+ import { ChangeLog } from "./ChangeLog.js";
17
+ import { InfoIcon, PlusIcon, WarningIcon } from "./icons.js";
18
+ import { type Draft, SnippetForm } from "./SnippetForm.js";
19
+ import { SnippetList } from "./SnippetList.js";
20
+ import { HfcStyles } from "./styles.js";
21
+
22
+ type View = { kind: "list" } | { kind: "edit"; draft: Draft };
23
+ type Tab = "snippets" | "changelog";
24
+ type Notify = (title: string, variant?: "success" | "error") => void;
25
+
26
+ /** True when an ancestor (the EmDash admin shell) already provides Kumo's toast manager. */
27
+ function useHasToastProvider(): boolean {
28
+ // Deliberate try/catch around a hook: Kumo's useKumoToastManager reads its context and throws
29
+ // when there is no provider. The hook is called on every render either way, so hook order is
30
+ // stable; we only use the throw as a "no provider here" signal.
31
+ try {
32
+ useKumoToastManager();
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function useNotify(): Notify {
40
+ const toasts = useKumoToastManager();
41
+ return React.useCallback<Notify>((title, variant = "success") => toasts.add({ title, variant }), [toasts]);
42
+ }
43
+
44
+ /**
45
+ * EmDash's admin wraps pages in Kumo's <Toasty>. If this page ever renders where that provider is
46
+ * a different Kumo instance (or missing), provide one locally instead of crashing.
47
+ */
48
+ export function SnippetsPage() {
49
+ const hasProvider = useHasToastProvider();
50
+ // `hfc-root` scopes our styles and marks the plugin's own UI (e2e accessibility checks run here).
51
+ const page = (
52
+ <div className="hfc-root" data-testid="hfc-root">
53
+ <HfcStyles />
54
+ <SnippetsScreen />
55
+ </div>
56
+ );
57
+ return hasProvider ? page : <Toasty>{page}</Toasty>;
58
+ }
59
+
60
+ function SnippetsScreen() {
61
+ const notify = useNotify();
62
+ const [tab, setTab] = React.useState<Tab>("snippets");
63
+ const [view, setView] = React.useState<View>({ kind: "list" });
64
+ const [data, setData] = React.useState<ListResponse | null>(null);
65
+ const [error, setError] = React.useState<string | null>(null);
66
+ const [confirmKill, setConfirmKill] = React.useState(false);
67
+ const [killPending, setKillPending] = React.useState(false);
68
+ const [killError, setKillError] = React.useState<string | null>(null);
69
+
70
+ const refresh = React.useCallback(() => {
71
+ return api.list().then(setData, (e: unknown) => setError(e instanceof Error ? e.message : String(e)));
72
+ }, []);
73
+ React.useEffect(() => {
74
+ void refresh();
75
+ }, [refresh]);
76
+
77
+ /** Run a mutation, report failures in the page banner, toast success, then reload the list. */
78
+ async function run(action: () => Promise<unknown>, success?: string): Promise<boolean> {
79
+ setError(null);
80
+ let ok = false;
81
+ try {
82
+ await action();
83
+ ok = true;
84
+ if (success) notify(success);
85
+ } catch (e) {
86
+ setError(e instanceof Error ? e.message : String(e));
87
+ }
88
+ await refresh();
89
+ return ok;
90
+ }
91
+
92
+ async function openEditor(id: string) {
93
+ setError(null);
94
+ try {
95
+ const { snippet } = await api.get(id);
96
+ setView({ kind: "edit", draft: snippet });
97
+ } catch (e) {
98
+ setError(e instanceof Error ? e.message : String(e));
99
+ }
100
+ }
101
+
102
+ /** Turning output off happens from the confirm Dialog, so its failure is shown there. */
103
+ async function setOutput(enabled: boolean) {
104
+ setKillPending(true);
105
+ setKillError(null);
106
+ setError(null);
107
+ try {
108
+ await api.setKillSwitch(!enabled);
109
+ notify(enabled ? "Snippet output turned on" : "All snippet output turned off");
110
+ setConfirmKill(false);
111
+ } catch (e) {
112
+ const message = e instanceof Error ? e.message : String(e);
113
+ if (enabled) setError(message);
114
+ else setKillError(message);
115
+ } finally {
116
+ setKillPending(false);
117
+ await refresh();
118
+ }
119
+ }
120
+
121
+ function closeKillDialog() {
122
+ setConfirmKill(false);
123
+ setKillError(null);
124
+ }
125
+
126
+ if (view.kind === "edit") {
127
+ return (
128
+ <SnippetForm
129
+ key={view.draft.id ?? "new"}
130
+ initial={view.draft}
131
+ onClose={() => setView({ kind: "list" })}
132
+ onSaved={() => {
133
+ notify("Snippet saved");
134
+ setView({ kind: "list" });
135
+ void refresh();
136
+ }}
137
+ />
138
+ );
139
+ }
140
+
141
+ const canManage = data?.canManage ?? false;
142
+ const snippetIds = new Set(data?.snippets.map((s) => s.id) ?? []);
143
+ const list = data ? (
144
+ <SnippetList
145
+ snippets={data.snippets}
146
+ canManage={canManage}
147
+ onNew={() => setView({ kind: "edit", draft: emptyDraft() })}
148
+ onToggle={(id, enabled) => void run(() => api.toggle(id, enabled))}
149
+ onEdit={(id) => void openEditor(id)}
150
+ onDuplicate={(id) => void run(() => api.duplicate(id), "Snippet duplicated")}
151
+ onDelete={async (id) => {
152
+ await run(() => api.remove(id), "Snippet deleted");
153
+ }}
154
+ />
155
+ ) : (
156
+ <div className="flex justify-center py-10">
157
+ <Loader />
158
+ </div>
159
+ );
160
+
161
+ return (
162
+ <div className="space-y-6">
163
+ <header className="flex flex-wrap items-start justify-between gap-4">
164
+ <div className="min-w-0">
165
+ <h1 className="text-2xl font-semibold leading-tight">Header &amp; Footer Code</h1>
166
+ <p className="text-sm text-kumo-subtle">
167
+ Add analytics, verification tags and other snippets to your pages without editing the theme.
168
+ </p>
169
+ </div>
170
+ {canManage && data && (
171
+ <div className="flex flex-wrap items-center gap-4">
172
+ <Switch
173
+ data-testid="hfc-killswitch"
174
+ checked={!data.disabled}
175
+ disabled={killPending}
176
+ onCheckedChange={(on: boolean) => (on ? void setOutput(true) : setConfirmKill(true))}
177
+ label="Output enabled"
178
+ />
179
+ <Button
180
+ variant="primary"
181
+ icon={<PlusIcon />}
182
+ data-testid="hfc-new"
183
+ onClick={() => setView({ kind: "edit", draft: emptyDraft() })}
184
+ >
185
+ New snippet
186
+ </Button>
187
+ </div>
188
+ )}
189
+ </header>
190
+
191
+ <div className="space-y-3">
192
+ <Banner
193
+ className="hfc-banner-info"
194
+ data-testid="hfc-theme-note"
195
+ icon={<InfoIcon size={20} />}
196
+ title="Your theme must include the EmDash page components"
197
+ description={
198
+ <>
199
+ Snippets only render if the layout includes <code>&lt;EmDashHead /&gt;</code>,{" "}
200
+ <code>&lt;EmDashBodyStart /&gt;</code> and <code>&lt;EmDashBodyEnd /&gt;</code> for the matching
201
+ placements. Pages served from an edge HTML cache show changes only after the cache is purged.
202
+ </>
203
+ }
204
+ />
205
+ {data?.disabled && (
206
+ <Banner
207
+ variant="error"
208
+ data-testid="hfc-disabled-banner"
209
+ icon={<WarningIcon size={20} />}
210
+ title="All snippet output is disabled"
211
+ description="No snippet renders on any page until output is turned back on."
212
+ />
213
+ )}
214
+ {error && <Banner variant="error" title="Something went wrong" description={error} />}
215
+ </div>
216
+
217
+ {canManage ? (
218
+ <div className="space-y-4">
219
+ <Tabs
220
+ className="hfc-tabs w-full max-w-full sm:w-fit"
221
+ listClassName="w-full"
222
+ indicatorClassName="hfc-tab-indicator"
223
+ value={tab}
224
+ onValueChange={(v) => setTab(v === "changelog" ? "changelog" : "snippets")}
225
+ tabs={[
226
+ {
227
+ value: "snippets",
228
+ className: "hfc-tab",
229
+ label: (
230
+ <span data-testid="hfc-tab-snippets" className="flex items-center gap-1.5">
231
+ Snippets
232
+ <Badge variant="secondary" className="hfc-tab-count">
233
+ {data?.snippets.length ?? 0}
234
+ </Badge>
235
+ </span>
236
+ ),
237
+ },
238
+ {
239
+ value: "changelog",
240
+ className: "hfc-tab",
241
+ label: <span data-testid="hfc-tab-changelog">Change log</span>,
242
+ },
243
+ ]}
244
+ />
245
+ <LayerCard>
246
+ {tab === "changelog" ? <ChangeLog snippetIds={snippetIds} onOpen={(id) => void openEditor(id)} /> : list}
247
+ </LayerCard>
248
+ </div>
249
+ ) : (
250
+ <LayerCard>{list}</LayerCard>
251
+ )}
252
+
253
+ <Dialog.Root role="alertdialog" open={confirmKill} onOpenChange={(o) => !o && !killPending && closeKillDialog()}>
254
+ <Dialog className="max-w-md p-6" size="sm">
255
+ <Dialog.Title className="text-lg font-semibold">Turn off all snippet output?</Dialog.Title>
256
+ <Dialog.Description className="text-kumo-subtle">
257
+ Every snippet stops rendering on every page, whether it is enabled or not, until you turn output back on.
258
+ </Dialog.Description>
259
+ {killError && (
260
+ <Banner
261
+ variant="error"
262
+ className="mt-4"
263
+ data-testid="hfc-killswitch-error"
264
+ title="Output was not turned off"
265
+ description={killError}
266
+ />
267
+ )}
268
+ <div className="mt-6 flex justify-end gap-2">
269
+ <Button variant="secondary" disabled={killPending} onClick={closeKillDialog}>
270
+ Cancel
271
+ </Button>
272
+ <Button
273
+ variant="destructive"
274
+ data-testid="hfc-confirm-killswitch"
275
+ loading={killPending}
276
+ onClick={() => void setOutput(false)}
277
+ >
278
+ Turn off output
279
+ </Button>
280
+ </div>
281
+ </Dialog>
282
+ </Dialog.Root>
283
+ </div>
284
+ );
285
+ }
@@ -0,0 +1,47 @@
1
+ import { apiFetch } from "emdash/plugin-utils";
2
+ import type { ChangeLogEntry, EditableSnippet, Snippet } from "../core/types.js";
3
+
4
+ const BASE = "/_emdash/api/plugins/header-footer-code";
5
+
6
+ export type SnippetSummary = Omit<Snippet, "code"> & { code?: string };
7
+ export interface ListResponse {
8
+ disabled: boolean;
9
+ canManage: boolean;
10
+ snippets: SnippetSummary[];
11
+ }
12
+
13
+ export class ApiError extends Error {
14
+ constructor(
15
+ message: string,
16
+ public fieldErrors: Record<string, string> = {},
17
+ ) {
18
+ super(message);
19
+ }
20
+ }
21
+
22
+ async function call<T>(route: string, payload?: unknown): Promise<T> {
23
+ const init: RequestInit =
24
+ payload === undefined
25
+ ? { method: "GET" }
26
+ : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) };
27
+ const res = await apiFetch(`${BASE}/${route}`, init);
28
+ const json = (await res.json().catch(() => ({}))) as {
29
+ data?: T;
30
+ error?: { message?: string; details?: { errors?: Record<string, string> } };
31
+ };
32
+ if (!res.ok) {
33
+ throw new ApiError(json.error?.message ?? `Request failed (${res.status})`, json.error?.details?.errors ?? {});
34
+ }
35
+ return json.data as T;
36
+ }
37
+
38
+ export const api = {
39
+ list: () => call<ListResponse>("snippets/list"),
40
+ get: (id: string) => call<{ snippet: Snippet }>("snippets/get", { id }),
41
+ save: (draft: EditableSnippet & { id?: string }) => call<{ snippet: Snippet }>("snippets/save", draft),
42
+ toggle: (id: string, enabled: boolean) => call<{ snippet: Snippet }>("snippets/toggle", { id, enabled }),
43
+ duplicate: (id: string) => call<{ snippet: Snippet }>("snippets/duplicate", { id }),
44
+ remove: (id: string) => call<{ deleted: true }>("snippets/delete", { id }),
45
+ setKillSwitch: (disabled: boolean) => call<{ disabled: boolean }>("killswitch/set", { disabled }),
46
+ changelog: () => call<{ entries: ChangeLogEntry[] }>("changelog/list"),
47
+ };
@@ -0,0 +1,51 @@
1
+ import { validatePattern } from "../core/paths.js";
2
+
3
+ /** Pure add/validate logic behind ChipInput (unit-tested in node). */
4
+
5
+ export type ChipKind = "path" | "locale";
6
+
7
+ const WHITESPACE = /\s/;
8
+ const SEPARATORS = /[,\n]/;
9
+
10
+ /** Returns an error message, or null when `value` (already trimmed) is a valid chip. */
11
+ export function validateChip(value: string, kind: ChipKind): string | null {
12
+ if (kind === "path") return validatePattern(value);
13
+ if (value === "") return "Locale must not be empty";
14
+ if (WHITESPACE.test(value)) return "Locale must not contain spaces";
15
+ return null;
16
+ }
17
+
18
+ export interface AddChipsResult {
19
+ /** The new chip list (unchanged when nothing valid was entered). */
20
+ chips: string[];
21
+ /**
22
+ * Text to leave in the input: the first invalid value only (so it never contains a separator
23
+ * that would re-trigger a commit), or "" when everything was added.
24
+ */
25
+ rest: string;
26
+ /** Reason the first invalid part was rejected (naming any other rejected parts), or null. */
27
+ error: string | null;
28
+ }
29
+
30
+ /**
31
+ * Add the comma/newline-separated values in `raw` to `chips`. Valid values are trimmed and
32
+ * added once (no duplicates); the first invalid one stays in the input with its error.
33
+ */
34
+ export function addChips(chips: readonly string[], raw: string, kind: ChipKind): AddChipsResult {
35
+ const next = [...chips];
36
+ const rejected: string[] = [];
37
+ let error: string | null = null;
38
+ for (const part of raw.split(SEPARATORS)) {
39
+ const value = part.trim();
40
+ if (value === "") continue;
41
+ const err = validateChip(value, kind);
42
+ if (err) {
43
+ rejected.push(value);
44
+ error ??= err;
45
+ continue;
46
+ }
47
+ if (!next.includes(value)) next.push(value);
48
+ }
49
+ if (error && rejected.length > 1) error = `${error} (also rejected: ${rejected.slice(1).join(", ")})`;
50
+ return { chips: next, rest: rejected[0] ?? "", error };
51
+ }