@vespercms/admin 0.0.0-stage → 0.1.0-next.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 +119 -2
- package/dist/BodyEditor-CAGNfofv.js +3047 -0
- package/dist/a11y/FieldNotes.d.ts +23 -0
- package/dist/a11y/NotesDrawer.d.ts +23 -0
- package/dist/a11y/pdf.d.ts +3 -0
- package/dist/a11y/run.d.ts +3 -0
- package/dist/a11y/useNotes.d.ts +28 -0
- package/dist/app/App.d.ts +2 -0
- package/dist/app/Screen.d.ts +88 -0
- package/dist/app/UserMenu.d.ts +7 -0
- package/dist/app/api.d.ts +75 -0
- package/dist/app/appearance.d.ts +10 -0
- package/dist/app/browser-api.d.ts +17 -0
- package/dist/app/collection-icon.d.ts +12 -0
- package/dist/app/context.d.ts +23 -0
- package/dist/app/entries-changed.d.ts +11 -0
- package/dist/app/file-url.d.ts +13 -0
- package/dist/app/format.d.ts +4 -0
- package/dist/app/idb.d.ts +7 -0
- package/dist/app/load.d.ts +16 -0
- package/dist/app/media.d.ts +5 -0
- package/dist/app/navigation.d.ts +4 -0
- package/dist/app/pane-context.d.ts +19 -0
- package/dist/app/panes.d.ts +14 -0
- package/dist/app/router.d.ts +62 -0
- package/dist/body/BodyEditor.d.ts +42 -0
- package/dist/body/TableView.d.ts +2 -0
- package/dist/body/catalog.d.ts +47 -0
- package/dist/body/checks.d.ts +15 -0
- package/dist/body/chips.d.ts +31 -0
- package/dist/body/commands.d.ts +31 -0
- package/dist/body/document.d.ts +42 -0
- package/dist/body/insert.d.ts +69 -0
- package/dist/body/nodes.d.ts +38 -0
- package/dist/body/reveal.d.ts +13 -0
- package/dist/body/scroll.d.ts +16 -0
- package/dist/body/settings.d.ts +19 -0
- package/dist/body/shortcuts.d.ts +13 -0
- package/dist/body/table.d.ts +31 -0
- package/dist/body/ui.d.ts +30 -0
- package/dist/body/views.d.ts +9 -0
- package/dist/checks-C-0xDu-I.js +57 -0
- package/dist/cloud/CloudGate.d.ts +14 -0
- package/dist/cloud/access.d.ts +14 -0
- package/dist/cloud/changes.d.ts +26 -0
- package/dist/cloud/connection.d.ts +100 -0
- package/dist/cloud/editor.d.ts +44 -0
- package/dist/cloud/session.d.ts +62 -0
- package/dist/cloud/storage.d.ts +45 -0
- package/dist/editor/Field.d.ts +24 -0
- package/dist/editor/PagePicker.d.ts +20 -0
- package/dist/editor/arrange.d.ts +13 -0
- package/dist/editor/context.d.ts +23 -0
- package/dist/editor/copy.d.ts +9 -0
- package/dist/editor/reveal.d.ts +7 -0
- package/dist/editor/target.d.ts +21 -0
- package/dist/editor/values.d.ts +18 -0
- package/dist/geo-map-CT3OGD7O.js +110 -0
- package/dist/github/GitHubGate.d.ts +15 -0
- package/dist/github/api.d.ts +20 -0
- package/dist/github/cache.d.ts +2 -0
- package/dist/github/session.d.ts +35 -0
- package/dist/index.d.ts +3 -0
- package/dist/markdown-KUkv11EB.js +211 -0
- package/dist/media/MediaPicker.d.ts +28 -0
- package/dist/media/file-icon.d.ts +3 -0
- package/dist/media/formats.d.ts +23 -0
- package/dist/media/hygiene.d.ts +37 -0
- package/dist/media/upload.d.ts +31 -0
- package/dist/mount.d.ts +3 -0
- package/dist/pdf-m5s_uXJ3.js +24 -0
- package/dist/run-y4XRkg17.js +7 -0
- package/dist/schedule/ScheduleDialog.d.ts +28 -0
- package/dist/schedule/ScheduleSummary.d.ts +12 -0
- package/dist/schedule/ScheduledView.d.ts +8 -0
- package/dist/schedule/cloud.d.ts +21 -0
- package/dist/schedule/now.d.ts +5 -0
- package/dist/schedule/times.d.ts +37 -0
- package/dist/screens/EntryEditor.d.ts +13 -0
- package/dist/screens/EntryList.d.ts +18 -0
- package/dist/screens/Home.d.ts +6 -0
- package/dist/screens/ListPane.d.ts +6 -0
- package/dist/screens/MediaBrowser.d.ts +7 -0
- package/dist/screens/NotFound.d.ts +3 -0
- package/dist/screens/PageTree.d.ts +10 -0
- package/dist/screens/Redirects.d.ts +1 -0
- package/dist/screens/Unavailable.d.ts +8 -0
- package/dist/shortcuts-B_JMAvF9.js +4630 -0
- package/dist/studio.css +3 -0
- package/dist/studio.js +6312 -0
- package/dist/types.d.ts +76 -0
- package/dist/widgets/basic.d.ts +25 -0
- package/dist/widgets/crops.d.ts +43 -0
- package/dist/widgets/focal-point.d.ts +15 -0
- package/dist/widgets/geo-map.d.ts +10 -0
- package/dist/widgets/geo.d.ts +16 -0
- package/dist/widgets/group.d.ts +41 -0
- package/dist/widgets/icon.d.ts +9 -0
- package/dist/widgets/image.d.ts +16 -0
- package/dist/widgets/markdown-lazy.d.ts +2 -0
- package/dist/widgets/markdown.d.ts +2 -0
- package/dist/widgets/reference.d.ts +12 -0
- package/dist/widgets/registry.d.ts +32 -0
- package/dist/widgets/special.d.ts +20 -0
- package/dist/widgets/union.d.ts +36 -0
- package/package.json +63 -4
- package/src/a11y/FieldNotes.tsx +56 -0
- package/src/a11y/NotesDrawer.tsx +81 -0
- package/src/a11y/pdf.ts +45 -0
- package/src/a11y/run.ts +12 -0
- package/src/a11y/useNotes.ts +139 -0
- package/src/app/App.tsx +156 -0
- package/src/app/Screen.tsx +416 -0
- package/src/app/UserMenu.tsx +95 -0
- package/src/app/api.ts +176 -0
- package/src/app/appearance.ts +54 -0
- package/src/app/browser-api.ts +173 -0
- package/src/app/collection-icon.ts +15 -0
- package/src/app/context.tsx +32 -0
- package/src/app/entries-changed.ts +48 -0
- package/src/app/file-url.tsx +44 -0
- package/src/app/format.ts +39 -0
- package/src/app/idb.ts +82 -0
- package/src/app/load.ts +40 -0
- package/src/app/media.ts +20 -0
- package/src/app/navigation.ts +4 -0
- package/src/app/pane-context.ts +25 -0
- package/src/app/panes.tsx +513 -0
- package/src/app/router.ts +158 -0
- package/src/body/BodyEditor.tsx +1139 -0
- package/src/body/TableView.tsx +700 -0
- package/src/body/catalog.ts +198 -0
- package/src/body/checks.ts +83 -0
- package/src/body/chips.ts +150 -0
- package/src/body/commands.ts +191 -0
- package/src/body/document.ts +388 -0
- package/src/body/insert.tsx +301 -0
- package/src/body/nodes.ts +287 -0
- package/src/body/reveal.ts +93 -0
- package/src/body/scroll.ts +53 -0
- package/src/body/settings.tsx +510 -0
- package/src/body/shortcuts.ts +31 -0
- package/src/body/table.ts +192 -0
- package/src/body/ui.ts +77 -0
- package/src/body/views.tsx +268 -0
- package/src/build.ts +6 -0
- package/src/cloud/CloudGate.tsx +245 -0
- package/src/cloud/access.ts +52 -0
- package/src/cloud/changes.tsx +474 -0
- package/src/cloud/connection.ts +281 -0
- package/src/cloud/editor.tsx +250 -0
- package/src/cloud/session.ts +289 -0
- package/src/cloud/storage.ts +212 -0
- package/src/editor/Field.tsx +182 -0
- package/src/editor/PagePicker.tsx +102 -0
- package/src/editor/arrange.ts +98 -0
- package/src/editor/context.tsx +31 -0
- package/src/editor/copy.ts +30 -0
- package/src/editor/reveal.ts +38 -0
- package/src/editor/target.ts +104 -0
- package/src/editor/values.ts +71 -0
- package/src/github/GitHubGate.tsx +261 -0
- package/src/github/api.ts +81 -0
- package/src/github/cache.ts +64 -0
- package/src/github/session.ts +146 -0
- package/src/index.ts +10 -0
- package/src/media/MediaPicker.tsx +302 -0
- package/src/media/file-icon.ts +17 -0
- package/src/media/formats.ts +262 -0
- package/src/media/hygiene.ts +220 -0
- package/src/media/upload.ts +47 -0
- package/src/mount.tsx +20 -0
- package/src/schedule/ScheduleDialog.tsx +300 -0
- package/src/schedule/ScheduleSummary.tsx +59 -0
- package/src/schedule/ScheduledView.tsx +294 -0
- package/src/schedule/cloud.ts +66 -0
- package/src/schedule/now.ts +18 -0
- package/src/schedule/times.ts +133 -0
- package/src/screens/EntryEditor.tsx +1803 -0
- package/src/screens/EntryList.tsx +274 -0
- package/src/screens/Home.tsx +131 -0
- package/src/screens/ListPane.tsx +459 -0
- package/src/screens/MediaBrowser.tsx +253 -0
- package/src/screens/NotFound.tsx +19 -0
- package/src/screens/PageTree.tsx +718 -0
- package/src/screens/Redirects.tsx +522 -0
- package/src/screens/Unavailable.tsx +27 -0
- package/src/studio.css +163 -0
- package/src/types.ts +82 -0
- package/src/widgets/basic.tsx +311 -0
- package/src/widgets/crops.tsx +399 -0
- package/src/widgets/focal-point.tsx +240 -0
- package/src/widgets/geo-map.tsx +160 -0
- package/src/widgets/geo.tsx +170 -0
- package/src/widgets/group.tsx +827 -0
- package/src/widgets/icon.tsx +47 -0
- package/src/widgets/image.tsx +407 -0
- package/src/widgets/markdown-lazy.tsx +22 -0
- package/src/widgets/markdown.tsx +267 -0
- package/src/widgets/reference.tsx +196 -0
- package/src/widgets/registry.ts +104 -0
- package/src/widgets/special.tsx +286 -0
- package/src/widgets/union.tsx +233 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { PageLink } from '@vespercms/core';
|
|
2
|
+
import { Button, Combobox, Dialog } from '@vespercms/ui';
|
|
3
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import type { StudioApi } from '../app/api.ts';
|
|
5
|
+
import { useStudio } from '../app/context.tsx';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* The link picker (spec §6.15.6): editors find a page by its title, and the
|
|
9
|
+
* editor writes that page's current address. Content stays plain Markdown
|
|
10
|
+
* or a plain string, and a move later rewrites the link.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const cache = new WeakMap<StudioApi, Promise<PageLink[]>>();
|
|
14
|
+
|
|
15
|
+
/** Every page with a URL on the site, loaded once per studio. Empty until it arrives, or if it fails. */
|
|
16
|
+
export function usePages(): PageLink[] {
|
|
17
|
+
const { api } = useStudio();
|
|
18
|
+
const [pages, setPages] = useState<PageLink[]>([]);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
let live = true;
|
|
21
|
+
let pending = cache.get(api);
|
|
22
|
+
if (!pending) {
|
|
23
|
+
pending = api.listPages();
|
|
24
|
+
cache.set(api, pending);
|
|
25
|
+
}
|
|
26
|
+
pending.then(
|
|
27
|
+
(found) => {
|
|
28
|
+
if (live) setPages(found);
|
|
29
|
+
},
|
|
30
|
+
() => {
|
|
31
|
+
// Without the list, links are typed by hand, as before.
|
|
32
|
+
cache.delete(api);
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
return () => {
|
|
36
|
+
live = false;
|
|
37
|
+
};
|
|
38
|
+
}, [api]);
|
|
39
|
+
return pages;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Pages as link suggestions: found by title, inserted as their address. */
|
|
43
|
+
export function pageSuggestions(pages: readonly PageLink[]) {
|
|
44
|
+
return pages.map((page) => ({ value: page.url, label: page.title, detail: page.url }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* "Choose a page…" beside a URL field: a dialog with a searchable list of
|
|
49
|
+
* pages. `absolute` puts the site's address in front, for fields whose
|
|
50
|
+
* schema accepts only full URLs.
|
|
51
|
+
*/
|
|
52
|
+
export function ChoosePageButton({
|
|
53
|
+
onPick,
|
|
54
|
+
absolute,
|
|
55
|
+
fieldLabel,
|
|
56
|
+
isDisabled,
|
|
57
|
+
}: {
|
|
58
|
+
onPick: (url: string) => void;
|
|
59
|
+
absolute?: string;
|
|
60
|
+
fieldLabel: string;
|
|
61
|
+
isDisabled?: boolean;
|
|
62
|
+
}) {
|
|
63
|
+
const pages = usePages();
|
|
64
|
+
const [open, setOpen] = useState(false);
|
|
65
|
+
const options = useMemo(
|
|
66
|
+
() =>
|
|
67
|
+
pages.map((page) => ({
|
|
68
|
+
value: absolute ? new URL(page.url, absolute).href : page.url,
|
|
69
|
+
label: `${page.title} (${page.url})`,
|
|
70
|
+
})),
|
|
71
|
+
[pages, absolute],
|
|
72
|
+
);
|
|
73
|
+
if (pages.length === 0) return null;
|
|
74
|
+
return (
|
|
75
|
+
<>
|
|
76
|
+
<Button
|
|
77
|
+
variant="quiet"
|
|
78
|
+
{...(isDisabled ? { isDisabled } : {})}
|
|
79
|
+
onPress={() => {
|
|
80
|
+
setOpen(true);
|
|
81
|
+
}}
|
|
82
|
+
aria-label={`Choose a page for ${fieldLabel}`}
|
|
83
|
+
>
|
|
84
|
+
Choose a page…
|
|
85
|
+
</Button>
|
|
86
|
+
<Dialog title="Choose a page" isOpen={open} onOpenChange={setOpen}>
|
|
87
|
+
<Combobox
|
|
88
|
+
label="Page"
|
|
89
|
+
description="Search by title. Its address goes in the field."
|
|
90
|
+
options={options}
|
|
91
|
+
value={null}
|
|
92
|
+
onChange={(value) => {
|
|
93
|
+
if (value) {
|
|
94
|
+
onPick(value);
|
|
95
|
+
setOpen(false);
|
|
96
|
+
}
|
|
97
|
+
}}
|
|
98
|
+
/>
|
|
99
|
+
</Dialog>
|
|
100
|
+
</>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { CollectionManifest, FieldModel } from '@vespercms/core';
|
|
2
|
+
import { keyOf } from './values.ts';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Where each field goes in the editor (spec §10.3, and DECISIONS.md, "The
|
|
6
|
+
* entry editor has tabs"): the title as the document heading, above the
|
|
7
|
+
* tabs; the main fields on Content, in schema order; secondary frontmatter
|
|
8
|
+
* on Details; and fields placed on a tab vesper.config.ts declares, on that
|
|
9
|
+
* tab. A field's `placement` (from `.meta()` or vesper.config.ts) always
|
|
10
|
+
* wins; otherwise slugs, dates, tags, ordering, SEO fields and scheduling
|
|
11
|
+
* go to Details.
|
|
12
|
+
* "Hide from site search" is absent, not disabled, where the collection
|
|
13
|
+
* isn't searched or has no pages (spec §6.19): the manifest then names no
|
|
14
|
+
* `searchHidden` field.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const TITLE_KEYS = ['title', 'name', 'heading', 'label'];
|
|
18
|
+
|
|
19
|
+
const DETAIL_KEYS = new Set([
|
|
20
|
+
'slug',
|
|
21
|
+
'tags',
|
|
22
|
+
'draft',
|
|
23
|
+
'order',
|
|
24
|
+
'weight',
|
|
25
|
+
'sort',
|
|
26
|
+
'layout',
|
|
27
|
+
'template',
|
|
28
|
+
'permalink',
|
|
29
|
+
'canonical',
|
|
30
|
+
'canonicalurl',
|
|
31
|
+
'noindex',
|
|
32
|
+
'nofollow',
|
|
33
|
+
'aliases',
|
|
34
|
+
'redirects',
|
|
35
|
+
'seo',
|
|
36
|
+
'meta',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const DETAIL_WIDGETS = new Set(['slug', 'date', 'datetime', 'tags']);
|
|
40
|
+
|
|
41
|
+
export type ArrangedFields = {
|
|
42
|
+
title: FieldModel | undefined;
|
|
43
|
+
main: FieldModel[];
|
|
44
|
+
details: FieldModel[];
|
|
45
|
+
/** The collection's own tabs, in the order it declares them, each with its fields. */
|
|
46
|
+
tabs: { id: string; label: string; fields: FieldModel[] }[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function arrangeFields(collection: CollectionManifest): ArrangedFields {
|
|
50
|
+
const searched = collection.presentation.searchHidden !== undefined;
|
|
51
|
+
const fields = (collection.fields ?? []).filter(
|
|
52
|
+
(field) => !field.hidden && (searched || field.search !== 'hide'),
|
|
53
|
+
);
|
|
54
|
+
const title = fields.find(
|
|
55
|
+
(field) =>
|
|
56
|
+
field.kind === 'string' && TITLE_KEYS.includes(keyOf(field)) && field.placement !== 'details',
|
|
57
|
+
);
|
|
58
|
+
const schedule = collection.presentation.schedule;
|
|
59
|
+
// Where visitors go once it comes down belongs with the times.
|
|
60
|
+
const scheduled = new Set(
|
|
61
|
+
schedule
|
|
62
|
+
? [
|
|
63
|
+
schedule.publishAt,
|
|
64
|
+
schedule.unpublishAt,
|
|
65
|
+
schedule.unpublishRedirect ?? 'unpublishRedirect',
|
|
66
|
+
]
|
|
67
|
+
: [],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const main: FieldModel[] = [];
|
|
71
|
+
const details: FieldModel[] = [];
|
|
72
|
+
const tabs = (collection.presentation.tabs ?? []).map((tab) => ({
|
|
73
|
+
...tab,
|
|
74
|
+
fields: [] as FieldModel[],
|
|
75
|
+
}));
|
|
76
|
+
for (const field of fields) {
|
|
77
|
+
if (field === title) continue;
|
|
78
|
+
const own = tabs.find((tab) => tab.id === field.placement);
|
|
79
|
+
if (own) own.fields.push(field);
|
|
80
|
+
else if (field.placement === 'main') main.push(field);
|
|
81
|
+
else if (field.placement === 'details' || isDetail(field, scheduled)) details.push(field);
|
|
82
|
+
else main.push(field);
|
|
83
|
+
}
|
|
84
|
+
return { title, main, details, tabs: tabs.filter((tab) => tab.fields.length > 0) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isDetail(field: FieldModel, scheduled: ReadonlySet<string>): boolean {
|
|
88
|
+
const key = keyOf(field);
|
|
89
|
+
const lower = key.toLowerCase();
|
|
90
|
+
return (
|
|
91
|
+
scheduled.has(key) ||
|
|
92
|
+
DETAIL_KEYS.has(lower) ||
|
|
93
|
+
field.kind === 'date' ||
|
|
94
|
+
DETAIL_WIDGETS.has(field.widget ?? '') ||
|
|
95
|
+
/^(seo|meta|og)[A-Z_]|^seo$|Seo$/.test(key) ||
|
|
96
|
+
/(At|On|Date)$/.test(key)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { A11yNote, CollectionManifest, DataObject, DataValue } from '@vespercms/core';
|
|
2
|
+
import { createContext, useContext } from 'react';
|
|
3
|
+
import type { ValuePath } from './values.ts';
|
|
4
|
+
|
|
5
|
+
/** What every field in the open entry shares. */
|
|
6
|
+
export type EditorState = {
|
|
7
|
+
collection: CollectionManifest;
|
|
8
|
+
values: DataObject;
|
|
9
|
+
setValue: (path: ValuePath, value: DataValue) => void;
|
|
10
|
+
/** Validation messages by `pathKey`, from the last save. */
|
|
11
|
+
issues: ReadonlyMap<string, readonly string[]>;
|
|
12
|
+
/** Accessibility notes by `pathKey` of the field they're shown under (spec §6.18). */
|
|
13
|
+
notes?: ReadonlyMap<string, readonly A11yNote[]>;
|
|
14
|
+
disabled: boolean;
|
|
15
|
+
/** The entry hasn't been saved yet. */
|
|
16
|
+
isNew: boolean;
|
|
17
|
+
/** The entry's file, site-relative; for a new entry, where it will most likely be saved. */
|
|
18
|
+
entryPath: string;
|
|
19
|
+
/** The open entry's ID (`null` until a new entry is saved). */
|
|
20
|
+
entryId: string | null;
|
|
21
|
+
/** Prefix for field ids in a form that isn't the entry's own (a block's settings). */
|
|
22
|
+
idPrefix?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const EditorContext = createContext<EditorState | null>(null);
|
|
26
|
+
|
|
27
|
+
export function useEditor(): EditorState {
|
|
28
|
+
const editor = useContext(EditorContext);
|
|
29
|
+
if (!editor) throw new Error('useEditor() must be used inside an entry editor.');
|
|
30
|
+
return editor;
|
|
31
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CollectionManifest, DataObject } from '@vespercms/core';
|
|
2
|
+
import { arrangeFields } from './arrange.ts';
|
|
3
|
+
import { keyOf } from './values.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A copy's starting values (Duplicate, spec §10.3): every value as it is,
|
|
7
|
+
* " (copy)" after the title, and the fields that hold the entry's own address
|
|
8
|
+
* left empty so the copy gets its own. Those are the slug widget's, which then
|
|
9
|
+
* follow the new title, and a top-level `slug`, which Astro takes as the
|
|
10
|
+
* entry's ID. Images are shared, so their focal points and crops come along.
|
|
11
|
+
*/
|
|
12
|
+
export function copiedValues(collection: CollectionManifest, values: DataObject): DataObject {
|
|
13
|
+
const title = arrangeFields(collection).title;
|
|
14
|
+
const titleKey = title ? keyOf(title) : null;
|
|
15
|
+
const addresses = new Set([
|
|
16
|
+
'slug',
|
|
17
|
+
...(collection.fields ?? [])
|
|
18
|
+
.filter((field) => field.path.length === 1 && field.widget === 'slug')
|
|
19
|
+
.map(keyOf),
|
|
20
|
+
]);
|
|
21
|
+
return Object.fromEntries(
|
|
22
|
+
Object.entries(values)
|
|
23
|
+
.filter(([key]) => !addresses.has(key))
|
|
24
|
+
.map(([key, value]) =>
|
|
25
|
+
key === titleKey && typeof value === 'string' && value.trim() !== ''
|
|
26
|
+
? [key, `${value} (copy)`]
|
|
27
|
+
: [key, value],
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A field inside a closed list item is in the page but hidden (the item's
|
|
3
|
+
* panel is `hidden="until-found"`). Going to it — from the error summary or
|
|
4
|
+
* the notes drawer — first asks every closed item around it to open: the
|
|
5
|
+
* event bubbles from the field through each list it's nested in, and each
|
|
6
|
+
* list opens the item it passes through (DECISIONS.md, "Top-level groups are
|
|
7
|
+
* sections, and list items are rows that open in place").
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const REVEAL_EVENT = 'vesper:reveal';
|
|
11
|
+
|
|
12
|
+
/** True while `element` or something around it is hidden: a closed item, or a tab not chosen. */
|
|
13
|
+
function isHidden(element: Element): boolean {
|
|
14
|
+
return element.closest('[hidden], [inert]') !== null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Focuses an element as soon as it's on screen (a field on another tab
|
|
19
|
+
* shows once its tab is chosen; one inside a closed list item, once the
|
|
20
|
+
* item opens), and scrolls it into view.
|
|
21
|
+
*/
|
|
22
|
+
export function focusWhenShown(id: string, tries = 20, asked = false): void {
|
|
23
|
+
const element = document.getElementById(id);
|
|
24
|
+
if (element && !isHidden(element)) {
|
|
25
|
+
element.scrollIntoView({ block: 'center' });
|
|
26
|
+
element.focus();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (element && !asked) {
|
|
30
|
+
element.dispatchEvent(new CustomEvent(REVEAL_EVENT, { bubbles: true }));
|
|
31
|
+
asked = true;
|
|
32
|
+
}
|
|
33
|
+
if (tries > 0) {
|
|
34
|
+
setTimeout(() => {
|
|
35
|
+
focusWhenShown(id, tries - 1, asked);
|
|
36
|
+
}, 25);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fieldChildren,
|
|
3
|
+
isPlainObject,
|
|
4
|
+
type DataValue,
|
|
5
|
+
type FieldIssue,
|
|
6
|
+
type FieldModel,
|
|
7
|
+
} from '@vespercms/core';
|
|
8
|
+
import { itemNames } from '../widgets/group.tsx';
|
|
9
|
+
import { builtinWidget } from '../widgets/registry.ts';
|
|
10
|
+
import { kindRow } from '../widgets/union.tsx';
|
|
11
|
+
import { fieldId, keyOf, type ValuePath } from './values.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where an issue is shown: the deepest field on screen along its path. An
|
|
15
|
+
* issue inside a raw field, or in a nested value no control shows, lands on
|
|
16
|
+
* the nearest field that is shown.
|
|
17
|
+
*/
|
|
18
|
+
export function issueTarget(
|
|
19
|
+
fields: readonly FieldModel[],
|
|
20
|
+
path: FieldIssue['path'],
|
|
21
|
+
): { path: ValuePath; focusId: string; label: string } {
|
|
22
|
+
let models: readonly FieldModel[] = fields;
|
|
23
|
+
let model: FieldModel | undefined;
|
|
24
|
+
const matched: (string | number)[] = [];
|
|
25
|
+
for (const segment of path) {
|
|
26
|
+
if (typeof segment === 'number') {
|
|
27
|
+
const item = model?.kind === 'array' ? model.children?.[0] : undefined;
|
|
28
|
+
if (!item || !usesChildren(model)) break;
|
|
29
|
+
model = item;
|
|
30
|
+
models = fieldChildren(item);
|
|
31
|
+
} else {
|
|
32
|
+
// A kind's fields are found by key among every kind's: their ids come from the value's path.
|
|
33
|
+
const next = models.find((candidate) => keyOf(candidate) === segment);
|
|
34
|
+
if (!next || (model && !usesChildren(model))) break;
|
|
35
|
+
model = next;
|
|
36
|
+
models = fieldChildren(next);
|
|
37
|
+
}
|
|
38
|
+
matched.push(segment);
|
|
39
|
+
}
|
|
40
|
+
if (!model) return { path: [], focusId: 'main', label: 'Entry' };
|
|
41
|
+
const id = fieldId(matched);
|
|
42
|
+
const widget = builtinWidget(model);
|
|
43
|
+
// A widget that draws its own control for the child at fault (an image's alt text) gets focus there.
|
|
44
|
+
const child = path[matched.length];
|
|
45
|
+
const childSuffix = typeof child === 'string' ? widget.childControls?.[child] : undefined;
|
|
46
|
+
const childLabel = models.find((m) => keyOf(m) === child)?.label;
|
|
47
|
+
return {
|
|
48
|
+
path: matched,
|
|
49
|
+
focusId: childSuffix ? `${id}${childSuffix}` : widget.group ? `${id}-label` : id,
|
|
50
|
+
label: childSuffix && childLabel ? `${model.label}, ${childLabel.toLowerCase()}` : model.label,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whether a field's widget shows its children as their own fields (groups and repeaters do). */
|
|
55
|
+
function usesChildren(field: FieldModel | undefined): boolean {
|
|
56
|
+
if (!field) return true;
|
|
57
|
+
return builtinWidget(field).rendersChildren === true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Where an issue is, in words, for a list of notes away from the form:
|
|
62
|
+
* "Section 2 (Showcase) › Image, alt". Beside its field, the field's own
|
|
63
|
+
* label is enough, but in the notes drawer two images in blocks would both
|
|
64
|
+
* read "Image, alt". A list item is named by its place and, in blocks, its
|
|
65
|
+
* kind, which says the list's name too ("Section 2"), so the list's own
|
|
66
|
+
* label is left out before it.
|
|
67
|
+
*/
|
|
68
|
+
export function issuePlace(
|
|
69
|
+
fields: readonly FieldModel[],
|
|
70
|
+
path: FieldIssue['path'],
|
|
71
|
+
values: DataValue,
|
|
72
|
+
): string {
|
|
73
|
+
const target = issueTarget(fields, path);
|
|
74
|
+
const parts: string[] = [];
|
|
75
|
+
let models: readonly FieldModel[] = fields;
|
|
76
|
+
let model: FieldModel | undefined;
|
|
77
|
+
let value: DataValue | undefined = values;
|
|
78
|
+
for (const segment of target.path) {
|
|
79
|
+
if (typeof segment === 'number') {
|
|
80
|
+
const item = model?.children?.[0];
|
|
81
|
+
if (!model || !item) break;
|
|
82
|
+
const held = Array.isArray(value) ? value[segment] : undefined;
|
|
83
|
+
const one = itemNames(model).one;
|
|
84
|
+
const kind = item.kind === 'union' ? ` (${kindRow(held ?? null, item).name})` : '';
|
|
85
|
+
parts.pop();
|
|
86
|
+
parts.push(
|
|
87
|
+
`${one.charAt(0).toLocaleUpperCase()}${one.slice(1)} ${String(segment + 1)}${kind}`,
|
|
88
|
+
);
|
|
89
|
+
model = item;
|
|
90
|
+
models = fieldChildren(item);
|
|
91
|
+
value = held;
|
|
92
|
+
} else {
|
|
93
|
+
const next = models.find((candidate) => keyOf(candidate) === segment);
|
|
94
|
+
if (!next) break;
|
|
95
|
+
parts.push(next.label);
|
|
96
|
+
model = next;
|
|
97
|
+
models = fieldChildren(next);
|
|
98
|
+
value = isPlainObject(value) ? value[segment] : undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// The field itself keeps the name it has beside the control ("Image, alt").
|
|
102
|
+
if (typeof target.path.at(-1) === 'string') parts.splice(-1, 1, target.label);
|
|
103
|
+
return parts.length > 0 ? parts.join(' › ') : target.label;
|
|
104
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { isPlainObject, type DataObject, type DataValue, type FieldModel } from '@vespercms/core';
|
|
2
|
+
|
|
3
|
+
/** A concrete path into an entry's values: keys and array indexes. */
|
|
4
|
+
export type ValuePath = readonly (string | number)[];
|
|
5
|
+
|
|
6
|
+
export function getIn(root: DataValue, path: ValuePath): DataValue {
|
|
7
|
+
let current: DataValue = root;
|
|
8
|
+
for (const segment of path) {
|
|
9
|
+
if (typeof segment === 'number') {
|
|
10
|
+
current = Array.isArray(current) ? current[segment] : undefined;
|
|
11
|
+
} else {
|
|
12
|
+
current = isPlainObject(current) ? current[segment] : undefined;
|
|
13
|
+
}
|
|
14
|
+
if (current === undefined) return undefined;
|
|
15
|
+
}
|
|
16
|
+
return current;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Returns a copy of `root` with the value at `path` replaced. `undefined`
|
|
21
|
+
* removes an object key (the file then loses that line) and leaves array
|
|
22
|
+
* items in place. Missing parents are created.
|
|
23
|
+
*/
|
|
24
|
+
export function setIn(root: DataObject, path: ValuePath, value: DataValue): DataObject {
|
|
25
|
+
return setAt(root, path, 0, value) as DataObject;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function setAt(node: DataValue, path: ValuePath, index: number, value: DataValue): DataValue {
|
|
29
|
+
if (index === path.length) return value;
|
|
30
|
+
const segment = path[index] as string | number;
|
|
31
|
+
if (typeof segment === 'number') {
|
|
32
|
+
const list = Array.isArray(node) ? [...node] : [];
|
|
33
|
+
list[segment] = setAt(list[segment], path, index + 1, value);
|
|
34
|
+
return list;
|
|
35
|
+
}
|
|
36
|
+
const object: DataObject = isPlainObject(node) ? { ...node } : {};
|
|
37
|
+
const next = setAt(object[segment], path, index + 1, value);
|
|
38
|
+
if (next === undefined) Reflect.deleteProperty(object, segment);
|
|
39
|
+
else object[segment] = next;
|
|
40
|
+
return object;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A DOM id for the control at `path`: stable, unique per entry, and safe in selectors. */
|
|
44
|
+
export function fieldId(path: ValuePath, prefix = 'field'): string {
|
|
45
|
+
// Segments joined with dots, and no hyphens anywhere: ids built by adding
|
|
46
|
+
// `-label`, `-help` and so on to a field's id can then never equal another
|
|
47
|
+
// field's id (a group's `-label` legend and a child field called `label`).
|
|
48
|
+
// A form other than the entry's (a block's settings) uses its own prefix.
|
|
49
|
+
return `${prefix}.${path.map((s) => String(s).replace(/[^A-Za-z0-9_]/g, '_')).join('.')}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Key for matching validation issues to fields. */
|
|
53
|
+
export function pathKey(path: ValuePath): string {
|
|
54
|
+
return path.map(String).join('.');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A field model's own key: the last segment of its path (`'[]'` for array items). */
|
|
58
|
+
export function keyOf(field: FieldModel): string {
|
|
59
|
+
return field.path.at(-1) ?? '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The value a new entry starts with: each field's static default, where it has one. */
|
|
63
|
+
export function initialValues(fields: readonly FieldModel[] | null): DataObject {
|
|
64
|
+
const values: DataObject = {};
|
|
65
|
+
for (const field of fields ?? []) {
|
|
66
|
+
if (field.default !== undefined && !field.defaultDynamic) {
|
|
67
|
+
values[keyOf(field)] = structuredClone(field.default) as DataValue;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return values;
|
|
71
|
+
}
|