@backmatter/studio 0.0.16 → 0.0.18
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/dist/chunks/{UiKitApp-_EDqWID3.js → UiKitApp-DkuQBiQZ.js} +2 -2
- package/dist/chunks/{WorkspaceRoutes-B2PrFgpz.js → WorkspaceRoutes-DVvb3N-V.js} +27489 -26622
- package/dist/chunks/{dashboard-BnlnMnnr.js → dashboard-B4FITg6c.js} +2 -2
- package/dist/chunks/{define-studio-BYtYDGLX.js → define-studio-DlYjgrwr.js} +12 -16
- package/dist/chunks/{index-D_BHF_8q.js → index-CegtXnEI.js} +1 -1
- package/dist/dashboard.js +2 -2
- package/dist/define.js +1 -1
- package/dist/index.js +4 -4
- package/dist/site-widget.css +45 -1
- package/dist/site-widget.js +2799 -2791
- package/dist/studio.css +113 -15
- package/package.json +2 -2
- package/src/api.ts +22 -0
- package/src/chat/ChatProvider.tsx +6 -0
- package/src/chat/Composer.tsx +3 -1
- package/src/chrome/Toast.tsx +115 -0
- package/src/chrome/WorkspaceStatusBar.tsx +12 -3
- package/src/chrome/commit-error.ts +13 -0
- package/src/chrome/pull-failure.ts +61 -0
- package/src/{pages/code-session-routes.ts → content-routes.ts} +6 -6
- package/src/fields/FieldControl.tsx +771 -0
- package/src/fields/LinkFieldInput.tsx +315 -0
- package/src/fields/choose-field-input.ts +69 -0
- package/src/fields/link-targets.ts +60 -0
- package/src/fields/load-relation-documents.ts +35 -0
- package/src/fields/omit-field.ts +12 -0
- package/src/fields/relation-targets.ts +44 -0
- package/src/fields/resolve-object-fields.ts +21 -0
- package/src/host-path.ts +13 -0
- package/src/pages/BlockSettingsDialog.tsx +19 -115
- package/src/pages/BlocksList.tsx +43 -35
- package/src/pages/BodyProse.tsx +16 -1
- package/src/pages/CodeSessionPage.tsx +186 -248
- package/src/pages/DocEditorPage.tsx +168 -213
- package/src/pages/MediaFieldInput.tsx +609 -26
- package/src/pages/MediaGalleryPage.tsx +14 -3
- package/src/pages/PreviewAddress.tsx +263 -0
- package/src/pages/RenderedBody.tsx +17 -2
- package/src/pages/branch-notice.ts +16 -0
- package/src/pages/code-session-boot.ts +0 -12
- package/src/pages/code-session-context.tsx +2 -0
- package/src/pages/media-selection.ts +79 -0
- package/src/shell-resets.css +15 -0
- package/src/ui/Input.tsx +178 -20
- package/src/ui/popup.ts +11 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backmatter/studio",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.18",
|
|
4
4
|
"description": "The Backmatter editing surface: document editor, AI chat, publish. Mounted on your site or hosted on the dashboard.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"@ai-sdk/react": "^4.0.100",
|
|
59
|
-
"@backmatter/core": "^0.0.
|
|
59
|
+
"@backmatter/core": "^0.0.35",
|
|
60
60
|
"@base-ui/react": "^1.6.0",
|
|
61
61
|
"@central-icons-react/round-outlined-radius-2-stroke-1.5": "1.2.15",
|
|
62
62
|
"@dnd-kit/abstract": "^0.5.0",
|
package/src/api.ts
CHANGED
|
@@ -350,12 +350,20 @@ export function unbindGithub(workspaceId: string) {
|
|
|
350
350
|
|
|
351
351
|
export type SchemaField = {
|
|
352
352
|
kind: string;
|
|
353
|
+
multiple?: boolean;
|
|
353
354
|
required?: boolean;
|
|
354
355
|
relationTo?: string;
|
|
356
|
+
to?: "internal" | "external";
|
|
357
|
+
models?: string[];
|
|
355
358
|
object?: string;
|
|
356
359
|
fields?: Record<string, SchemaField>;
|
|
357
360
|
/** Studio input name from `field.text({ input })`; see `defineStudio`. */
|
|
358
361
|
input?: string;
|
|
362
|
+
options?: string[];
|
|
363
|
+
min?: number;
|
|
364
|
+
max?: number;
|
|
365
|
+
step?: number;
|
|
366
|
+
where?: Record<string, string | number | boolean>;
|
|
359
367
|
};
|
|
360
368
|
|
|
361
369
|
export type BlockSnapshot = {
|
|
@@ -496,6 +504,20 @@ export function fetchCodeSessionEdits(workspaceId: string, sessionId: string) {
|
|
|
496
504
|
);
|
|
497
505
|
}
|
|
498
506
|
|
|
507
|
+
export function updateCodeSessionBranch(workspaceId: string, sessionId: string) {
|
|
508
|
+
return api<{ ok: true; baseSha: string }>(
|
|
509
|
+
`/workspaces/${workspaceId}/code-sessions/${sessionId}/update`,
|
|
510
|
+
{ method: "POST" },
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function clearCodeSessionEdits(workspaceId: string, sessionId: string) {
|
|
515
|
+
return api<{ ok: true; baseSha: string }>(
|
|
516
|
+
`/workspaces/${workspaceId}/code-sessions/${sessionId}/clear-edits`,
|
|
517
|
+
{ method: "POST" },
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
499
521
|
export function commitCodeSessionEdits(
|
|
500
522
|
workspaceId: string,
|
|
501
523
|
sessionId: string,
|
|
@@ -163,6 +163,12 @@ export function useChatSession(): ChatSessionValue {
|
|
|
163
163
|
return ctx;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/** False outside a chat provider. True while a turn is submitted or streaming. */
|
|
167
|
+
export function useChatBusy(): boolean {
|
|
168
|
+
const ctx = useContext(ChatSessionContext);
|
|
169
|
+
return ctx?.status === "streaming" || ctx?.status === "submitted";
|
|
170
|
+
}
|
|
171
|
+
|
|
166
172
|
// ---------------------------------------------------------------------------
|
|
167
173
|
|
|
168
174
|
const modelStorageKey = (ws: string) => `backmatter-chat-model:${ws}`;
|
package/src/chat/Composer.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
|
|
|
3
3
|
import * as stylex from "@stylexjs/stylex";
|
|
4
4
|
import { docKey, type DocRef } from "../chrome/EditorFocusContext";
|
|
5
5
|
import { colors, radius, space, type } from "../styles/tokens.stylex";
|
|
6
|
+
import { useCodeSessionContext } from "../pages/code-session-context";
|
|
6
7
|
import { Tooltip } from "../ui/Tooltip";
|
|
7
8
|
import { useChatSession, useChatUI } from "./ChatProvider";
|
|
8
9
|
import { MentionPopover, type MentionKey } from "./MentionPopover";
|
|
@@ -64,7 +65,8 @@ export function Composer({ autoFocus }: { autoFocus: boolean }) {
|
|
|
64
65
|
const ref = useRef<HTMLTextAreaElement>(null);
|
|
65
66
|
const boxRef = useRef<HTMLDivElement>(null);
|
|
66
67
|
const mentionKeys = useRef<((key: MentionKey) => void) | null>(null);
|
|
67
|
-
const
|
|
68
|
+
const updating = useCodeSessionContext()?.updating === true;
|
|
69
|
+
const busy = session.status === "streaming" || session.status === "submitted" || updating;
|
|
68
70
|
const canSend = text.trim().length > 0 && !busy && ui.aiAvailable === true;
|
|
69
71
|
|
|
70
72
|
// Auto-grow. Measuring must not leave a 1px swing: that resizes the thread
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// packages/studio/src/chrome/Toast.tsx
|
|
2
|
+
import * as stylex from "@stylexjs/stylex";
|
|
3
|
+
import { colors, radius, space, type, z } from "../styles/tokens.stylex";
|
|
4
|
+
|
|
5
|
+
const hover = "@media (hover: hover)";
|
|
6
|
+
|
|
7
|
+
export type ToastProblem = {
|
|
8
|
+
file: string;
|
|
9
|
+
text: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** A dismissible notice that sits over the page, not in the status bar. */
|
|
13
|
+
export function Toast({
|
|
14
|
+
title,
|
|
15
|
+
problems,
|
|
16
|
+
onDismiss,
|
|
17
|
+
}: {
|
|
18
|
+
title: string;
|
|
19
|
+
problems: ToastProblem[];
|
|
20
|
+
onDismiss: () => void;
|
|
21
|
+
}) {
|
|
22
|
+
return (
|
|
23
|
+
<div role="alert" {...stylex.props(styles.toast)}>
|
|
24
|
+
<div {...stylex.props(styles.head)}>
|
|
25
|
+
<p {...stylex.props(styles.title)}>{title}</p>
|
|
26
|
+
<button type="button" onClick={onDismiss} {...stylex.props(styles.dismiss)}>
|
|
27
|
+
Dismiss
|
|
28
|
+
</button>
|
|
29
|
+
</div>
|
|
30
|
+
<ul {...stylex.props(styles.list)}>
|
|
31
|
+
{problems.map((problem, index) => (
|
|
32
|
+
<li key={`${problem.file}-${index}`} {...stylex.props(styles.item)}>
|
|
33
|
+
{problem.file ? <span {...stylex.props(styles.file)}>{problem.file}</span> : null}
|
|
34
|
+
<span {...stylex.props(styles.text)}>{problem.text}</span>
|
|
35
|
+
</li>
|
|
36
|
+
))}
|
|
37
|
+
</ul>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const styles = stylex.create({
|
|
43
|
+
toast: {
|
|
44
|
+
position: "fixed",
|
|
45
|
+
zIndex: z.tooltip,
|
|
46
|
+
top: "44px",
|
|
47
|
+
right: space.xl,
|
|
48
|
+
boxSizing: "border-box",
|
|
49
|
+
display: "flex",
|
|
50
|
+
flexDirection: "column",
|
|
51
|
+
gap: space.md,
|
|
52
|
+
width: "min(380px, calc(100vw - 32px))",
|
|
53
|
+
maxHeight: "min(420px, calc(100vh - 64px))",
|
|
54
|
+
padding: space.lg,
|
|
55
|
+
borderWidth: 1,
|
|
56
|
+
borderStyle: "solid",
|
|
57
|
+
borderColor: colors.border,
|
|
58
|
+
borderRadius: radius.md,
|
|
59
|
+
backgroundColor: colors.bgSurface,
|
|
60
|
+
boxShadow: "0 8px 24px rgba(24, 24, 27, 0.12)",
|
|
61
|
+
color: colors.text,
|
|
62
|
+
},
|
|
63
|
+
head: {
|
|
64
|
+
display: "flex",
|
|
65
|
+
alignItems: "center",
|
|
66
|
+
justifyContent: "space-between",
|
|
67
|
+
gap: space.lg,
|
|
68
|
+
},
|
|
69
|
+
title: {
|
|
70
|
+
margin: 0,
|
|
71
|
+
fontSize: type.sm,
|
|
72
|
+
fontWeight: 600,
|
|
73
|
+
color: colors.danger,
|
|
74
|
+
},
|
|
75
|
+
list: {
|
|
76
|
+
margin: 0,
|
|
77
|
+
padding: 0,
|
|
78
|
+
overflow: "auto",
|
|
79
|
+
listStyle: "none",
|
|
80
|
+
display: "flex",
|
|
81
|
+
flexDirection: "column",
|
|
82
|
+
gap: space.md,
|
|
83
|
+
},
|
|
84
|
+
item: {
|
|
85
|
+
display: "flex",
|
|
86
|
+
flexDirection: "column",
|
|
87
|
+
gap: "2px",
|
|
88
|
+
minWidth: 0,
|
|
89
|
+
},
|
|
90
|
+
file: {
|
|
91
|
+
fontFamily: type.mono,
|
|
92
|
+
fontSize: type.xs,
|
|
93
|
+
color: colors.text,
|
|
94
|
+
},
|
|
95
|
+
text: {
|
|
96
|
+
fontSize: type.sm,
|
|
97
|
+
lineHeight: 1.45,
|
|
98
|
+
color: colors.textMuted,
|
|
99
|
+
},
|
|
100
|
+
dismiss: {
|
|
101
|
+
flexShrink: 0,
|
|
102
|
+
margin: 0,
|
|
103
|
+
padding: 0,
|
|
104
|
+
borderWidth: 0,
|
|
105
|
+
backgroundColor: "transparent",
|
|
106
|
+
color: colors.textMuted,
|
|
107
|
+
fontSize: type.xs,
|
|
108
|
+
cursor: "pointer",
|
|
109
|
+
[hover]: {
|
|
110
|
+
":hover": {
|
|
111
|
+
color: colors.text,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
});
|
|
@@ -26,7 +26,6 @@ import {
|
|
|
26
26
|
import { contentDocPath, isWorkspaceCodePath } from "../workspace-path";
|
|
27
27
|
import { useCodeSessionContext } from "../pages/code-session-context";
|
|
28
28
|
import {
|
|
29
|
-
commitErrorSummary,
|
|
30
29
|
explainCommitError,
|
|
31
30
|
problemsForRef,
|
|
32
31
|
type CommitErrorView,
|
|
@@ -40,7 +39,9 @@ import { useEditorStatus } from "./EditorStatusContext";
|
|
|
40
39
|
import { openableSites } from "./open-site";
|
|
41
40
|
import { OpenSiteButton, showsOpenSite } from "./OpenSiteButton";
|
|
42
41
|
import { GitBranchIcon } from "./icons";
|
|
42
|
+
import { pullFailureProblems, type PullProblem } from "./pull-failure";
|
|
43
43
|
import { StatusBar, StatusItem, StatusSpacer } from "./StatusBar";
|
|
44
|
+
import { Toast } from "./Toast";
|
|
44
45
|
import { replicaStore } from "../replica/instance";
|
|
45
46
|
import { useReplicaSession } from "../replica/ReplicaProvider";
|
|
46
47
|
import { useReplicaState } from "../replica/useReplica";
|
|
@@ -172,6 +173,7 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
|
|
|
172
173
|
const [resolvingKey, setResolvingKey] = useState<string | null>(null);
|
|
173
174
|
const [message, setMessage] = useState("");
|
|
174
175
|
const [error, setError] = useState<string | null>(null);
|
|
176
|
+
const [pullProblems, setPullProblems] = useState<PullProblem[] | null>(null);
|
|
175
177
|
const [commitFailure, setCommitFailure] = useState<CommitErrorView | null>(null);
|
|
176
178
|
const onCodePage = isWorkspaceCodePath(location.pathname);
|
|
177
179
|
const codeSession = useCodeSessionContext();
|
|
@@ -306,13 +308,15 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
|
|
|
306
308
|
async function onPull() {
|
|
307
309
|
setPulling(true);
|
|
308
310
|
setError(null);
|
|
311
|
+
setPullProblems(null);
|
|
309
312
|
try {
|
|
310
313
|
await pullFromGithub(workspaceId);
|
|
311
314
|
notifyHeadChanged();
|
|
312
315
|
await reloadGit();
|
|
313
316
|
setOpen(false);
|
|
314
317
|
} catch (err) {
|
|
315
|
-
|
|
318
|
+
setOpen(false);
|
|
319
|
+
setPullProblems(pullFailureProblems(err));
|
|
316
320
|
} finally {
|
|
317
321
|
setPulling(false);
|
|
318
322
|
}
|
|
@@ -436,6 +440,10 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
|
|
|
436
440
|
: "Commit";
|
|
437
441
|
|
|
438
442
|
return (
|
|
443
|
+
<>
|
|
444
|
+
{pullProblems ? (
|
|
445
|
+
<Toast title="Pull failed" problems={pullProblems} onDismiss={() => setPullProblems(null)} />
|
|
446
|
+
) : null}
|
|
439
447
|
<StatusBar>
|
|
440
448
|
<div {...stylex.props(styles.identity)}>
|
|
441
449
|
{workspaceNav ? (
|
|
@@ -595,7 +603,7 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
|
|
|
595
603
|
{error ? (
|
|
596
604
|
<StatusItem tone="danger">{error}</StatusItem>
|
|
597
605
|
) : commitFailure ? (
|
|
598
|
-
<StatusItem tone="danger">
|
|
606
|
+
<StatusItem tone="danger">Can't commit</StatusItem>
|
|
599
607
|
) : refreshError ? (
|
|
600
608
|
<StatusItem tone="danger">{refreshError}</StatusItem>
|
|
601
609
|
) : null}
|
|
@@ -810,6 +818,7 @@ export function WorkspaceStatusBar({ workspaceId }: { workspaceId: string }) {
|
|
|
810
818
|
</Popover.Root>
|
|
811
819
|
) : null}
|
|
812
820
|
</StatusBar>
|
|
821
|
+
</>
|
|
813
822
|
);
|
|
814
823
|
}
|
|
815
824
|
|
|
@@ -27,6 +27,9 @@ export function explainCommitError(err: unknown): CommitErrorView {
|
|
|
27
27
|
if (apiErr.code === "github_not_bound") {
|
|
28
28
|
return { kind: "message", text: "Connect GitHub first" };
|
|
29
29
|
}
|
|
30
|
+
if (apiErr.code === "unknown_type") {
|
|
31
|
+
return { kind: "message", text: unknownTypeMessage(apiErr.details ?? []) };
|
|
32
|
+
}
|
|
30
33
|
if (apiErr.code === "validation") {
|
|
31
34
|
return { kind: "validation", files: groupValidation(validationLines(apiErr)) };
|
|
32
35
|
}
|
|
@@ -52,6 +55,16 @@ export function problemsForRef(
|
|
|
52
55
|
return view.files.find((file) => file.ref === ref)?.problems ?? [];
|
|
53
56
|
}
|
|
54
57
|
|
|
58
|
+
function unknownTypeMessage(types: string[]): string {
|
|
59
|
+
if (types.length === 0) {
|
|
60
|
+
return "A file uses a model this workspace doesn't have, so it can't be committed.";
|
|
61
|
+
}
|
|
62
|
+
if (types.length === 1) {
|
|
63
|
+
return `${types[0]} isn't a model in this workspace. Pull couldn't load that model file, so this draft can't be committed.`;
|
|
64
|
+
}
|
|
65
|
+
return `${types.join(", ")} aren't models in this workspace. Pull couldn't load those model files, so these drafts can't be committed.`;
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
function validationLines(err: ApiError): string[] {
|
|
56
69
|
if (err.details && err.details.length > 0) return err.details;
|
|
57
70
|
const body = err.message.replace(/^validation:\s*/, "");
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// packages/studio/src/chrome/pull-failure.ts
|
|
2
|
+
import type { ApiError } from "../api";
|
|
3
|
+
|
|
4
|
+
export type PullProblem = {
|
|
5
|
+
/** Repo path, when the failure names a file. */
|
|
6
|
+
file: string;
|
|
7
|
+
text: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** What the pull-failed notice says. One problem per file, in words. */
|
|
11
|
+
export function pullFailureProblems(err: unknown): PullProblem[] {
|
|
12
|
+
const lines = rawLines(err);
|
|
13
|
+
if (lines.length === 0) return [{ file: "", text: "Pull failed." }];
|
|
14
|
+
return lines.map(explainPullLine);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function rawLines(err: unknown): string[] {
|
|
18
|
+
const details = (err as ApiError).details;
|
|
19
|
+
if (Array.isArray(details) && details.length > 0 && details.every((line) => typeof line === "string")) {
|
|
20
|
+
return details;
|
|
21
|
+
}
|
|
22
|
+
if (err instanceof Error) {
|
|
23
|
+
const message = err.message.replace(/^github_import_failed:\s*/, "");
|
|
24
|
+
if (message && message !== "github_import_failed") return [message];
|
|
25
|
+
}
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function explainPullLine(line: string): PullProblem {
|
|
30
|
+
const split = line.indexOf(": ");
|
|
31
|
+
const file = split === -1 ? "" : line.slice(0, split);
|
|
32
|
+
const rest = split === -1 ? line : line.slice(split + 2);
|
|
33
|
+
|
|
34
|
+
const renamed = rest.match(/defines name "([^"]+)" but the file is named "([^"]+)"/);
|
|
35
|
+
if (renamed) {
|
|
36
|
+
return {
|
|
37
|
+
file,
|
|
38
|
+
text: `The name is ${renamed[1]}, but the file is ${renamed[2]}. Rename one so they match.`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (/field\.locale/.test(rest)) {
|
|
42
|
+
return {
|
|
43
|
+
file,
|
|
44
|
+
text: "This file calls field.locale(), which was removed, so it didn't load.",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (/import is not allowed/.test(rest)) {
|
|
48
|
+
return {
|
|
49
|
+
file,
|
|
50
|
+
text: "This file imports something other than defineModel, field, or an object, so it didn't load.",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const cleaned = rest
|
|
55
|
+
.replace(/^model "[^"]+" failed to evaluate:\s*/i, "")
|
|
56
|
+
.replace(/^object "[^"]+" failed to evaluate:\s*/i, "")
|
|
57
|
+
.replace(/TypeError:\s*/g, "")
|
|
58
|
+
.replace(/\s*\([^)]*\)/g, "")
|
|
59
|
+
.trim();
|
|
60
|
+
return { file, text: cleaned || "This file didn't load." };
|
|
61
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// packages/studio/src/
|
|
1
|
+
// packages/studio/src/content-routes.ts
|
|
2
2
|
import {
|
|
3
3
|
isRoutable,
|
|
4
4
|
isTemplateSlug,
|
|
@@ -7,15 +7,15 @@ import {
|
|
|
7
7
|
type RouteModel,
|
|
8
8
|
} from "@backmatter/core";
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
type ContentFileModel = RouteModel & {
|
|
11
11
|
path: string;
|
|
12
12
|
external?: boolean;
|
|
13
13
|
template?: string;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
/** Public routes for
|
|
17
|
-
export function
|
|
18
|
-
models: readonly
|
|
16
|
+
/** Public routes for content files. A file that is not a routable document is skipped. */
|
|
17
|
+
export function routesForContentFiles(
|
|
18
|
+
models: readonly ContentFileModel[],
|
|
19
19
|
files: readonly string[],
|
|
20
20
|
): string[] {
|
|
21
21
|
const routes = new Set<string>();
|
|
@@ -35,7 +35,7 @@ export function contentRoutes(
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/** Routes whose path contains the address query. A blank query keeps the full list. */
|
|
38
|
-
export function
|
|
38
|
+
export function routesMatching(routes: readonly string[], query: string): string[] {
|
|
39
39
|
const needle = query.trim().toLowerCase();
|
|
40
40
|
if (!needle) return [...routes];
|
|
41
41
|
return routes.filter((route) => route.toLowerCase().includes(needle));
|