caracms 0.8.0 → 0.8.2
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 +23 -2
- package/package.json +1 -1
- package/skills/caracms-hosting/SKILL.md +1 -1
- package/src/body.ts +55 -0
- package/src/cli/validate.ts +1 -1
- package/src/cloud-middleware.ts +39 -3
- package/src/content.ts +15 -6
- package/src/document-problems.ts +48 -0
- package/src/editor/client/collections/CollectionGrid.tsx +2 -2
- package/src/editor/client/collections/CollectionTable.tsx +3 -3
- package/src/editor/client/documents/DocMenu.tsx +56 -1
- package/src/editor/client/documents/actions.ts +30 -1
- package/src/editor/client/documents/dialogs.tsx +61 -3
- package/src/editor/client/editor.ts +98 -2
- package/src/editor/client/fields/LockupField.tsx +4 -4
- package/src/editor/client/inbox/InboxStage.tsx +25 -1
- package/src/editor/client/live/notices.tsx +19 -0
- package/src/editor/client/live/publish.tsx +58 -2
- package/src/editor/client/nav/DocRow.tsx +4 -4
- package/src/editor/client/pane/levels/CollectionLevel.tsx +2 -1
- package/src/editor/client/pane/levels/DocumentLevel.tsx +2 -1
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +2 -2
- package/src/editor/client/pane/levels/FormLevel.tsx +33 -6
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +19 -2
- package/src/editor/client/pane/parts.tsx +67 -4
- package/src/editor/client/pane/switcher.tsx +4 -4
- package/src/editor/client/shell/Bar.tsx +11 -0
- package/src/editor/client/state/keeper.ts +28 -4
- package/src/editor/client/state/live.ts +18 -1
- package/src/editor/routes/_live.ts +1 -1
- package/src/editor/routes/admin.astro +9 -0
- package/src/editor/routes/discard.ts +11 -0
- package/src/editor/server/admin-props.ts +18 -2
- package/src/editor/server/arrange.ts +4 -4
- package/src/editor/server/create.ts +4 -4
- package/src/editor/server/drafts-store.ts +2 -2
- package/src/editor/server/empty-site.ts +23 -0
- package/src/editor/server/media/cloud-mux.ts +4 -1
- package/src/editor/server/media/upload.ts +2 -41
- package/src/editor/server/move.ts +32 -4
- package/src/editor/server/write.ts +4 -4
- package/src/editor/styles/levels/live.css +2 -0
- package/src/editor/types.ts +92 -4
- package/src/forms/cloud-store.ts +4 -1
- package/src/forms/inbox.ts +10 -5
- package/src/forms/store.ts +14 -2
- package/src/index.ts +23 -8
- package/src/live/auth/login.ts +4 -3
- package/src/live/auth/views.ts +8 -1
- package/src/live/autosaves.ts +94 -6
- package/src/live/cloud.ts +64 -20
- package/src/live/drafts-store.ts +17 -6
- package/src/live/editor.ts +232 -16
- package/src/live/git.ts +492 -22
- package/src/live/github.ts +84 -17
- package/src/live/locks.ts +22 -0
- package/src/live/messages.ts +49 -2
- package/src/live/notices.ts +5 -1
- package/src/live/runtime.ts +2 -1
- package/src/live/status.ts +5 -0
- package/src/live/sweep.ts +44 -1
- package/src/live/webhook.ts +8 -3
package/README.md
CHANGED
|
@@ -10,8 +10,17 @@ is, under the MIT license.
|
|
|
10
10
|
|
|
11
11
|
## Start
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
Two steps: create an Astro site, then add caracms to it with `npx caracms init`.
|
|
14
|
+
(`npm create caracms` says the same.)
|
|
15
|
+
|
|
16
|
+
**1. Create the Astro site** (Astro 7, on Node 22.12 or later), or use one you have:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npm create astro@latest
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**2. In it, add caracms.** From the site's root, in a clean git checkout, install it and
|
|
23
|
+
commit the install:
|
|
15
24
|
|
|
16
25
|
```sh
|
|
17
26
|
npm install caracms --save-exact
|
|
@@ -321,6 +330,18 @@ site, signs every commit, and isn't tied to a person. Use it for anyone running
|
|
|
321
330
|
a handful of sites, or a repo that requires signed commits. Install it on the site's repo
|
|
322
331
|
only, with the same permissions as the token.
|
|
323
332
|
|
|
333
|
+
**Protect `drafts` from deletion.** It holds every change saved since the last Publish,
|
|
334
|
+
and to a branch tidy-up it looks like any stale branch. In the repo's Settings → Rules →
|
|
335
|
+
Rulesets, add a branch ruleset that targets `drafts` and turns on "Restrict deletions"
|
|
336
|
+
only (leave "Block force pushes" off: Publish moves `drafts` with a forced update). If
|
|
337
|
+
`drafts` goes anyway, the next start makes it again from the last commit the server
|
|
338
|
+
recorded, while GitHub still has that commit; when it can't, the editor says what was lost.
|
|
339
|
+
|
|
340
|
+
**Roll the site back with `git revert`, never a force-push.** A revert is a new commit on
|
|
341
|
+
`main`, which `drafts` takes in like any push; resetting `main` to an older commit and
|
|
342
|
+
force-pushing leaves the rolled-back content waiting in the editor, so the next Publish puts
|
|
343
|
+
it back (the editor warns everyone when it sees one).
|
|
344
|
+
|
|
324
345
|
### CaraCMS Cloud
|
|
325
346
|
|
|
326
347
|
#### Where the site runs: `services`
|
package/package.json
CHANGED
|
@@ -233,7 +233,7 @@ are CaraCMS's; keys and the repo are the team's.
|
|
|
233
233
|
| "That site key was revoked. Make a new one in app.caracms.com." (`revoked_key`) | Make a new labelled key for that place (host, laptop, staging), set it where the old one was (the host's variable or `.env`), redeploy or restart. Don't reuse another place's key. |
|
|
234
234
|
| "That site key isn't one we know." (`unknown_key`) | A mistyped or truncated key, a key for a deleted site, or `CARACMS_CLOUD_URL` pointing at another cloud. Check the variable's value has no stray quotes or spaces, unset `CARACMS_CLOUD_URL`, else make a new key. |
|
|
235
235
|
| "The caracms GitHub App isn't installed on <repo>, or that repo doesn't exist. Install the App on it: <link>" (`app_not_installed`) | Install the App from the link on that repo only. The cloud refreshes its record of the install only when the site's page in app.caracms.com opens (or a repo is linked, or the site mints a token), so open the site's page there, then run `init --cloud` again: it shows "App installed". The next visit to `/admin` tries again. If it is installed, the repo linked in app.caracms.com is the wrong one: the team fixes it there (a changed repo reaches the site at its next start). |
|
|
236
|
-
| "This site has no GitHub repo in CaraCMS Cloud yet.
|
|
236
|
+
| "This site has no GitHub repo in CaraCMS Cloud yet. A team owner can pick one in app.caracms.com." (`no_repo`; what `/admin` shows) | A team owner links the repo in app.caracms.com, then the App goes on it. The site still builds meanwhile. |
|
|
237
237
|
| "CaraCMS Cloud couldn't be reached, so the site can't be built. Try again in a minute." (`unreachable`) | Retry the build. A running site keeps serving. If it persists, tell the person; don't switch the mode. |
|
|
238
238
|
| "This site runs on CaraCMS Cloud (caracms.config.ts), but CARACMS_CLOUD_KEY isn't set. Make a key for it in app.caracms.com." | Not the cloud refusing: the key is missing where the site is built or runs (section 1). Expected during the move's steps 2–3. |
|
|
239
239
|
|
package/src/body.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a request's body under a cap (caracms-cloud adversarial review, finding 10). The
|
|
3
|
+
* bytes are counted as they're read and the read stops the moment they pass the cap,
|
|
4
|
+
* whatever `Content-Length` said or whether it was sent at all (a chunked body has none),
|
|
5
|
+
* so no route buffers more than its cap plus one chunk. A `Content-Length` over the cap is
|
|
6
|
+
* refused before a byte is read. The media upload and the forms endpoint read this way
|
|
7
|
+
* (M5b Decision 6); so do the webhook, sign-in and every editor write route since finding 10.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface CappedBody {
|
|
11
|
+
ok: true;
|
|
12
|
+
bytes: Uint8Array;
|
|
13
|
+
}
|
|
14
|
+
export interface OverCap {
|
|
15
|
+
ok: false;
|
|
16
|
+
/** The size, when the request said it (Content-Length); null when the read was stopped without knowing. */
|
|
17
|
+
size: number | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Read a request's body, stopping as soon as it passes `cap` bytes. */
|
|
21
|
+
export async function readCapped(request: Request, cap: number): Promise<CappedBody | OverCap> {
|
|
22
|
+
const declared = Number(request.headers.get('content-length') ?? NaN);
|
|
23
|
+
const said = Number.isFinite(declared) && declared >= 0 ? declared : null;
|
|
24
|
+
if (said !== null && said > cap) {
|
|
25
|
+
await request.body?.cancel().catch(() => {});
|
|
26
|
+
return { ok: false, size: said };
|
|
27
|
+
}
|
|
28
|
+
const reader = request.body?.getReader();
|
|
29
|
+
if (!reader) return { ok: true, bytes: new Uint8Array(0) };
|
|
30
|
+
const chunks: Uint8Array[] = [];
|
|
31
|
+
let total = 0;
|
|
32
|
+
for (;;) {
|
|
33
|
+
const { done, value } = await reader.read();
|
|
34
|
+
if (done) break;
|
|
35
|
+
total += value.byteLength;
|
|
36
|
+
if (total > cap) {
|
|
37
|
+
await reader.cancel().catch(() => {});
|
|
38
|
+
return { ok: false, size: said !== null && said > total ? said : null };
|
|
39
|
+
}
|
|
40
|
+
chunks.push(value);
|
|
41
|
+
}
|
|
42
|
+
const bytes = new Uint8Array(total);
|
|
43
|
+
let at = 0;
|
|
44
|
+
for (const c of chunks) {
|
|
45
|
+
bytes.set(c, at);
|
|
46
|
+
at += c.byteLength;
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, bytes };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The body as UTF-8 text, or null when it passes `cap` bytes (the route answers its own 413). */
|
|
52
|
+
export async function readCappedText(request: Request, cap: number): Promise<string | null> {
|
|
53
|
+
const read = await readCapped(request, cap);
|
|
54
|
+
return read.ok ? new TextDecoder().decode(read.bytes) : null;
|
|
55
|
+
}
|
package/src/cli/validate.ts
CHANGED
|
@@ -424,7 +424,7 @@ export async function validateSite(site: Site, codeRoutes: readonly CodeRoute[]
|
|
|
424
424
|
const c: Context = { file: entry.file, site, report, warningContext, exists };
|
|
425
425
|
const collection = site.collections[entry.collection]!;
|
|
426
426
|
if (entry.warning) {
|
|
427
|
-
if (entry.warning.code === 'no-title') error(c, 'title', 'This document has no "title", so
|
|
427
|
+
if (entry.warning.code === 'no-title') error(c, 'title', 'This document has no "title", so it has no page on the site. Add one.');
|
|
428
428
|
else error(c, '', entry.warning.message);
|
|
429
429
|
if (entry.warning.code !== 'no-title') continue;
|
|
430
430
|
}
|
package/src/cloud-middleware.ts
CHANGED
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
*
|
|
15
15
|
* `cloudSettingsForBuild()` is the build's start (`astro:build:start`): the same fetch, and
|
|
16
16
|
* a refusal or an unreachable cloud **fails the build** with the cloud's sentence or the
|
|
17
|
-
* package's own.
|
|
17
|
+
* package's own. A refusal fails it at once; an unreachable cloud (no answer, a timeout, an
|
|
18
|
+
* answer that isn't the API's, such as a 502 page) is tried again with growing waits for
|
|
19
|
+
* about three minutes first (`BUILD_RETRY_WAITS_MS`, `BUILD_RETRY_WINDOW_MS`), one log line
|
|
20
|
+
* per wait, since a Convex blip during a Publish's deploy would otherwise leave the site
|
|
21
|
+
* unchanged. Only the build retries: the server's first request keeps one try and `RETRY_MS`.
|
|
22
|
+
* The build ignores `github.repo`: a site with no repo linked still builds.
|
|
18
23
|
* The key beside the site's own forms and email variables fails it too (caracms-cloud M4
|
|
19
24
|
* Decision 22), whether or not the media is the cloud's.
|
|
20
25
|
*
|
|
@@ -68,11 +73,42 @@ export const onRequest: MiddlewareHandler = async (_context, next) => {
|
|
|
68
73
|
return next();
|
|
69
74
|
};
|
|
70
75
|
|
|
76
|
+
/** The build's waits between tries at an unreachable cloud, in order; the last repeats while the window lasts. */
|
|
77
|
+
export const BUILD_RETRY_WAITS_MS = [5_000, 10_000, 20_000, 40_000, 60_000];
|
|
78
|
+
/** No wait starts that would end more than this long after the first try began. */
|
|
79
|
+
export const BUILD_RETRY_WINDOW_MS = 210_000;
|
|
80
|
+
|
|
81
|
+
/** The build's clock, its waits and its log line (tests). */
|
|
82
|
+
export interface BuildRetry {
|
|
83
|
+
now?: () => number;
|
|
84
|
+
wait?: (ms: number) => Promise<void>;
|
|
85
|
+
log?: (line: string) => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The settings for the build: an unreachable cloud is tried again within the window; a refusal is thrown at once. */
|
|
89
|
+
async function loadForBuild(connection: Parameters<typeof loadCloudSettings>[0], retry: BuildRetry) {
|
|
90
|
+
const { now = Date.now, wait = (ms) => new Promise<void>((done) => setTimeout(done, ms)), log = (line) => console.warn(line) } = retry;
|
|
91
|
+
const end = now() + BUILD_RETRY_WINDOW_MS;
|
|
92
|
+
for (let tries = 1; ; tries++) {
|
|
93
|
+
try {
|
|
94
|
+
return await loadCloudSettings(connection);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (isCloudRefusal(error) && error.error !== 'unreachable') throw error;
|
|
97
|
+
const ms = BUILD_RETRY_WAITS_MS[Math.min(tries, BUILD_RETRY_WAITS_MS.length) - 1]!;
|
|
98
|
+
if (now() + ms > end) throw error;
|
|
99
|
+
const what = isCloudRefusal(error) && error.detail ? ` (${error.detail})` : '';
|
|
100
|
+
const at = new Date(now() + ms).toISOString().slice(11, 19);
|
|
101
|
+
log(`[caracms] CaraCMS Cloud couldn't be reached for the build's settings${what}, try ${tries}; trying again in ${ms / 1000} s, at ${at} UTC.`);
|
|
102
|
+
await wait(ms);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
71
107
|
/**
|
|
72
108
|
* The build's start: the settings, or an Error with the sentence that fails the build. Nothing for a site off the cloud.
|
|
73
109
|
* `services` is the config's (the integration passes it: this runs outside the bundle, where the build's defines don't reach).
|
|
74
110
|
*/
|
|
75
|
-
export async function cloudSettingsForBuild(env: Env = process.env, services: Services | null = null): Promise<void> {
|
|
111
|
+
export async function cloudSettingsForBuild(env: Env = process.env, services: Services | null = null, retry: BuildRetry = {}): Promise<void> {
|
|
76
112
|
env = servicesEnv(env, services);
|
|
77
113
|
if (services === 'cloud' && !env.CARACMS_CLOUD_KEY?.trim()) throw new Error(`caracms: ${CLOUD_KEY_MISSING_PROBLEM}`);
|
|
78
114
|
// caracms-cloud M4 Decision 22: the key with the site's own forms and email variables fails the build too.
|
|
@@ -86,7 +122,7 @@ export async function cloudSettingsForBuild(env: Env = process.env, services: Se
|
|
|
86
122
|
if (cloud.problems.length || forms.length) throw new Error(`caracms: ${[...cloud.problems, ...forms].join(' ')}`);
|
|
87
123
|
let settings;
|
|
88
124
|
try {
|
|
89
|
-
settings = await
|
|
125
|
+
settings = await loadForBuild(cloud.connection, retry);
|
|
90
126
|
} catch (error) {
|
|
91
127
|
const message = isCloudRefusal(error) && error.error !== 'unreachable' ? error.message : CLOUD_UNREACHABLE_BUILD;
|
|
92
128
|
throw new Error(`caracms: ${message}`);
|
package/src/content.ts
CHANGED
|
@@ -43,6 +43,7 @@ import { leftBehindMessage, leftBehindRoutes, ownRoutedCollections } from './cod
|
|
|
43
43
|
import { sameAddress } from './slug.ts';
|
|
44
44
|
import { byLabel, isOrderedCollection, orderSiblings, parsePageOrder, type PageOrder } from './page-order.ts';
|
|
45
45
|
import { SITE_SETTINGS_FILE } from './reserved.ts';
|
|
46
|
+
import { documentProblems } from './document-problems.ts';
|
|
46
47
|
|
|
47
48
|
// Posts and tags, Decision 14: a route's reader on the disk (read.ts).
|
|
48
49
|
export { contentReader } from './read.ts';
|
|
@@ -360,19 +361,25 @@ function hideChildren(entries: DocumentEntry[]): void {
|
|
|
360
361
|
* Read one document. Throws DocumentError: 'not-found', 'bad-path',
|
|
361
362
|
* 'unknown-collection', or the listing's warning codes ('invalid-json',
|
|
362
363
|
* 'no-title', 'nested-index') for a file that can't be opened.
|
|
364
|
+
*
|
|
365
|
+
* `untitled: true` is the editor's read (caracms-cloud adversarial review, finding 15): a
|
|
366
|
+
* document with no title opens, its entry still warned, so the title can be put back there.
|
|
367
|
+
* Everything that renders the site reads without it, so such a document has no page there.
|
|
363
368
|
*/
|
|
364
369
|
export async function readDocument(
|
|
365
370
|
site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>,
|
|
366
371
|
collectionName: string,
|
|
367
372
|
path: string,
|
|
368
373
|
source: ContentSource = diskSource,
|
|
374
|
+
options: { untitled?: boolean } = {},
|
|
369
375
|
): Promise<ReadDocument> {
|
|
370
376
|
const file = resolveDocumentFile(site, collectionName, path);
|
|
371
377
|
const collection = site.collections[collectionName]!;
|
|
372
378
|
const text = await source.readText(file);
|
|
373
379
|
if (text === null) throw new DocumentError('not-found', `${collection.label}: no document "${path}".`);
|
|
374
380
|
const { entry, document } = inspect(site, collection, path, text);
|
|
375
|
-
|
|
381
|
+
const opens = !entry.warning || (options.untitled === true && entry.warning.code === 'no-title');
|
|
382
|
+
if (!opens || !document) throw new DocumentError(entry.warning!.code, entry.warning!.message);
|
|
376
383
|
if (collection.heldBy) {
|
|
377
384
|
const held = await heldBaseOf(site, collection.name, source);
|
|
378
385
|
if (held) placeHeld(entry, held);
|
|
@@ -429,11 +436,13 @@ function inspect(
|
|
|
429
436
|
} catch (error) {
|
|
430
437
|
return warn('invalid-json', `This file isn't valid JSON (${error instanceof Error ? error.message : String(error)}).`);
|
|
431
438
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const
|
|
439
|
+
// caracms-cloud adversarial review, finding 15: a blank title keeps the document off the site
|
|
440
|
+
// (the listing's warning), but it's still read, columns and all, so the editor can open it
|
|
441
|
+
// and put one back.
|
|
442
|
+
const untitled = documentProblems(raw).length > 0;
|
|
443
|
+
const title = untitled ? '' : (raw as { title: string }).title;
|
|
444
|
+
const warning: DocumentWarning | null = untitled ? { code: 'no-title', message: 'This has no title, so it isn’t on the site. Open it to add one.' } : null;
|
|
445
|
+
const entry: DocumentEntry = { ...base, title, label: title || fileLabel, warning };
|
|
437
446
|
// M9 Decision 7: the home page is never hidden.
|
|
438
447
|
if ((document.kind === undefined || document.kind === 'sections') && document.hidden && path !== 'index') entry.hidden = entry.hiddenSelf = true;
|
|
439
448
|
if ((document.kind === undefined || document.kind === 'sections') && document.holds !== undefined) entry.holds = document.holds;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What makes a document one the reader won't open (caracms-cloud adversarial review,
|
|
3
|
+
* finding 15), said the way the editor says it. One rule for three places, so they never
|
|
4
|
+
* disagree: content.ts → `inspect` (the listing's "no title" warning), the editor's note on
|
|
5
|
+
* the field while it's typed, and Publish, which holds a document like this back (it stays
|
|
6
|
+
* waiting) and publishes the rest (live/git.ts → `publish`).
|
|
7
|
+
*
|
|
8
|
+
* Today that's the title: every kind of document needs one that isn't blank. Fields have no
|
|
9
|
+
* "required" of their own, and a field's value of the wrong shape parses as empty, never
|
|
10
|
+
* refused (document.ts → `parseAnyDocument`), so nothing else makes a document unreadable.
|
|
11
|
+
* A file that isn't JSON can't be read at all; only a hand edit makes one.
|
|
12
|
+
*
|
|
13
|
+
* Browser-safe: the editor imports it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface DocumentProblem {
|
|
17
|
+
/** Where it is: `title`, or `''` for the whole file. */
|
|
18
|
+
path: string;
|
|
19
|
+
/** The note on the field, in its own words: "This needs a title." */
|
|
20
|
+
note: string;
|
|
21
|
+
/** Why Publish held it back, after "<title> wasn't published: ": "its title is empty". */
|
|
22
|
+
reason: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const NO_TITLE: DocumentProblem = { path: 'title', note: 'This needs a title.', reason: 'its title is empty' };
|
|
26
|
+
const NOT_JSON: DocumentProblem = { path: '', note: "This file isn't valid JSON.", reason: "its file isn't valid JSON" };
|
|
27
|
+
|
|
28
|
+
/** A document's problems, from its plain value (a file's, or the editor's own). Empty: the reader opens it. */
|
|
29
|
+
export function documentProblems(document: unknown): DocumentProblem[] {
|
|
30
|
+
const title = document && typeof document === 'object' && !Array.isArray(document) ? (document as { title?: unknown }).title : undefined;
|
|
31
|
+
return typeof title === 'string' && title.trim() !== '' ? [] : [NO_TITLE];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A file's problems, from its text. */
|
|
35
|
+
export function textProblems(text: string): DocumentProblem[] {
|
|
36
|
+
let value: unknown;
|
|
37
|
+
try {
|
|
38
|
+
value = JSON.parse(text);
|
|
39
|
+
} catch {
|
|
40
|
+
return [NOT_JSON];
|
|
41
|
+
}
|
|
42
|
+
return documentProblems(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** "About wasn't published: its title is empty." */
|
|
46
|
+
export function heldBackSentence(title: string, reason: string): string {
|
|
47
|
+
return `${title} wasn't published: ${reason}.`;
|
|
48
|
+
}
|
|
@@ -19,7 +19,7 @@ import { docIcon } from '../nav/DocRow.tsx';
|
|
|
19
19
|
import { Avatar, DocMarksView } from '../live/marks.tsx';
|
|
20
20
|
import { displayName } from '../live/format.ts';
|
|
21
21
|
import { fmtDur } from '../fields/MediaField.tsx';
|
|
22
|
-
import { docId, type CardPicture, type EditorAnyCollection, type ListedDocument } from '../../types.ts';
|
|
22
|
+
import { docId, warnedWords, type CardPicture, type EditorAnyCollection, type ListedDocument } from '../../types.ts';
|
|
23
23
|
import { ratioValue } from '../../../cards.ts';
|
|
24
24
|
import { cellText, type RefIndex, type TableColumn } from './table.ts';
|
|
25
25
|
|
|
@@ -76,7 +76,7 @@ function Card(props: { doc: ListedDocument; collection: EditorAnyCollection; cap
|
|
|
76
76
|
{doc.warning ? (
|
|
77
77
|
<span className="wmark" data-warning={doc.warning.code} title={doc.warning.message}>
|
|
78
78
|
<Icon name="warn" size="xs" />
|
|
79
|
-
<span className="sr"
|
|
79
|
+
<span className="sr">{warnedWords(doc)}: {doc.warning.message}</span>
|
|
80
80
|
</span>
|
|
81
81
|
) : null}
|
|
82
82
|
{live ? <DocMarksView file={doc.file} compact lock={false} /> : null}
|
|
@@ -34,7 +34,7 @@ import { when } from '../live/format.ts';
|
|
|
34
34
|
import { openDialog } from '../documents/dialogs.tsx';
|
|
35
35
|
import { PAGELESS_SUB, singular } from '../documents/actions.ts';
|
|
36
36
|
import { usedOnLine } from '../block-list/summary.ts';
|
|
37
|
-
import { docId, type EditorAnyCollection, type ListedDocument } from '../../types.ts';
|
|
37
|
+
import { docId, opensInEditor, warnedWords, type EditorAnyCollection, type ListedDocument } from '../../types.ts';
|
|
38
38
|
import { captionColumns, cellText, cellValue, defaultView, filterable, MISSING, nextSort, orderOptions, orderValue, refIndex, refParts, sortable, tableColumns, viewRows, type FilterValue, type RefIndex, type TableColumn, type TableView } from './table.ts';
|
|
39
39
|
import { useCollection } from '../fields/ReferenceField.tsx';
|
|
40
40
|
import { CollectionGrid } from './CollectionGrid.tsx';
|
|
@@ -83,7 +83,7 @@ export function useViewMode(collection: EditorAnyCollection): ViewMode {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
const openDoc = (editor: Editor, doc: ListedDocument) => {
|
|
86
|
-
if (doc.warning) {
|
|
86
|
+
if (doc.warning && !opensInEditor(doc)) {
|
|
87
87
|
editor.announce(`${doc.label} can't be opened. ${doc.warning.message}`);
|
|
88
88
|
return;
|
|
89
89
|
}
|
|
@@ -375,7 +375,7 @@ function Row(props: { doc: ListedDocument; columns: readonly TableColumn[]; curr
|
|
|
375
375
|
{doc.warning ? (
|
|
376
376
|
<span className="wmark" data-warning={doc.warning.code} title={doc.warning.message}>
|
|
377
377
|
<Icon name="warn" size="xs" />
|
|
378
|
-
<span className="sr"
|
|
378
|
+
<span className="sr">{warnedWords(doc)}: {doc.warning.message}</span>
|
|
379
379
|
</span>
|
|
380
380
|
) : null}
|
|
381
381
|
{/* M9 Decision 7, collections under pages (Decision 11): hidden, or hidden with the page that holds it, named. */}
|
|
@@ -11,15 +11,24 @@
|
|
|
11
11
|
* study, a journal entry), off for Home or a collection's top page and while someone
|
|
12
12
|
* else is editing it.
|
|
13
13
|
*
|
|
14
|
+
* Discard changes (live mode): **Discard changes…** (or Discard this new page…) while the
|
|
15
|
+
* document is waiting to publish, which opens the confirmation; off, with the reason, while
|
|
16
|
+
* someone else is editing it.
|
|
17
|
+
*
|
|
14
18
|
* M6c (G1): a document with no page of its own (a shared block, a fields collection
|
|
15
19
|
* with no `urlBase`) has neither View on the live site nor Change address…, only Delete.
|
|
20
|
+
*
|
|
21
|
+
* `TitleNote` (caracms-cloud adversarial review, finding 15): a page or a shared block with
|
|
22
|
+
* no title says so at the top of its level, with Rename… beside it (a case study's title is
|
|
23
|
+
* the first field of its form, which says so itself: FormLevel.tsx → TitleField).
|
|
16
24
|
*/
|
|
17
25
|
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
|
18
26
|
import { useEditor, useStore } from '../context.tsx';
|
|
19
27
|
import { displayName } from '../live/format.ts';
|
|
20
28
|
import { Icon } from '../shell/Icons.tsx';
|
|
29
|
+
import { NO_TITLE } from '../../../document-problems.ts';
|
|
21
30
|
import { docId, sameDoc } from '../../types.ts';
|
|
22
|
-
import { singular } from './actions.ts';
|
|
31
|
+
import { discardWording, singular } from './actions.ts';
|
|
23
32
|
import { openDialog } from './dialogs.tsx';
|
|
24
33
|
|
|
25
34
|
export function DocMenu({ label }: { label: string }) {
|
|
@@ -54,6 +63,8 @@ export function DocMenu({ label }: { label: string }) {
|
|
|
54
63
|
const shared = collection.kind === 'shared';
|
|
55
64
|
const noPage = collection.urlBase === null;
|
|
56
65
|
const pending = (waiting ?? []).find((w) => w.collection === ref.collection && w.path === ref.path && w.change === 'new');
|
|
66
|
+
// Discard changes: this document is waiting to publish.
|
|
67
|
+
const waitingHere = (waiting ?? []).find((w) => w.file === entry.file) ?? null;
|
|
57
68
|
const isNew = !!pending;
|
|
58
69
|
const listed = documents.find((d) => docId(d) === docId(ref));
|
|
59
70
|
const home = sameDoc(ref, editor.site.home);
|
|
@@ -145,6 +156,25 @@ export function DocMenu({ label }: { label: string }) {
|
|
|
145
156
|
<hr />
|
|
146
157
|
</>
|
|
147
158
|
)}
|
|
159
|
+
{waitingHere ? (
|
|
160
|
+
<>
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
role="menuitem"
|
|
164
|
+
data-act="discard-document"
|
|
165
|
+
disabled={!!holder}
|
|
166
|
+
onClick={() => (setOpen(false), openDialog(editor, { kind: 'discard', file: waitingHere.file }))}
|
|
167
|
+
>
|
|
168
|
+
<Icon name="undo" />
|
|
169
|
+
{discardWording(waitingHere, editor.you, shared ? 'shared block' : singular(collection)).menu}
|
|
170
|
+
</button>
|
|
171
|
+
{holder ? (
|
|
172
|
+
<div className="why" data-why="discard">
|
|
173
|
+
{holder} is editing it.
|
|
174
|
+
</div>
|
|
175
|
+
) : null}
|
|
176
|
+
</>
|
|
177
|
+
) : null}
|
|
148
178
|
<button type="button" role="menuitem" className="danger" data-act="delete-document" disabled={!!why} onClick={remove}>
|
|
149
179
|
<Icon name="trash" />
|
|
150
180
|
Delete {shared ? 'shared block' : singular(collection)}…
|
|
@@ -159,3 +189,28 @@ export function DocMenu({ label }: { label: string }) {
|
|
|
159
189
|
</div>
|
|
160
190
|
);
|
|
161
191
|
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The open document has no title (blanked, or read that way from its file): it's kept as it
|
|
195
|
+
* is, isn't on the site, and Publish holds it back until it has one. Renaming it gives it one.
|
|
196
|
+
*/
|
|
197
|
+
export function TitleNote() {
|
|
198
|
+
const editor = useEditor();
|
|
199
|
+
const untitled = useStore(editor.doc.state, (s) => s.document.title.trim() === '');
|
|
200
|
+
const readOnly = useStore(editor.access, (a) => a.readOnly);
|
|
201
|
+
if (!untitled) return null;
|
|
202
|
+
return (
|
|
203
|
+
<div className="note warn tight" role="status" data-note="no-title">
|
|
204
|
+
<Icon name="warn" />
|
|
205
|
+
<div>
|
|
206
|
+
<b>{NO_TITLE.note}</b>
|
|
207
|
+
<p>Until it has one, it isn't on the site and Publish leaves it waiting.</p>
|
|
208
|
+
<div className="acts">
|
|
209
|
+
<button type="button" className="btn sm" data-act="rename-untitled" disabled={readOnly} onClick={() => openDialog(editor, { kind: 'rename' })}>
|
|
210
|
+
Rename…
|
|
211
|
+
</button>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
import { titleCase } from '../../../fields.ts';
|
|
14
14
|
import { RESERVED_SLUG, slugify } from '../../../slug.ts';
|
|
15
15
|
import type { Editor } from '../editor.ts';
|
|
16
|
-
import {
|
|
16
|
+
import { displayName } from '../../../live/messages.ts';
|
|
17
|
+
import { docId, ENDPOINTS, MEDIA_ENDPOINTS, TAB_HEADER, sameDoc, type CreateDeleteRefusal, type CreateRequest, type CreateResponse, type DeleteResponse, type DocRef, type MoveResponse, type EditorAnyCollection, type ListedDocument, type Person, type ReplaceFileResponse, type WaitingView } from '../../types.ts';
|
|
17
18
|
|
|
18
19
|
export type Refused = { ok: false; status: number; error: string; code: string | null; usedOn: CreateDeleteRefusal['usedOn'] | null };
|
|
19
20
|
|
|
@@ -274,3 +275,31 @@ export function andList(names: readonly string[]): string {
|
|
|
274
275
|
|
|
275
276
|
/** An address part from what's typed, by the name rule (slug.ts). */
|
|
276
277
|
export const partFrom = (typed: string) => slugify(typed);
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Discard changes' words (the waiting list's Discard…, the document menu's item, the
|
|
281
|
+
* confirmation), from the waiting entry: who else changed it is named ("…including Ann's?").
|
|
282
|
+
*/
|
|
283
|
+
export function discardWording(
|
|
284
|
+
item: Pick<WaitingView, 'title' | 'change' | 'editors' | 'movedFrom'>,
|
|
285
|
+
you: Person | null,
|
|
286
|
+
noun: string,
|
|
287
|
+
): { heading: string; body: string; confirm: string; menu: string } {
|
|
288
|
+
const others = item.editors.filter((e) => !you || e.person.email.toLowerCase() !== you.email.toLowerCase()).map((e) => `${displayName(e.person)}'s`);
|
|
289
|
+
const names = others.length < 2 ? others.join('') : `${others.slice(0, -1).join(', ')} and ${others.at(-1)}`;
|
|
290
|
+
// A moved document waits as new on its new file; it's the move that goes.
|
|
291
|
+
if (item.change === 'new' && !item.movedFrom) {
|
|
292
|
+
return {
|
|
293
|
+
heading: names ? `Discard this new ${noun}, including ${names} changes?` : `Discard this new ${noun}?`,
|
|
294
|
+
body: `${item.title} isn't on the live site yet, so it's removed.`,
|
|
295
|
+
confirm: `Discard this new ${noun}`,
|
|
296
|
+
menu: `Discard this new ${noun}…`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
heading: names ? `Discard the unpublished changes to ${item.title}, including ${names}?` : `Discard the unpublished changes to ${item.title}?`,
|
|
301
|
+
body: item.movedFrom ? `It goes back to ${item.movedFrom}, as it is on the live site.` : item.change === 'deleted' ? 'It comes back, as it is on the live site.' : "It goes back to what's live.",
|
|
302
|
+
confirm: 'Discard changes',
|
|
303
|
+
menu: 'Discard changes…',
|
|
304
|
+
};
|
|
305
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* openDialog(editor, { kind: 'move', ref }) Change address… (pre-M7 item 8)
|
|
12
12
|
* openDialog(editor, { kind: 'rename' }) Rename… on the open document
|
|
13
13
|
* openDialog(editor, { kind: 'replace', key }) Replace… on a file (M6c G4, media/ReplaceDialog.tsx)
|
|
14
|
+
* openDialog(editor, { kind: 'discard', file }) Discard changes… (live mode: back to what's live)
|
|
14
15
|
*
|
|
15
16
|
* Esc closes the dialog only (the editor's own Esc, going up a level, doesn't run).
|
|
16
17
|
* Addresses are shown before creating, made by slug.ts as the server makes them, and
|
|
@@ -28,7 +29,7 @@ import { Icon } from '../shell/Icons.tsx';
|
|
|
28
29
|
import { createStore, type Store } from '../state/store.ts';
|
|
29
30
|
import type { CreateDeleteRefusal, DocRef, ListedDocument } from '../../types.ts';
|
|
30
31
|
import { INSIDE_CLOSE, InsidePicker } from './InsidePicker.tsx';
|
|
31
|
-
import { addressFor, addressesMoving, andList, createDocument, deleteDocument, insideOptions, moveDocument, pagesUnder, singular } from './actions.ts';
|
|
32
|
+
import { addressFor, addressesMoving, andList, createDocument, deleteDocument, discardWording, insideOptions, moveDocument, pagesUnder, singular } from './actions.ts';
|
|
32
33
|
import { filesLabel, inboxFor } from '../inbox/store.ts';
|
|
33
34
|
import { ReplaceDialog } from '../media/ReplaceDialog.tsx';
|
|
34
35
|
|
|
@@ -46,7 +47,9 @@ export type Dialog =
|
|
|
46
47
|
/** M6c G4 Decision 9: Replace… on a file in the library (its key). */
|
|
47
48
|
| { kind: 'replace'; key: string }
|
|
48
49
|
/** Arrange pages (Decision 15): leaving the screen with something arranged; `go` leaves, dropping it. */
|
|
49
|
-
| { kind: 'arrange-leave'; go: () => void }
|
|
50
|
+
| { kind: 'arrange-leave'; go: () => void }
|
|
51
|
+
/** Discard changes (live mode): a waiting document, by its file, back to what's live. */
|
|
52
|
+
| { kind: 'discard'; file: string };
|
|
50
53
|
|
|
51
54
|
const dialogs = new WeakMap<Editor, Store<Dialog | null>>();
|
|
52
55
|
|
|
@@ -85,6 +88,8 @@ export function DocumentDialogs() {
|
|
|
85
88
|
return <ReplaceDialog key={dialog.key} fileKey={dialog.key} />;
|
|
86
89
|
case 'arrange-leave':
|
|
87
90
|
return <LeaveArrange go={dialog.go} />;
|
|
91
|
+
case 'discard':
|
|
92
|
+
return <DiscardChanges key={dialog.file} file={dialog.file} />;
|
|
88
93
|
}
|
|
89
94
|
}
|
|
90
95
|
|
|
@@ -517,12 +522,14 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
|
|
|
517
522
|
*/
|
|
518
523
|
function RenameDocument() {
|
|
519
524
|
const editor = useEditor();
|
|
525
|
+
const title = useStore(editor.doc.state, (s) => s.document.title);
|
|
520
526
|
const current = useStore(editor.doc.state, (s) => s.document.title || s.entry.label);
|
|
521
527
|
// Taken by someone else, signed out or deleted since the menu opened: setTitle would do nothing.
|
|
522
528
|
const readOnly = useStore(editor.access, (a) => a.readOnly);
|
|
523
529
|
const [name, setName] = useState(current);
|
|
524
530
|
const next = name.replace(/\s+/g, ' ').trim();
|
|
525
|
-
|
|
531
|
+
// Finding 15: a document with no title may take the name it's listed by as its title.
|
|
532
|
+
const blocked = !next || next === title || readOnly;
|
|
526
533
|
const confirm = () => {
|
|
527
534
|
if (blocked) return;
|
|
528
535
|
editor.doc.setTitle(next);
|
|
@@ -641,6 +648,57 @@ function NewShared() {
|
|
|
641
648
|
);
|
|
642
649
|
}
|
|
643
650
|
|
|
651
|
+
// ── Discard changes ──────────────────────────────────────────────────────────
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Discard changes (live mode): asks first, naming anyone else whose changes go too, then puts
|
|
655
|
+
* the document back to what's live (editor.discardChanges). The server's sentence when it
|
|
656
|
+
* refuses (someone editing it now, a Publish running). Gone from the waiting list meanwhile:
|
|
657
|
+
* it closes.
|
|
658
|
+
*/
|
|
659
|
+
function DiscardChanges({ file }: { file: string }) {
|
|
660
|
+
const editor = useEditor();
|
|
661
|
+
const item = useStore(editor.liveState, (s) => s.status?.waiting.find((w) => w.file === file) ?? null);
|
|
662
|
+
const you = useStore(editor.liveState, (s) => s.status?.you ?? null);
|
|
663
|
+
const [error, setError] = useState<string | null>(null);
|
|
664
|
+
const [busy, setBusy] = useState(false);
|
|
665
|
+
const [seen] = useState(item);
|
|
666
|
+
const shown = item ?? (busy ? seen : null);
|
|
667
|
+
useEffect(() => {
|
|
668
|
+
if (!shown) closeDialog(editor);
|
|
669
|
+
}, [shown, editor]);
|
|
670
|
+
if (!shown) return null;
|
|
671
|
+
const collection = editor.site.collections.find((c) => c.name === shown.collection);
|
|
672
|
+
const noun = collection?.kind === 'shared' ? 'shared block' : collection ? singular(collection) : 'page';
|
|
673
|
+
const words = discardWording(shown, you ?? editor.you, noun);
|
|
674
|
+
const confirm = async () => {
|
|
675
|
+
setBusy(true);
|
|
676
|
+
setError(null);
|
|
677
|
+
const answer = await editor.discardChanges(shown);
|
|
678
|
+
setBusy(false);
|
|
679
|
+
if (answer.ok) closeDialog(editor);
|
|
680
|
+
else setError(answer.error);
|
|
681
|
+
};
|
|
682
|
+
return (
|
|
683
|
+
<Frame alert label="discard" describedBy="dlg-d">
|
|
684
|
+
<div className="icon-h" style={{ background: 'var(--status-danger-wash)', color: 'var(--status-danger)' }}>
|
|
685
|
+
<Icon name="undo" />
|
|
686
|
+
</div>
|
|
687
|
+
<h2 id="dlg-h">{words.heading}</h2>
|
|
688
|
+
<p id="dlg-d">{words.body}</p>
|
|
689
|
+
<Err id="discard-err" text={error} />
|
|
690
|
+
<div className="acts">
|
|
691
|
+
<button type="button" className="btn" onClick={() => closeDialog(editor)}>
|
|
692
|
+
Cancel
|
|
693
|
+
</button>
|
|
694
|
+
<button type="button" className="btn danger" data-act="confirm-discard" disabled={busy} onClick={() => void confirm()}>
|
|
695
|
+
{words.confirm}
|
|
696
|
+
</button>
|
|
697
|
+
</div>
|
|
698
|
+
</Frame>
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
644
702
|
// ── Delete ───────────────────────────────────────────────────────────────────
|
|
645
703
|
|
|
646
704
|
function DeleteDocument({ docRef }: { docRef: DocRef }) {
|