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.
- package/LICENSE +21 -0
- package/README.md +217 -0
- package/dist/index.d.mts +14 -0
- package/dist/index.mjs +22 -0
- package/dist/plugin.d.mts +17 -0
- package/dist/plugin.mjs +608 -0
- package/dist/types-CNDv5vQX.d.mts +46 -0
- package/dist/version-iEzwHCKh.mjs +6 -0
- package/package.json +46 -0
- package/src/admin/ChangeLog.tsx +84 -0
- package/src/admin/ChipInput.tsx +154 -0
- package/src/admin/CodeEditor.tsx +135 -0
- package/src/admin/SnippetForm.tsx +458 -0
- package/src/admin/SnippetList.tsx +282 -0
- package/src/admin/SnippetsPage.tsx +285 -0
- package/src/admin/api.ts +47 -0
- package/src/admin/chips.ts +51 -0
- package/src/admin/format.ts +96 -0
- package/src/admin/icons.tsx +91 -0
- package/src/admin/indent.ts +56 -0
- package/src/admin/index.tsx +6 -0
- package/src/admin/styles.tsx +93 -0
- package/src/core/fields.ts +117 -0
- package/src/core/match.ts +54 -0
- package/src/core/paths.ts +48 -0
- package/src/core/pipeline.ts +53 -0
- package/src/core/types.ts +79 -0
- package/src/core/validate.ts +139 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Badge, Banner, Empty, Link, Loader, Table } from "@cloudflare/kumo";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import type { ChangeLogEntry } from "../core/types.js";
|
|
4
|
+
import { api } from "./api.js";
|
|
5
|
+
import { ACTION_META, formatTimestamp, relativeTime, userLabel } from "./format.js";
|
|
6
|
+
|
|
7
|
+
export function ChangeLog({ snippetIds, onOpen }: { snippetIds: ReadonlySet<string>; onOpen: (id: string) => void }) {
|
|
8
|
+
const [entries, setEntries] = React.useState<ChangeLogEntry[] | null>(null);
|
|
9
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
10
|
+
const [now] = React.useState(() => Date.now());
|
|
11
|
+
|
|
12
|
+
React.useEffect(() => {
|
|
13
|
+
api.changelog().then(
|
|
14
|
+
(r) => setEntries(r.entries),
|
|
15
|
+
(e: unknown) => setError(e instanceof Error ? e.message : String(e)),
|
|
16
|
+
);
|
|
17
|
+
}, []);
|
|
18
|
+
|
|
19
|
+
if (error) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="p-4">
|
|
22
|
+
<Banner variant="error" title="Could not load the change log" description={error} />
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
if (!entries) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="flex justify-center py-10">
|
|
29
|
+
<Loader />
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (entries.length === 0) {
|
|
34
|
+
return <Empty title="No changes yet" description="Creating, editing or deleting snippets is recorded here." />;
|
|
35
|
+
}
|
|
36
|
+
return (
|
|
37
|
+
<div className="overflow-x-auto">
|
|
38
|
+
<Table>
|
|
39
|
+
<Table.Header>
|
|
40
|
+
<Table.Row>
|
|
41
|
+
<Table.Head>When</Table.Head>
|
|
42
|
+
<Table.Head>Who</Table.Head>
|
|
43
|
+
<Table.Head>Action</Table.Head>
|
|
44
|
+
<Table.Head>Snippet</Table.Head>
|
|
45
|
+
</Table.Row>
|
|
46
|
+
</Table.Header>
|
|
47
|
+
<Table.Body>
|
|
48
|
+
{entries.map((e) => {
|
|
49
|
+
const meta = ACTION_META[e.action];
|
|
50
|
+
const canOpen = e.snippetId !== null && snippetIds.has(e.snippetId);
|
|
51
|
+
return (
|
|
52
|
+
<Table.Row key={e.id} data-testid="hfc-changelog-row">
|
|
53
|
+
<Table.Cell className="whitespace-nowrap text-sm">
|
|
54
|
+
<time dateTime={e.at} title={formatTimestamp(e.at)}>
|
|
55
|
+
{relativeTime(e.at, now)}
|
|
56
|
+
</time>
|
|
57
|
+
</Table.Cell>
|
|
58
|
+
<Table.Cell className="text-sm">{userLabel(e.user)}</Table.Cell>
|
|
59
|
+
<Table.Cell>
|
|
60
|
+
<Badge variant={meta?.variant ?? "secondary"}>{meta?.label ?? e.action}</Badge>
|
|
61
|
+
</Table.Cell>
|
|
62
|
+
<Table.Cell className="text-sm">
|
|
63
|
+
{e.snippetName === null ? (
|
|
64
|
+
<span className="text-kumo-subtle">—</span>
|
|
65
|
+
) : canOpen ? (
|
|
66
|
+
<Link
|
|
67
|
+
variant="plain"
|
|
68
|
+
render={<button type="button" className="cursor-pointer text-start" />}
|
|
69
|
+
onClick={() => onOpen(e.snippetId!)}
|
|
70
|
+
>
|
|
71
|
+
{e.snippetName}
|
|
72
|
+
</Link>
|
|
73
|
+
) : (
|
|
74
|
+
<span>{e.snippetName}</span>
|
|
75
|
+
)}
|
|
76
|
+
</Table.Cell>
|
|
77
|
+
</Table.Row>
|
|
78
|
+
);
|
|
79
|
+
})}
|
|
80
|
+
</Table.Body>
|
|
81
|
+
</Table>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { cn } from "@cloudflare/kumo";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { type ChipKind, addChips, validateChip } from "./chips.js";
|
|
4
|
+
import { XIcon } from "./icons.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tag-style list input: type and press Enter (or a comma) to add, Backspace on an empty input
|
|
8
|
+
* removes the last chip, × removes one. Invalid values stay in the input and the reason is
|
|
9
|
+
* reported through `onErrorChange` (the form shows it under the field).
|
|
10
|
+
*/
|
|
11
|
+
export function ChipInput({
|
|
12
|
+
id,
|
|
13
|
+
kind,
|
|
14
|
+
value,
|
|
15
|
+
onChange,
|
|
16
|
+
onErrorChange,
|
|
17
|
+
onPendingChange,
|
|
18
|
+
invalid,
|
|
19
|
+
describedBy,
|
|
20
|
+
label,
|
|
21
|
+
placeholder,
|
|
22
|
+
}: {
|
|
23
|
+
id: string;
|
|
24
|
+
kind: ChipKind;
|
|
25
|
+
value: string[];
|
|
26
|
+
onChange: (value: string[]) => void;
|
|
27
|
+
onErrorChange: (error: string | null) => void;
|
|
28
|
+
/** Reports whether the input holds text not yet added as a chip (counts as unsaved). */
|
|
29
|
+
onPendingChange?: (pending: boolean) => void;
|
|
30
|
+
invalid?: boolean;
|
|
31
|
+
describedBy?: string;
|
|
32
|
+
label: string;
|
|
33
|
+
placeholder?: string;
|
|
34
|
+
}) {
|
|
35
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
36
|
+
const [text, setText] = React.useState("");
|
|
37
|
+
// After a rejected add, re-validate as the user edits so the message clears once fixed.
|
|
38
|
+
const [liveCheck, setLiveCheck] = React.useState(false);
|
|
39
|
+
// IME composition: Enter/comma confirm the composed text, they must not add a chip.
|
|
40
|
+
const composing = React.useRef(false);
|
|
41
|
+
|
|
42
|
+
const pending = text.trim() !== "";
|
|
43
|
+
const onPendingRef = React.useRef(onPendingChange);
|
|
44
|
+
onPendingRef.current = onPendingChange;
|
|
45
|
+
React.useEffect(() => {
|
|
46
|
+
onPendingRef.current?.(pending);
|
|
47
|
+
}, [pending]);
|
|
48
|
+
|
|
49
|
+
function commit(raw: string) {
|
|
50
|
+
const r = addChips(value, raw, kind);
|
|
51
|
+
if (r.chips.length !== value.length) onChange(r.chips);
|
|
52
|
+
setText(r.rest);
|
|
53
|
+
setLiveCheck(r.error !== null);
|
|
54
|
+
onErrorChange(r.error);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function onTextChange(next: string) {
|
|
58
|
+
if (!composing.current && /[,\n]/.test(next)) {
|
|
59
|
+
commit(next);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
setText(next);
|
|
63
|
+
if (liveCheck) {
|
|
64
|
+
const t = next.trim();
|
|
65
|
+
const err = t === "" ? null : validateChip(t, kind);
|
|
66
|
+
onErrorChange(err);
|
|
67
|
+
if (err === null) setLiveCheck(false);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function remove(index: number) {
|
|
72
|
+
onChange(value.filter((_, i) => i !== index));
|
|
73
|
+
inputRef.current?.focus();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
|
77
|
+
if (e.nativeEvent.isComposing || composing.current) return;
|
|
78
|
+
if (e.key === "Enter") {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
commit(text);
|
|
81
|
+
} else if (e.key === "Backspace" && text === "" && value.length > 0) {
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
remove(value.length - 1);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: clicking the padding focuses the real input
|
|
89
|
+
<div
|
|
90
|
+
className={cn(
|
|
91
|
+
"flex min-h-9 w-full cursor-text flex-wrap items-center gap-1.5 rounded-lg bg-kumo-base px-2 py-1.5 ring ring-kumo-line focus-within:ring-kumo-focus/50",
|
|
92
|
+
invalid && "!ring-kumo-danger",
|
|
93
|
+
)}
|
|
94
|
+
onClick={(e) => {
|
|
95
|
+
if (e.target === e.currentTarget) inputRef.current?.focus();
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
{value.map((chip, i) => (
|
|
99
|
+
<span
|
|
100
|
+
key={chip}
|
|
101
|
+
data-testid={`${id}-chip`}
|
|
102
|
+
className="inline-flex max-w-full items-center gap-1 rounded-md bg-kumo-tint py-0.5 ps-2 pr-1 font-mono text-sm text-kumo-default"
|
|
103
|
+
>
|
|
104
|
+
<span className="truncate">{chip}</span>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
className="inline-flex cursor-pointer items-center rounded p-0.5 text-kumo-subtle hover:bg-kumo-fill hover:text-kumo-default"
|
|
108
|
+
aria-label={`Remove ${chip}`}
|
|
109
|
+
onClick={() => remove(i)}
|
|
110
|
+
>
|
|
111
|
+
<XIcon size={12} />
|
|
112
|
+
</button>
|
|
113
|
+
</span>
|
|
114
|
+
))}
|
|
115
|
+
<input
|
|
116
|
+
ref={inputRef}
|
|
117
|
+
id={id}
|
|
118
|
+
data-testid={id}
|
|
119
|
+
aria-label={label}
|
|
120
|
+
aria-invalid={invalid || undefined}
|
|
121
|
+
aria-describedby={describedBy}
|
|
122
|
+
className="min-w-32 flex-1 bg-transparent px-1 py-0.5 text-sm text-kumo-default outline-none"
|
|
123
|
+
value={text}
|
|
124
|
+
placeholder={value.length === 0 ? placeholder : undefined}
|
|
125
|
+
onChange={(e) => onTextChange(e.target.value)}
|
|
126
|
+
onKeyDown={onKeyDown}
|
|
127
|
+
onCompositionStart={() => {
|
|
128
|
+
composing.current = true;
|
|
129
|
+
}}
|
|
130
|
+
onCompositionEnd={(e) => {
|
|
131
|
+
composing.current = false;
|
|
132
|
+
onTextChange(e.currentTarget.value);
|
|
133
|
+
}}
|
|
134
|
+
onPaste={(e) => {
|
|
135
|
+
// A single-line <input> would strip pasted newlines ("/a\n/b" -> "/a/b"), so split
|
|
136
|
+
// multi-value pastes here and add each value.
|
|
137
|
+
const pasted = e.clipboardData.getData("text");
|
|
138
|
+
if (!/[,\r\n]/.test(pasted)) return;
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
const el = e.currentTarget;
|
|
141
|
+
const start = el.selectionStart ?? text.length;
|
|
142
|
+
const end = el.selectionEnd ?? text.length;
|
|
143
|
+
commit(`${text.slice(0, start)}${pasted.replace(/\r/g, "")}${text.slice(end)}`);
|
|
144
|
+
}}
|
|
145
|
+
onBlur={() => {
|
|
146
|
+
if (text.trim() !== "") commit(text);
|
|
147
|
+
}}
|
|
148
|
+
spellCheck={false}
|
|
149
|
+
autoCapitalize="off"
|
|
150
|
+
autoComplete="off"
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { cn } from "@cloudflare/kumo";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { indentText } from "./indent.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Code textarea with a line-number gutter and Tab/Shift+Tab indenting. No editor dependency:
|
|
7
|
+
* the gutter is a <pre> kept in step with the textarea's scroll position. Press Esc, then Tab,
|
|
8
|
+
* to move focus out of the editor with the keyboard.
|
|
9
|
+
*/
|
|
10
|
+
export function CodeEditor({
|
|
11
|
+
id,
|
|
12
|
+
value,
|
|
13
|
+
onChange,
|
|
14
|
+
invalid,
|
|
15
|
+
describedBy,
|
|
16
|
+
label,
|
|
17
|
+
}: {
|
|
18
|
+
id: string;
|
|
19
|
+
value: string;
|
|
20
|
+
onChange: (value: string) => void;
|
|
21
|
+
invalid?: boolean;
|
|
22
|
+
describedBy?: string;
|
|
23
|
+
label: string;
|
|
24
|
+
}) {
|
|
25
|
+
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
|
26
|
+
const gutterRef = React.useRef<HTMLPreElement>(null);
|
|
27
|
+
const tabEscape = React.useRef(false);
|
|
28
|
+
const pendingSelection = React.useRef<[number, number] | null>(null);
|
|
29
|
+
|
|
30
|
+
const lineCount = React.useMemo(() => {
|
|
31
|
+
let n = 1;
|
|
32
|
+
for (let i = value.indexOf("\n"); i !== -1; i = value.indexOf("\n", i + 1)) n++;
|
|
33
|
+
return n;
|
|
34
|
+
}, [value]);
|
|
35
|
+
const numbers = React.useMemo(
|
|
36
|
+
() => Array.from({ length: lineCount }, (_, i) => String(i + 1)).join("\n"),
|
|
37
|
+
[lineCount],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const syncScroll = React.useCallback(() => {
|
|
41
|
+
if (gutterRef.current && textareaRef.current) gutterRef.current.scrollTop = textareaRef.current.scrollTop;
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
// Restore the selection after a fallback (non-execCommand) indent re-render, and keep the
|
|
45
|
+
// gutter aligned when the content height changes.
|
|
46
|
+
React.useLayoutEffect(() => {
|
|
47
|
+
const ta = textareaRef.current;
|
|
48
|
+
if (ta && pendingSelection.current) {
|
|
49
|
+
ta.setSelectionRange(...pendingSelection.current);
|
|
50
|
+
pendingSelection.current = null;
|
|
51
|
+
}
|
|
52
|
+
syncScroll();
|
|
53
|
+
}, [value, syncScroll]);
|
|
54
|
+
|
|
55
|
+
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
|
56
|
+
// Modifier-only presses (e.g. Shift before Shift+Tab) must not cancel a pending Esc.
|
|
57
|
+
if (e.key === "Shift" || e.key === "Control" || e.key === "Alt" || e.key === "Meta") return;
|
|
58
|
+
if (e.key === "Escape") {
|
|
59
|
+
tabEscape.current = true;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (e.key !== "Tab" || e.altKey || e.ctrlKey || e.metaKey) {
|
|
63
|
+
tabEscape.current = false;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (tabEscape.current) {
|
|
67
|
+
tabEscape.current = false;
|
|
68
|
+
return; // let focus move on
|
|
69
|
+
}
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
const ta = e.currentTarget;
|
|
72
|
+
const old = ta.value;
|
|
73
|
+
const edit = indentText(old, ta.selectionStart, ta.selectionEnd, e.shiftKey);
|
|
74
|
+
if (edit.value === old) return;
|
|
75
|
+
|
|
76
|
+
// Replace only the changed span so the browser's undo history keeps working.
|
|
77
|
+
let a = 0;
|
|
78
|
+
while (a < old.length && a < edit.value.length && old[a] === edit.value[a]) a++;
|
|
79
|
+
let b = 0;
|
|
80
|
+
while (
|
|
81
|
+
b < old.length - a &&
|
|
82
|
+
b < edit.value.length - a &&
|
|
83
|
+
old[old.length - 1 - b] === edit.value[edit.value.length - 1 - b]
|
|
84
|
+
)
|
|
85
|
+
b++;
|
|
86
|
+
const inserted = edit.value.slice(a, edit.value.length - b);
|
|
87
|
+
ta.setSelectionRange(a, old.length - b);
|
|
88
|
+
const ok =
|
|
89
|
+
typeof document.execCommand === "function" &&
|
|
90
|
+
document.execCommand(inserted === "" ? "delete" : "insertText", false, inserted);
|
|
91
|
+
if (ok && ta.value === edit.value) {
|
|
92
|
+
ta.setSelectionRange(edit.start, edit.end);
|
|
93
|
+
} else {
|
|
94
|
+
pendingSelection.current = [edit.start, edit.end];
|
|
95
|
+
onChange(edit.value);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
className={cn(
|
|
102
|
+
"flex overflow-hidden rounded-lg bg-kumo-base ring ring-kumo-line focus-within:ring-kumo-focus/50",
|
|
103
|
+
invalid && "!ring-kumo-danger",
|
|
104
|
+
)}
|
|
105
|
+
>
|
|
106
|
+
<pre
|
|
107
|
+
ref={gutterRef}
|
|
108
|
+
aria-hidden="true"
|
|
109
|
+
className="hfc-code-text m-0 shrink-0 select-none overflow-hidden border-e border-kumo-line bg-kumo-elevated px-3 py-3 text-end font-mono text-kumo-subtle"
|
|
110
|
+
>
|
|
111
|
+
{numbers}
|
|
112
|
+
{"\n\n\n"}
|
|
113
|
+
</pre>
|
|
114
|
+
<textarea
|
|
115
|
+
ref={textareaRef}
|
|
116
|
+
id={id}
|
|
117
|
+
data-testid={id}
|
|
118
|
+
aria-label={label}
|
|
119
|
+
aria-invalid={invalid || undefined}
|
|
120
|
+
aria-describedby={describedBy}
|
|
121
|
+
value={value}
|
|
122
|
+
onChange={(e) => onChange(e.target.value)}
|
|
123
|
+
onKeyDown={onKeyDown}
|
|
124
|
+
onScroll={syncScroll}
|
|
125
|
+
spellCheck={false}
|
|
126
|
+
autoCapitalize="off"
|
|
127
|
+
autoComplete="off"
|
|
128
|
+
autoCorrect="off"
|
|
129
|
+
wrap="off"
|
|
130
|
+
placeholder={"<script>\n // your code\n</script>"}
|
|
131
|
+
className="hfc-code-text hfc-code-area min-w-0 flex-1 resize-y bg-transparent px-3 py-3 font-mono text-kumo-default outline-none"
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|