@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,192 @@
|
|
|
1
|
+
import type { JSONContent } from '@tiptap/core';
|
|
2
|
+
import type { MarkdownManager } from '@tiptap/markdown';
|
|
3
|
+
import {
|
|
4
|
+
escapePipes,
|
|
5
|
+
tableValue,
|
|
6
|
+
unescapePipes,
|
|
7
|
+
type BodyTable,
|
|
8
|
+
type TableAlign,
|
|
9
|
+
type TableValue,
|
|
10
|
+
} from '@vespercms/core';
|
|
11
|
+
import { restoreChips, withPlaceholders } from './chips.ts';
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* A table as the writing view holds it (DECISIONS.md, "Tables are edited as
|
|
15
|
+
* a grid, and a save splices cells"): core's `TableValue`, with each cell's
|
|
16
|
+
* chips as placeholders, so a cell is parsed, shown and edited like a line
|
|
17
|
+
* of the body. Every change to a table goes through these functions, so
|
|
18
|
+
* rows and columns keep the index they were loaded at.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Inline nodes a cell may hold, beside text with its marks. */
|
|
22
|
+
const CELL_NODES = new Set(['text', 'image', 'vesperInline']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A named character reference Tiptap doesn't decode (any but `<`, `>`,
|
|
26
|
+
* `"` and `&`): it shows as written, and an edit would save it as
|
|
27
|
+
* `&nbsp;` (DECISIONS.md, "The compact Markdown editor checks marked's
|
|
28
|
+
* tokens before the round trip").
|
|
29
|
+
*/
|
|
30
|
+
const UNDECODED = /&(?!(?:lt|gt|quot|amp);)[A-Za-z][A-Za-z0-9]*;/;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A cell parsed on its own would start a block where it starts with a
|
|
34
|
+
* block's marker (`2. Second`, `# 1`, `- none`); in a table it's text.
|
|
35
|
+
*/
|
|
36
|
+
function asInline(text: string): string {
|
|
37
|
+
const ordered = /^(\d{1,9})[.)](\s|$)/.exec(text);
|
|
38
|
+
if (ordered?.[1]) return `${ordered[1]}\\${text.slice(ordered[1].length)}`;
|
|
39
|
+
return /^(#{1,6}(\s|$)|[-+*](\s|$)|>|([-*_][ \t]*){3,}$|`{3}|~{3})/.test(text)
|
|
40
|
+
? `\\${text}`
|
|
41
|
+
: text;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A cell's inline nodes, from its text as written (pipes escaped, chips as placeholders). */
|
|
45
|
+
export function parseCell(markdown: MarkdownManager, text: string): JSONContent[] | null {
|
|
46
|
+
try {
|
|
47
|
+
const nodes = markdown.parse(asInline(unescapePipes(text))).content ?? [];
|
|
48
|
+
if (nodes.length === 0) return [];
|
|
49
|
+
const [paragraph] = nodes;
|
|
50
|
+
if (nodes.length !== 1 || paragraph?.type !== 'paragraph') return null;
|
|
51
|
+
const inline = paragraph.content ?? [];
|
|
52
|
+
return inline.every((node) => CELL_NODES.has(node.type ?? '')) ? inline : null;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Inline nodes as a cell's text: one line, every pipe escaped. */
|
|
59
|
+
export function cellText(markdown: MarkdownManager, inline: readonly JSONContent[]): string {
|
|
60
|
+
if (inline.length === 0) return '';
|
|
61
|
+
const doc = { type: 'doc', content: [{ type: 'paragraph', content: [...inline] }] };
|
|
62
|
+
return escapePipes(markdown.serialize(doc).trim());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The cell's inline nodes when the writing view can own it: one line of
|
|
67
|
+
* inline Markdown that reads back the same once written. Null otherwise.
|
|
68
|
+
*/
|
|
69
|
+
export function cellFits(markdown: MarkdownManager, text: string): JSONContent[] | null {
|
|
70
|
+
const inline = parseCell(markdown, text);
|
|
71
|
+
if (!inline) return null;
|
|
72
|
+
if (inline.some((node) => node.type === 'text' && UNDECODED.test(node.text ?? ''))) return null;
|
|
73
|
+
const again = parseCell(markdown, cellText(markdown, inline));
|
|
74
|
+
return JSON.stringify(again) === JSON.stringify(inline) ? inline : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The table as loaded, each cell with its chips as placeholders. */
|
|
78
|
+
export function editorValue(text: string, table: BodyTable): TableValue {
|
|
79
|
+
const value = tableValue(text, table);
|
|
80
|
+
const lines = [table.header, ...table.rows];
|
|
81
|
+
return {
|
|
82
|
+
...value,
|
|
83
|
+
rows: value.rows.map((row, r) => ({
|
|
84
|
+
...row,
|
|
85
|
+
cells: row.cells.map((content, c) => {
|
|
86
|
+
const cell = lines[r]?.cells[c];
|
|
87
|
+
const base = cell?.contentStart ?? 0;
|
|
88
|
+
const chips = (cell?.chips ?? []).map((chip) => ({
|
|
89
|
+
start: chip.start - base,
|
|
90
|
+
end: chip.end - base,
|
|
91
|
+
}));
|
|
92
|
+
return withPlaceholders(content, chips);
|
|
93
|
+
}),
|
|
94
|
+
})),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The table with every chip's text back in place, for core's writer. */
|
|
99
|
+
export function savedValue(value: TableValue): TableValue {
|
|
100
|
+
return {
|
|
101
|
+
...value,
|
|
102
|
+
rows: value.rows.map((row) => ({ ...row, cells: row.cells.map(restoreChips) })),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A new table: a header row and one row, two columns, all empty. */
|
|
107
|
+
export function newTableValue(): TableValue {
|
|
108
|
+
return {
|
|
109
|
+
align: [null, null],
|
|
110
|
+
columns: [null, null],
|
|
111
|
+
rows: [
|
|
112
|
+
{ from: null, cells: ['', ''] },
|
|
113
|
+
{ from: null, cells: ['', ''] },
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function setCell(value: TableValue, row: number, column: number, text: string): TableValue {
|
|
119
|
+
return {
|
|
120
|
+
...value,
|
|
121
|
+
rows: value.rows.map((r, i) =>
|
|
122
|
+
i === row ? { ...r, cells: r.cells.map((c, j) => (j === column ? text : c)) } : r,
|
|
123
|
+
),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A new empty row at `at` (1 or more: the header stays first). */
|
|
128
|
+
export function insertRow(value: TableValue, at: number): TableValue {
|
|
129
|
+
const rows = [...value.rows];
|
|
130
|
+
rows.splice(Math.max(1, at), 0, { from: null, cells: value.columns.map(() => '') });
|
|
131
|
+
return { ...value, rows };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A new empty column at `at`. */
|
|
135
|
+
export function insertColumn(value: TableValue, at: number): TableValue {
|
|
136
|
+
const splice = <T>(list: readonly T[], item: T) => [
|
|
137
|
+
...list.slice(0, at),
|
|
138
|
+
item,
|
|
139
|
+
...list.slice(at),
|
|
140
|
+
];
|
|
141
|
+
return {
|
|
142
|
+
align: splice(value.align, null),
|
|
143
|
+
columns: splice(value.columns, null),
|
|
144
|
+
rows: value.rows.map((row) => ({ ...row, cells: splice(row.cells, '') })),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Moves a row below the header to `to` (also below the header). */
|
|
149
|
+
export function moveRow(value: TableValue, from: number, to: number): TableValue {
|
|
150
|
+
if (from < 1 || to < 1 || to >= value.rows.length || from >= value.rows.length) return value;
|
|
151
|
+
const rows = [...value.rows];
|
|
152
|
+
const [moved] = rows.splice(from, 1);
|
|
153
|
+
if (moved) rows.splice(to, 0, moved);
|
|
154
|
+
return { ...value, rows };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function moveColumn(value: TableValue, from: number, to: number): TableValue {
|
|
158
|
+
const count = value.columns.length;
|
|
159
|
+
if (from < 0 || to < 0 || from >= count || to >= count) return value;
|
|
160
|
+
const move = <T>(list: readonly T[]) => {
|
|
161
|
+
const out = [...list];
|
|
162
|
+
const [moved] = out.splice(from, 1);
|
|
163
|
+
if (moved !== undefined) out.splice(to, 0, moved);
|
|
164
|
+
return out;
|
|
165
|
+
};
|
|
166
|
+
return {
|
|
167
|
+
align: move(value.align),
|
|
168
|
+
columns: move(value.columns),
|
|
169
|
+
rows: value.rows.map((row) => ({ ...row, cells: move(row.cells) })),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Deletes a row below the header. */
|
|
174
|
+
export function deleteRow(value: TableValue, row: number): TableValue {
|
|
175
|
+
if (row < 1) return value;
|
|
176
|
+
return { ...value, rows: value.rows.filter((_, i) => i !== row) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Deletes a column, unless it's the only one. */
|
|
180
|
+
export function deleteColumn(value: TableValue, column: number): TableValue {
|
|
181
|
+
if (value.columns.length <= 1) return value;
|
|
182
|
+
const without = <T>(list: readonly T[]) => list.filter((_, i) => i !== column);
|
|
183
|
+
return {
|
|
184
|
+
align: without(value.align),
|
|
185
|
+
columns: without(value.columns),
|
|
186
|
+
rows: value.rows.map((row) => ({ ...row, cells: without(row.cells) })),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function alignColumn(value: TableValue, column: number, align: TableAlign): TableValue {
|
|
191
|
+
return { ...value, align: value.align.map((a, i) => (i === column ? align : a)) };
|
|
192
|
+
}
|
package/src/body/ui.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { BodyIssue, TableEdit, TableValue } from '@vespercms/core';
|
|
2
|
+
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import type { BlockKind, Catalog } from './catalog.ts';
|
|
4
|
+
import type { BodyChecks } from './checks.ts';
|
|
5
|
+
import type { ContainerTypes } from './nodes.ts';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* What the body editor's node views share: the catalog of blocks, the
|
|
9
|
+
* actions their buttons start, and validation of one block's own text, so
|
|
10
|
+
* each block shows its own messages inline (spec §6.5.1, "Validation").
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type BodyActions = {
|
|
14
|
+
openSettings: (pos: number) => void;
|
|
15
|
+
move: (pos: number, direction: -1 | 1) => void;
|
|
16
|
+
remove: (pos: number) => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type BodyUi = BodyActions & {
|
|
20
|
+
catalog: Catalog;
|
|
21
|
+
containers: ContainerTypes;
|
|
22
|
+
disabled: boolean;
|
|
23
|
+
checks: BodyChecks;
|
|
24
|
+
/** Site-relative paths of the files a partial can point at, once loaded. */
|
|
25
|
+
partials: readonly string[] | null;
|
|
26
|
+
entryPath: string;
|
|
27
|
+
/** Says something in the body's polite status ("Row added below row 3."). */
|
|
28
|
+
announce: (message: string) => void;
|
|
29
|
+
/** Whether a table's change would save, through core's writer, against the table it was loaded from. */
|
|
30
|
+
checkTable: (path: string | null, value: TableValue) => TableEdit;
|
|
31
|
+
/** True once, for the table just inserted, so it starts with its first cell being edited. */
|
|
32
|
+
takeNewTable: () => boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const BodyUiContext = createContext<BodyUi | null>(null);
|
|
36
|
+
|
|
37
|
+
export function useBodyUi(): BodyUi {
|
|
38
|
+
const ui = useContext(BodyUiContext);
|
|
39
|
+
if (!ui) throw new Error('useBodyUi() must be used inside the body editor.');
|
|
40
|
+
return ui;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One block's issues, from its own text: synchronous for Markdoc, after a tick for MDX. */
|
|
44
|
+
export function useBlockIssues(text: string): BodyIssue[] {
|
|
45
|
+
const { checks } = useBodyUi();
|
|
46
|
+
const sync = useMemo(() => checks.validateSync(text), [checks, text]);
|
|
47
|
+
const [async, setAsync] = useState<BodyIssue[]>([]);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (sync !== null) return;
|
|
50
|
+
let live = true;
|
|
51
|
+
void checks.validate(text).then((issues) => {
|
|
52
|
+
if (live) setAsync(issues);
|
|
53
|
+
});
|
|
54
|
+
return () => {
|
|
55
|
+
live = false;
|
|
56
|
+
};
|
|
57
|
+
}, [checks, text, sync]);
|
|
58
|
+
return sync ?? async;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The header's summary: the summary attribute's value, shown with its option label. */
|
|
62
|
+
export function summaryOf(
|
|
63
|
+
kind: BlockKind | undefined,
|
|
64
|
+
values: Readonly<Record<string, unknown>>,
|
|
65
|
+
): string {
|
|
66
|
+
if (!kind) return '';
|
|
67
|
+
const name = kind.summary ?? (kind.builtin === 'partial' ? 'file' : undefined);
|
|
68
|
+
if (!name) return '';
|
|
69
|
+
const value = values[name];
|
|
70
|
+
const field = kind.fields.find((f) => f.path[0] === name);
|
|
71
|
+
const fallback = value === undefined ? field?.default : value;
|
|
72
|
+
if (fallback === undefined || fallback === null || fallback === '') return '';
|
|
73
|
+
const option = field?.constraints?.options?.find((o) => o.value === fallback);
|
|
74
|
+
const text =
|
|
75
|
+
option?.label ?? (typeof fallback === 'string' ? fallback : JSON.stringify(fallback));
|
|
76
|
+
return text.length > 60 ? `${text.slice(0, 57)}…` : text;
|
|
77
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NodeViewContent,
|
|
3
|
+
NodeViewWrapper,
|
|
4
|
+
useEditorState,
|
|
5
|
+
type ReactNodeViewProps,
|
|
6
|
+
} from '@tiptap/react';
|
|
7
|
+
import type { BodyIssue } from '@vespercms/core';
|
|
8
|
+
import { Button, cx } from '@vespercms/ui';
|
|
9
|
+
import { useId } from 'react';
|
|
10
|
+
import { fallbackLabel } from './catalog.ts';
|
|
11
|
+
import { chipInfo, tagValues } from './chips.ts';
|
|
12
|
+
import { sourceLabel } from './document.ts';
|
|
13
|
+
import { summaryOf, useBlockIssues, useBodyUi } from './ui.ts';
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
* How blocks look in the writing view (spec §6.5.1): a tag with content is
|
|
17
|
+
* a labelled frame, one without is a compact card, an inline tag is a chip,
|
|
18
|
+
* and anything the editor can't show is a protected block. Each frame and
|
|
19
|
+
* card is a group named after its tag, with a real Settings button, and
|
|
20
|
+
* shows its own validation messages in words.
|
|
21
|
+
*
|
|
22
|
+
* Buttons inside the text stay out of the Tab order (69 chips must never be
|
|
23
|
+
* 69 Tab stops), except the Settings button of the frame the cursor is in,
|
|
24
|
+
* so Tab from inside a frame reaches it. Everything they do is also on the
|
|
25
|
+
* keyboard: select a block and press Enter.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** A selected block shows a focus outline, but only while the text has focus. */
|
|
29
|
+
const SELECTED = cx(
|
|
30
|
+
'in-[.ProseMirror-focused]:outline-2 in-[.ProseMirror-focused]:outline-solid in-[.ProseMirror-focused]:outline-offset-2 in-[.ProseMirror-focused]:outline-focus',
|
|
31
|
+
'in-[.ProseMirror-focused]:ring-6 in-[.ProseMirror-focused]:ring-(color:--glow-focus)',
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
function Issues({ id, issues }: { id: string; issues: readonly BodyIssue[] }) {
|
|
35
|
+
if (issues.length === 0) return null;
|
|
36
|
+
return (
|
|
37
|
+
<ul id={id} className="mt-2 flex flex-col gap-1 text-small">
|
|
38
|
+
{issues.map((issue) => (
|
|
39
|
+
<li
|
|
40
|
+
key={`${String(issue.line)}${issue.message}`}
|
|
41
|
+
className={issue.level === 'error' ? 'text-danger' : 'text-content-muted'}
|
|
42
|
+
>
|
|
43
|
+
{issue.level === 'error' ? issue.message : `Note: ${issue.message}`}
|
|
44
|
+
</li>
|
|
45
|
+
))}
|
|
46
|
+
</ul>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function headerText(label: string, summary: string): string {
|
|
51
|
+
return summary ? `${label} · ${summary}` : label;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A tag with editable content: the header, then the content, inside a hairline frame. */
|
|
55
|
+
export function ContainerView({ node, getPos, selected, editor }: ReactNodeViewProps) {
|
|
56
|
+
const ui = useBodyUi();
|
|
57
|
+
const headerId = useId();
|
|
58
|
+
const issuesId = useId();
|
|
59
|
+
const syntax = node.attrs.syntax as 'markdoc' | 'mdx';
|
|
60
|
+
const name = String(node.attrs.name);
|
|
61
|
+
const open = String(node.attrs.open);
|
|
62
|
+
const close = String(node.attrs.close);
|
|
63
|
+
const kind = ui.catalog.find(syntax, name);
|
|
64
|
+
const label = kind?.label ?? fallbackLabel(syntax, name);
|
|
65
|
+
const summary =
|
|
66
|
+
kind?.builtin === 'if'
|
|
67
|
+
? `shown when ${conditionOf(open)}`
|
|
68
|
+
: summaryOf(kind, tagValues(syntax, open).values);
|
|
69
|
+
// The attributes only: what's inside is checked as part of the whole body.
|
|
70
|
+
const issues = useBlockIssues(syntax === 'mdx' ? `${open}\n\n${close}` : `${open}\n${close}`);
|
|
71
|
+
// Tab from inside this frame reaches its Settings button, and no other frame's.
|
|
72
|
+
const inside = useEditorState({
|
|
73
|
+
editor,
|
|
74
|
+
selector: ({ editor: e }) => {
|
|
75
|
+
const pos = getPos();
|
|
76
|
+
if (typeof pos !== 'number') return false;
|
|
77
|
+
const { from, to } = e.state.selection;
|
|
78
|
+
return from > pos && to < pos + node.nodeSize;
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
return (
|
|
82
|
+
<NodeViewWrapper
|
|
83
|
+
as="div"
|
|
84
|
+
role="group"
|
|
85
|
+
aria-labelledby={headerId}
|
|
86
|
+
aria-describedby={issues.length > 0 ? issuesId : undefined}
|
|
87
|
+
className={cx(
|
|
88
|
+
'my-3 rounded-md border border-border p-3',
|
|
89
|
+
issues.some((i) => i.level === 'error') && 'border-danger',
|
|
90
|
+
selected && SELECTED,
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
93
|
+
<div contentEditable={false} className="mb-2 flex items-center justify-between gap-2">
|
|
94
|
+
<p id={headerId} className="text-label text-content-muted">
|
|
95
|
+
{headerText(label, summary)}
|
|
96
|
+
</p>
|
|
97
|
+
<SettingsButton label={label} getPos={getPos} inTabOrder={inside} />
|
|
98
|
+
</div>
|
|
99
|
+
<NodeViewContent className="vesper-container-content" />
|
|
100
|
+
<div contentEditable={false}>
|
|
101
|
+
<Issues id={issuesId} issues={issues} />
|
|
102
|
+
</div>
|
|
103
|
+
</NodeViewWrapper>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function SettingsButton({
|
|
108
|
+
label,
|
|
109
|
+
getPos,
|
|
110
|
+
inTabOrder,
|
|
111
|
+
}: {
|
|
112
|
+
label: string;
|
|
113
|
+
getPos: () => number | undefined;
|
|
114
|
+
inTabOrder: boolean;
|
|
115
|
+
}) {
|
|
116
|
+
const ui = useBodyUi();
|
|
117
|
+
return (
|
|
118
|
+
<Button
|
|
119
|
+
variant="quiet"
|
|
120
|
+
excludeFromTabOrder={!inTabOrder}
|
|
121
|
+
aria-label={`Settings for ${label}`}
|
|
122
|
+
isDisabled={ui.disabled}
|
|
123
|
+
onPress={() => {
|
|
124
|
+
const pos = getPos();
|
|
125
|
+
if (typeof pos === 'number') ui.openSettings(pos);
|
|
126
|
+
}}
|
|
127
|
+
className="min-h-8 px-2 text-small"
|
|
128
|
+
>
|
|
129
|
+
Settings
|
|
130
|
+
</Button>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A tag or component without editable content. */
|
|
135
|
+
export function CardView({ node, getPos, selected }: ReactNodeViewProps) {
|
|
136
|
+
const ui = useBodyUi();
|
|
137
|
+
const headerId = useId();
|
|
138
|
+
const issuesId = useId();
|
|
139
|
+
const syntax = node.attrs.syntax as 'markdoc' | 'mdx';
|
|
140
|
+
const name = String(node.attrs.name);
|
|
141
|
+
const source = String(node.attrs.source);
|
|
142
|
+
const kind = ui.catalog.find(syntax, name);
|
|
143
|
+
const label = kind?.label ?? fallbackLabel(syntax, name);
|
|
144
|
+
const summary =
|
|
145
|
+
kind?.builtin === 'else'
|
|
146
|
+
? conditionOf(source)
|
|
147
|
+
: kind?.builtin === 'if'
|
|
148
|
+
? ''
|
|
149
|
+
: summaryOf(kind, tagValues(syntax, source).values);
|
|
150
|
+
const issues = useBlockIssues(source);
|
|
151
|
+
return (
|
|
152
|
+
<NodeViewWrapper
|
|
153
|
+
as="div"
|
|
154
|
+
contentEditable={false}
|
|
155
|
+
role="group"
|
|
156
|
+
aria-labelledby={headerId}
|
|
157
|
+
aria-describedby={issues.length > 0 ? issuesId : undefined}
|
|
158
|
+
className={cx(
|
|
159
|
+
// The page's own surface, a step back from the writing surface around it.
|
|
160
|
+
'my-3 rounded-md border border-border bg-surface p-3',
|
|
161
|
+
issues.some((i) => i.level === 'error') && 'border-danger',
|
|
162
|
+
selected && SELECTED,
|
|
163
|
+
)}
|
|
164
|
+
>
|
|
165
|
+
<div className="flex items-center justify-between gap-2">
|
|
166
|
+
<p id={headerId} className="text-label text-content">
|
|
167
|
+
{headerText(label, summary)}
|
|
168
|
+
</p>
|
|
169
|
+
<SettingsButton label={label} getPos={getPos} inTabOrder={false} />
|
|
170
|
+
</div>
|
|
171
|
+
<Issues id={issuesId} issues={issues} />
|
|
172
|
+
</NodeViewWrapper>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The condition of `{% if … %}` or `{% else … /%}`, as written. */
|
|
177
|
+
function conditionOf(tag: string): string {
|
|
178
|
+
return tag
|
|
179
|
+
.replace(/^\{%\s*(if|else)\b/, '')
|
|
180
|
+
.replace(/\/?%\}$/, '')
|
|
181
|
+
.trim();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** An inline chip. Tags and components show their friendly name and text; the rest show their source. */
|
|
185
|
+
export function InlineView({ node, selected }: ReactNodeViewProps) {
|
|
186
|
+
const ui = useBodyUi();
|
|
187
|
+
const source = String(node.attrs.source);
|
|
188
|
+
const info = chipInfo(source);
|
|
189
|
+
const kind = info.name ? ui.catalog.find(info.syntax, info.name) : undefined;
|
|
190
|
+
const isTag = info.kind === 'tag' || info.kind === 'component';
|
|
191
|
+
// Only a tag can be checked on its own; a variable or an annotation needs
|
|
192
|
+
// the text around it, so the whole body's check covers those.
|
|
193
|
+
const issues = useBlockIssues(isTag ? source : '').filter((issue) => issue.level === 'error');
|
|
194
|
+
const known = kind && isTag;
|
|
195
|
+
const text = known
|
|
196
|
+
? headerText(
|
|
197
|
+
kind.label,
|
|
198
|
+
plainInner(info.inner) || summaryOf(kind, tagValues(info.syntax, info.open ?? '').values),
|
|
199
|
+
)
|
|
200
|
+
: source.length > 80
|
|
201
|
+
? `${source.slice(0, 77)}…`
|
|
202
|
+
: source;
|
|
203
|
+
return (
|
|
204
|
+
<NodeViewWrapper
|
|
205
|
+
as="span"
|
|
206
|
+
// No contentEditable={false}: ProseMirror sets it on the node view's own
|
|
207
|
+
// element, which keeps the caret out. A second one in here looks to
|
|
208
|
+
// ProseMirror like a widget to step over, and ⌘← or ⌘→ next to a chip
|
|
209
|
+
// at either end of a line then does nothing (DECISIONS.md).
|
|
210
|
+
className={cx(
|
|
211
|
+
'mx-0.5 rounded-sm border border-border-strong bg-surface-sunken px-1 py-0.5 text-small',
|
|
212
|
+
!known && 'font-mono',
|
|
213
|
+
issues.length > 0 && 'border-danger',
|
|
214
|
+
selected && SELECTED,
|
|
215
|
+
)}
|
|
216
|
+
>
|
|
217
|
+
{text}
|
|
218
|
+
{issues.length > 0 && (
|
|
219
|
+
<span className="text-danger"> ({issues.map((i) => i.message).join(' ')})</span>
|
|
220
|
+
)}
|
|
221
|
+
</NodeViewWrapper>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** An inline tag's content, when it's plain text. */
|
|
226
|
+
function plainInner(inner: string | undefined): string {
|
|
227
|
+
if (!inner || /[{<*_`[\]\\]/.test(inner)) return '';
|
|
228
|
+
return inner;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** A protected block: named, shown as source, editable as raw text. */
|
|
232
|
+
export function SourceView({ node, getPos, selected }: ReactNodeViewProps) {
|
|
233
|
+
const ui = useBodyUi();
|
|
234
|
+
const text = String(node.attrs.text ?? '');
|
|
235
|
+
const lines = text.split('\n');
|
|
236
|
+
const label = sourceLabel(String(node.attrs.blockType ?? ''));
|
|
237
|
+
return (
|
|
238
|
+
<NodeViewWrapper
|
|
239
|
+
as="div"
|
|
240
|
+
contentEditable={false}
|
|
241
|
+
className={cx(
|
|
242
|
+
'my-3 rounded-md border border-dashed border-border-strong bg-surface-sunken p-3',
|
|
243
|
+
selected && SELECTED,
|
|
244
|
+
)}
|
|
245
|
+
>
|
|
246
|
+
<div className="mb-1 flex items-center justify-between gap-2">
|
|
247
|
+
<p className="text-label text-content-muted">{label}, kept as written.</p>
|
|
248
|
+
<Button
|
|
249
|
+
variant="quiet"
|
|
250
|
+
excludeFromTabOrder
|
|
251
|
+
aria-label={`Edit as text: ${label}`}
|
|
252
|
+
isDisabled={ui.disabled}
|
|
253
|
+
onPress={() => {
|
|
254
|
+
const pos = getPos();
|
|
255
|
+
if (typeof pos === 'number') ui.openSettings(pos);
|
|
256
|
+
}}
|
|
257
|
+
className="min-h-8 px-2 text-small"
|
|
258
|
+
>
|
|
259
|
+
Edit as text
|
|
260
|
+
</Button>
|
|
261
|
+
</div>
|
|
262
|
+
<pre className="overflow-x-auto font-mono text-small whitespace-pre-wrap text-content">
|
|
263
|
+
{lines.slice(0, 6).join('\n')}
|
|
264
|
+
{lines.length > 6 ? `\n… ${String(lines.length - 6)} more lines` : ''}
|
|
265
|
+
</pre>
|
|
266
|
+
</NodeViewWrapper>
|
|
267
|
+
);
|
|
268
|
+
}
|
package/src/build.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The library build's entry. Importing the stylesheet here makes Vite compile
|
|
2
|
+
// it to dist/studio.css; library mode keeps CSS out of the JavaScript, so
|
|
3
|
+
// pages import '@vespercms/admin/studio.css' themselves.
|
|
4
|
+
import './studio.css';
|
|
5
|
+
|
|
6
|
+
export * from './index.ts';
|