caracms 0.7.0 → 0.8.1

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.
Files changed (75) hide show
  1. package/README.md +108 -19
  2. package/bin/caracms.mjs +11 -3
  3. package/bin/init.mjs +332 -34
  4. package/bin/media.mjs +60 -6
  5. package/bin/templates/railway-cloud.ts.txt +73 -0
  6. package/package.json +1 -1
  7. package/skills/caracms-adopt/SKILL.md +30 -13
  8. package/skills/caracms-hosting/SKILL.md +240 -0
  9. package/skills/caracms-tina/SKILL.md +45 -15
  10. package/src/body.ts +55 -0
  11. package/src/cli/validate.ts +1 -1
  12. package/src/cloud-middleware.ts +51 -5
  13. package/src/config.ts +20 -1
  14. package/src/content.ts +15 -6
  15. package/src/document-problems.ts +48 -0
  16. package/src/editor/client/collections/CollectionGrid.tsx +2 -2
  17. package/src/editor/client/collections/CollectionTable.tsx +3 -3
  18. package/src/editor/client/documents/DocMenu.tsx +56 -1
  19. package/src/editor/client/documents/actions.ts +30 -1
  20. package/src/editor/client/documents/dialogs.tsx +61 -3
  21. package/src/editor/client/editor.ts +98 -2
  22. package/src/editor/client/fields/LockupField.tsx +4 -4
  23. package/src/editor/client/inbox/InboxStage.tsx +25 -1
  24. package/src/editor/client/live/notices.tsx +19 -0
  25. package/src/editor/client/live/publish.tsx +58 -2
  26. package/src/editor/client/nav/DocRow.tsx +4 -4
  27. package/src/editor/client/pane/levels/CollectionLevel.tsx +2 -1
  28. package/src/editor/client/pane/levels/DocumentLevel.tsx +2 -1
  29. package/src/editor/client/pane/levels/DocumentsLevel.tsx +2 -2
  30. package/src/editor/client/pane/levels/FormLevel.tsx +33 -6
  31. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +19 -2
  32. package/src/editor/client/pane/parts.tsx +67 -4
  33. package/src/editor/client/pane/switcher.tsx +4 -4
  34. package/src/editor/client/shell/Bar.tsx +11 -0
  35. package/src/editor/client/state/keeper.ts +28 -4
  36. package/src/editor/client/state/live.ts +18 -1
  37. package/src/editor/routes/_live.ts +1 -1
  38. package/src/editor/routes/admin.astro +9 -0
  39. package/src/editor/routes/discard.ts +11 -0
  40. package/src/editor/server/admin-props.ts +18 -2
  41. package/src/editor/server/arrange.ts +4 -4
  42. package/src/editor/server/create.ts +4 -4
  43. package/src/editor/server/drafts-store.ts +2 -2
  44. package/src/editor/server/empty-site.ts +23 -0
  45. package/src/editor/server/media/cloud-mux.ts +4 -1
  46. package/src/editor/server/media/config.ts +12 -5
  47. package/src/editor/server/media/upload.ts +2 -41
  48. package/src/editor/server/mode.ts +3 -1
  49. package/src/editor/server/move.ts +32 -4
  50. package/src/editor/server/write.ts +4 -4
  51. package/src/editor/styles/levels/live.css +2 -0
  52. package/src/editor/types.ts +92 -4
  53. package/src/email.ts +4 -1
  54. package/src/forms/cloud-store.ts +4 -1
  55. package/src/forms/inbox.ts +10 -5
  56. package/src/forms/store.ts +21 -4
  57. package/src/index.ts +21 -6
  58. package/src/live/auth/login.ts +4 -3
  59. package/src/live/auth/views.ts +8 -1
  60. package/src/live/autosaves.ts +94 -6
  61. package/src/live/cloud.ts +64 -20
  62. package/src/live/drafts-store.ts +17 -6
  63. package/src/live/editor.ts +232 -16
  64. package/src/live/git.ts +492 -22
  65. package/src/live/github.ts +84 -17
  66. package/src/live/locks.ts +22 -0
  67. package/src/live/messages.ts +49 -2
  68. package/src/live/mode.ts +19 -8
  69. package/src/live/notices.ts +5 -1
  70. package/src/live/runtime.ts +2 -1
  71. package/src/live/services.ts +114 -0
  72. package/src/live/status.ts +5 -0
  73. package/src/live/sweep.ts +44 -1
  74. package/src/live/webhook.ts +8 -3
  75. package/src/media.ts +9 -3
@@ -14,13 +14,23 @@
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. The build ignores `github.repo`: a site with no repo linked still builds.
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.
25
+ *
26
+ * With the config's `services` (caracms-cloud M5): `'self-hosted'` never fetches (the key is
27
+ * ignored); `'cloud'` without the key fails the build with Decision 4's sentence. The build
28
+ * names the ignored variables once, here; the server, at its first request (`cloudMedia`).
20
29
  */
21
30
  import type { MiddlewareHandler } from 'astro';
22
31
  import { CLOUD_NO_MEDIA, CLOUD_UNREACHABLE_BUILD, isCloudRefusal, heldCloudSettings, loadCloudSettings } from './live/cloud.ts';
23
32
  import { cloudFormsConflict } from './live/mode.ts';
33
+ import { CLOUD_KEY_MISSING_PROBLEM, servicesEnv, type Services } from './live/services.ts';
24
34
  import { cloudMedia } from './media.ts';
25
35
 
26
36
  type Env = Record<string, string | undefined>;
@@ -63,12 +73,48 @@ export const onRequest: MiddlewareHandler = async (_context, next) => {
63
73
  return next();
64
74
  };
65
75
 
66
- /** The build's start: the settings, or an Error with the sentence that fails the build. Nothing for a site off the cloud. */
67
- export async function cloudSettingsForBuild(env: Env = process.env): Promise<void> {
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
+
107
+ /**
108
+ * The build's start: the settings, or an Error with the sentence that fails the build. Nothing for a site off the cloud.
109
+ * `services` is the config's (the integration passes it: this runs outside the bundle, where the build's defines don't reach).
110
+ */
111
+ export async function cloudSettingsForBuild(env: Env = process.env, services: Services | null = null, retry: BuildRetry = {}): Promise<void> {
112
+ env = servicesEnv(env, services);
113
+ if (services === 'cloud' && !env.CARACMS_CLOUD_KEY?.trim()) throw new Error(`caracms: ${CLOUD_KEY_MISSING_PROBLEM}`);
68
114
  // caracms-cloud M4 Decision 22: the key with the site's own forms and email variables fails the build too.
69
115
  const conflict = cloudFormsConflict(env);
70
116
  const forms = conflict ? [conflict] : [];
71
- const cloud = cloudMedia(env);
117
+ const cloud = cloudMedia(env, services);
72
118
  if (!cloud) {
73
119
  if (forms.length) throw new Error(`caracms: ${forms.join(' ')}`);
74
120
  return;
@@ -76,7 +122,7 @@ export async function cloudSettingsForBuild(env: Env = process.env): Promise<voi
76
122
  if (cloud.problems.length || forms.length) throw new Error(`caracms: ${[...cloud.problems, ...forms].join(' ')}`);
77
123
  let settings;
78
124
  try {
79
- settings = await loadCloudSettings(cloud.connection);
125
+ settings = await loadForBuild(cloud.connection, retry);
80
126
  } catch (error) {
81
127
  const message = isCloudRefusal(error) && error.error !== 'unreachable' ? error.message : CLOUD_UNREACHABLE_BUILD;
82
128
  throw new Error(`caracms: ${message}`);
package/src/config.ts CHANGED
@@ -253,8 +253,19 @@ export interface SiteConfig {
253
253
  * whose pages all show the site's own title.
254
254
  */
255
255
  titleFallback?: TitleFallback;
256
+ /**
257
+ * Where the site's services come from (caracms-cloud M5 Decision 1): GitHub access,
258
+ * media, forms and email, video. `'cloud'`: CaraCMS Cloud, through `CARACMS_CLOUD_KEY`.
259
+ * `'self-hosted'`: the site's own variables. The other mode's variables are ignored,
260
+ * with a warning (live/services.ts). Unset, the key decides, as before M5 (Decision 3).
261
+ * `caracms init --cloud` or `--self-hosted` writes it.
262
+ */
263
+ services?: Services;
256
264
  }
257
265
 
266
+ /** The config's `services` values (live/services.ts). */
267
+ export type Services = 'cloud' | 'self-hosted';
268
+
258
269
  /** The config's `titleFallback` values (head.ts). */
259
270
  export type TitleFallback = 'page-and-brand' | 'site';
260
271
 
@@ -366,6 +377,8 @@ export interface ResolvedSite {
366
377
  titleSeparator: string;
367
378
  /** What a page without its own `seo.title` falls back to (`'page-and-brand'` when the config sets none). */
368
379
  titleFallback: TitleFallback;
380
+ /** Where the site's services come from (caracms-cloud M5 Decision 1); null when the config doesn't say. */
381
+ services: Services | null;
369
382
  /**
370
383
  * M8 Decision 10: the URLs the site's own `src/pages/` serves (code-routes.ts), read by the
371
384
  * integration at setup (and by the commands). Absent before then: nothing is reserved.
@@ -628,7 +641,13 @@ export function resolveSite(config: SiteConfig, root: string): ResolvedSite {
628
641
  fail(`titleFallback must be 'page-and-brand' (the page's title and the site's name) or 'site' (Site Settings' title on every page) (got ${JSON.stringify(config.titleFallback)}).`);
629
642
  }
630
643
 
631
- return { root, collections, blocks, textStyles, page: abs(config.page), brand, sharedFolder, phoneLinesWidth, repoPath: repoPath!, timeZone, forms, titleSeparator, titleFallback };
644
+ // caracms-cloud M5 Decision 1: where the site's services come from.
645
+ const services = config.services ?? null;
646
+ if (services !== null && services !== 'cloud' && services !== 'self-hosted') {
647
+ fail(`services must be 'cloud' (CaraCMS Cloud) or 'self-hosted' (the site's own GitHub access, bucket, imgproxy and so on) (got ${JSON.stringify(config.services)}).`);
648
+ }
649
+
650
+ return { root, collections, blocks, textStyles, page: abs(config.page), brand, sharedFolder, phoneLinesWidth, repoPath: repoPath!, timeZone, forms, titleSeparator, titleFallback, services };
632
651
  }
633
652
 
634
653
  interface FieldCheck {
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
- if (entry.warning || !document) throw new DocumentError(entry.warning!.code, entry.warning!.message);
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
- const title = (raw as { title?: unknown } | null)?.title;
433
- if (typeof title !== 'string' || title.trim() === '') {
434
- return warn('no-title', 'This file has no title, so it can’t be opened. Add a "title".');
435
- }
436
- const entry: DocumentEntry = { ...base, title, label: title, warning: null };
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">, can't be opened: {doc.warning.message}</span>
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">, can't be opened: {doc.warning.message}</span>
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 { docId, ENDPOINTS, MEDIA_ENDPOINTS, TAB_HEADER, sameDoc, type CreateDeleteRefusal, type CreateRequest, type CreateResponse, type DeleteResponse, type DocRef, type MoveResponse, type EditorAnyCollection, type ListedDocument, type ReplaceFileResponse } from '../../types.ts';
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
- const blocked = !next || next === current || readOnly;
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 }) {