caracms 0.3.3 → 0.4.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 +59 -3
- package/bin/caracms.mjs +30 -6
- package/bin/gate.mjs +157 -6
- package/bin/templates/catch-all.astro.txt +10 -9
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +21 -4
- package/skills/caracms-blocks/SKILL.md +36 -13
- package/skills/caracms-content/SKILL.md +27 -6
- package/skills/caracms-tina/SKILL.md +53 -11
- package/skills/caracms-tina/field-kinds.md +2 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
- package/src/cli/validate.ts +51 -10
- package/src/code-routes.ts +26 -0
- package/src/components/Head.astro +5 -5
- package/src/components/RenderDocument.astro +3 -3
- package/src/components/sitemap.xml.ts +5 -4
- package/src/config.ts +57 -11
- package/src/content-schema.ts +5 -2
- package/src/content.ts +148 -8
- package/src/document.ts +8 -0
- package/src/editor/client/collections/CollectionTable.tsx +9 -1
- package/src/editor/client/documents/actions.ts +61 -16
- package/src/editor/client/documents/dialogs.tsx +104 -15
- package/src/editor/client/editor.ts +22 -1
- package/src/editor/client/fields/HiddenToggle.tsx +18 -2
- package/src/editor/client/nav/DocRow.tsx +8 -1
- package/src/editor/client/nav/tree.ts +26 -1
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +58 -13
- package/src/editor/client/pane/parts.tsx +14 -1
- package/src/editor/routes/move.ts +1 -1
- package/src/editor/server/admin-props.ts +19 -1
- package/src/editor/server/create.ts +13 -2
- package/src/editor/server/drafts-store.ts +24 -9
- package/src/editor/server/move.ts +266 -81
- package/src/editor/styles/editor.css +5 -0
- package/src/editor/styles/levels/collections.css +2 -0
- package/src/editor/styles/levels/m5a-documents.css +15 -0
- package/src/editor/types.ts +31 -5
- package/src/held.ts +116 -0
- package/src/index.ts +4 -2
- package/src/live/README.md +3 -2
- package/src/live/auth/middleware.ts +11 -0
- package/src/live/autosaves.ts +88 -1
- package/src/live/cloud.ts +131 -0
- package/src/live/drafts-store.ts +122 -87
- package/src/live/editor.ts +22 -4
- package/src/live/git.ts +63 -12
- package/src/live/github.ts +69 -7
- package/src/live/mode.ts +42 -6
- package/src/live/runtime.ts +18 -2
- package/src/redirect-middleware.ts +3 -0
- package/src/redirects.ts +32 -1
- package/src/relink.ts +40 -9
- package/src/serialize.ts +1 -1
- package/src/slug.ts +15 -4
package/src/content-schema.ts
CHANGED
|
@@ -126,13 +126,16 @@ function document(collection: ResolvedCollection, properties: Record<string, Sch
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
/** A collection's schema (`schemas/<collection>.schema.json`, or the reserved two's names). */
|
|
129
|
-
export function collectionSchema(site: Pick<ResolvedSite, 'blocks'
|
|
129
|
+
export function collectionSchema(site: Pick<ResolvedSite, 'blocks'> & Partial<Pick<ResolvedSite, 'collections'>>, collection: ResolvedCollection): Schema {
|
|
130
130
|
const own = Object.fromEntries(Object.entries(collection.fields ?? {}).map(([key, f]) => [key, fieldSchema(f)]));
|
|
131
131
|
switch (collection.kind) {
|
|
132
132
|
case 'sections':
|
|
133
133
|
// M9 Decision 7: `hidden: true` keeps the page off the site; absent, it's shown.
|
|
134
134
|
const hidden: Schema = { description: 'Hidden from the site: not served, not in the sitemap, still edited in /admin.', const: true };
|
|
135
|
-
|
|
135
|
+
// Collections under pages (Decision 5): a page of a collection some collection's heldBy names may hold one of them.
|
|
136
|
+
const holdable = Object.values(site.collections ?? {}).filter((c) => c.heldBy === collection.name).map((c) => c.name);
|
|
137
|
+
const holds: Schema | null = holdable.length ? { description: 'The collection this page holds: its documents are at this page\'s address + their slug.', enum: holdable } : null;
|
|
138
|
+
return document(collection, { hidden, ...(holds ? { holds } : {}), ...own, blocks: { type: 'array', items: { anyOf: [{ $ref: '#/$defs/shared' }, anyBlock(site)] } }, seo }, ['title', 'blocks'], { shared: sharedEntry, ...blockDefs(site) });
|
|
136
139
|
case 'fields':
|
|
137
140
|
return document(collection, { ...own, seo }, ['title']);
|
|
138
141
|
case 'shared':
|
package/src/content.ts
CHANGED
|
@@ -36,6 +36,9 @@ import { isBuiltInColumn } from './columns.ts';
|
|
|
36
36
|
import { fieldsCover, sectionsCover, type CardCover } from './cards.ts';
|
|
37
37
|
import { own, parseAnyDocument, type AnyDocument, type DocumentKind } from './document.ts';
|
|
38
38
|
import { parseSource } from './json-source.ts';
|
|
39
|
+
import { hasHeld, heldBases, type HeldBase } from './held.ts';
|
|
40
|
+
import { leftBehindMessage, leftBehindRoutes, ownRoutedCollections } from './code-routes.ts';
|
|
41
|
+
import { sameAddress } from './slug.ts';
|
|
39
42
|
|
|
40
43
|
// Posts and tags, Decision 14: a route's reader on the disk (read.ts).
|
|
41
44
|
export { contentReader } from './read.ts';
|
|
@@ -79,6 +82,16 @@ export interface DocumentEntry {
|
|
|
79
82
|
*/
|
|
80
83
|
hidden?: true;
|
|
81
84
|
hiddenBy?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Collections under pages (Decision 11): set when `hiddenBy` is a page of another collection,
|
|
87
|
+
* the page that holds this document's collection (`pages`). Absent: `hiddenBy` is in this one.
|
|
88
|
+
*/
|
|
89
|
+
hiddenByCollection?: string;
|
|
90
|
+
/**
|
|
91
|
+
* Collections under pages (Decision 5): a page's `"holds"`, the collection it holds (held.ts).
|
|
92
|
+
* Read from a file that can't be opened too, as far as it can be, so a broken holder is named.
|
|
93
|
+
*/
|
|
94
|
+
holds?: string;
|
|
82
95
|
/** A section builder's shared-block entries, by shared block path, in block order, once each (Decision 8). Absent otherwise. */
|
|
83
96
|
uses?: string[];
|
|
84
97
|
/** A shared block: its block's type (`''` when it has none). Absent otherwise. */
|
|
@@ -220,13 +233,17 @@ export function resolveDocumentFile(site: Pick<ResolvedSite, 'collections'>, col
|
|
|
220
233
|
return file;
|
|
221
234
|
}
|
|
222
235
|
|
|
223
|
-
/**
|
|
236
|
+
/**
|
|
237
|
+
* Every document in one collection, in several (in the order given), or in all of them (in
|
|
238
|
+
* config order), with warnings, in the editor's order. A held collection's documents have
|
|
239
|
+
* their resolved addresses (held.ts), whichever collections are listed.
|
|
240
|
+
*/
|
|
224
241
|
export async function listDocuments(
|
|
225
242
|
site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>,
|
|
226
|
-
collectionName?: string,
|
|
243
|
+
collectionName?: string | readonly string[],
|
|
227
244
|
source: ContentSource = diskSource,
|
|
228
245
|
): Promise<DocumentEntry[]> {
|
|
229
|
-
const names = collectionName
|
|
246
|
+
const names = collectionName === undefined ? Object.keys(site.collections) : typeof collectionName === 'string' ? [collectionName] : [...collectionName];
|
|
230
247
|
const all: DocumentEntry[] = [];
|
|
231
248
|
for (const name of names) {
|
|
232
249
|
const collection = own(site.collections, name);
|
|
@@ -236,7 +253,20 @@ export async function listDocuments(
|
|
|
236
253
|
if (collection.nested) hideChildren(entries);
|
|
237
254
|
all.push(...(collection.nested ? flattenTree(pageTree(entries)) : entries.sort(byLabel)));
|
|
238
255
|
}
|
|
239
|
-
|
|
256
|
+
// Collections under pages (Decision 7): a held collection's addresses come from its holder's listing.
|
|
257
|
+
const held = names.map((n) => site.collections[n]!).filter((c) => c.heldBy);
|
|
258
|
+
if (held.length) {
|
|
259
|
+
const holding: DocumentEntry[] = [];
|
|
260
|
+
for (const by of new Set(held.map((c) => c.heldBy!))) {
|
|
261
|
+
holding.push(...(names.includes(by) ? all.filter((e) => e.collection === by) : await listDocuments(site, by, source)));
|
|
262
|
+
}
|
|
263
|
+
const bases = heldBases(site.collections, holding);
|
|
264
|
+
for (const entry of all) {
|
|
265
|
+
const base = own(bases, entry.collection);
|
|
266
|
+
if (base) placeHeld(entry, base);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (collectionName === undefined) {
|
|
240
270
|
// Decision 8's "used on": every document whose block list has an entry for the shared block.
|
|
241
271
|
for (const entry of all) {
|
|
242
272
|
if (entry.kind !== 'shared') continue;
|
|
@@ -246,6 +276,44 @@ export async function listDocuments(
|
|
|
246
276
|
return all;
|
|
247
277
|
}
|
|
248
278
|
|
|
279
|
+
/** A held document's address and hidden mark from its collection's resolved base (held.ts). */
|
|
280
|
+
function placeHeld(entry: DocumentEntry, held: HeldBase): void {
|
|
281
|
+
entry.url = held.base === null ? null : held.base + entry.path;
|
|
282
|
+
// Decision 11: a hidden holder hides its documents, as a hidden page hides the pages under it.
|
|
283
|
+
if (held.hidden && held.holder) {
|
|
284
|
+
entry.hidden = true;
|
|
285
|
+
entry.hiddenBy = held.holder.path;
|
|
286
|
+
entry.hiddenByCollection = held.holder.collection;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** A held collection's resolved base, from its holder collection's listing through `source`. */
|
|
291
|
+
export async function heldBaseOf(site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>, collectionName: string, source: ContentSource = diskSource): Promise<HeldBase | null> {
|
|
292
|
+
const collection = own(site.collections, collectionName);
|
|
293
|
+
if (!collection?.heldBy) return null;
|
|
294
|
+
return heldBases({ [collection.name]: collection }, await listDocuments(site, collection.heldBy, source))[collection.name] ?? null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Every held collection's resolved base (held.ts), from the listings of the collections that hold them; `{}` on a site with none. */
|
|
298
|
+
export async function heldBasesOf(site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>, source: ContentSource = diskSource): Promise<Record<string, HeldBase>> {
|
|
299
|
+
if (!hasHeld(site.collections)) return {};
|
|
300
|
+
const by = [...new Set(Object.values(site.collections).flatMap((c) => (c.heldBy ? [c.heldBy] : [])))];
|
|
301
|
+
const holding = (await Promise.all(by.map((name) => listDocuments(site, name, source)))).flat();
|
|
302
|
+
return heldBases(site.collections, holding);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* A document's public address as its listing gives it: `documentUrl` for a collection with a
|
|
307
|
+
* prefix, the holder's address + `/` + the path for a held one (null while it isn't held).
|
|
308
|
+
*/
|
|
309
|
+
export async function documentAddress(site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>, collectionName: string, path: string, source: ContentSource = diskSource): Promise<string | null> {
|
|
310
|
+
const collection = own(site.collections, collectionName);
|
|
311
|
+
if (!collection) return null;
|
|
312
|
+
if (!collection.heldBy) return documentUrl(collection, path);
|
|
313
|
+
const held = await heldBaseOf(site, collectionName, source);
|
|
314
|
+
return held?.base ? held.base + path : null;
|
|
315
|
+
}
|
|
316
|
+
|
|
249
317
|
/** M9 Decision 7: a hidden page's children are hidden with it; `hiddenBy` names the topmost hidden page above. */
|
|
250
318
|
function hideChildren(entries: DocumentEntry[]): void {
|
|
251
319
|
const hidden = new Set(entries.filter((e) => e.hidden).map((e) => e.path));
|
|
@@ -279,6 +347,10 @@ export async function readDocument(
|
|
|
279
347
|
if (text === null) throw new DocumentError('not-found', `${collection.label}: no document "${path}".`);
|
|
280
348
|
const { entry, document } = inspect(site, collection, path, text);
|
|
281
349
|
if (entry.warning || !document) throw new DocumentError(entry.warning!.code, entry.warning!.message);
|
|
350
|
+
if (collection.heldBy) {
|
|
351
|
+
const held = await heldBaseOf(site, collection.name, source);
|
|
352
|
+
if (held) placeHeld(entry, held);
|
|
353
|
+
}
|
|
282
354
|
return { entry, text, document };
|
|
283
355
|
}
|
|
284
356
|
|
|
@@ -312,10 +384,12 @@ function inspect(
|
|
|
312
384
|
url: documentUrl(collection, path),
|
|
313
385
|
};
|
|
314
386
|
const fileLabel = titleCase(path.split('/').pop()!);
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
387
|
+
// Decision 13: a broken holder is named, so its `"holds"` is read from the text as far as it can be.
|
|
388
|
+
const holdsIn = () => (collection.kind === 'sections' ? /"holds"\s*:\s*"([^"\\]*)"/.exec(text)?.[1] : undefined);
|
|
389
|
+
const warn = (code: WarningCode, message: string, title = '') => {
|
|
390
|
+
const holds = holdsIn();
|
|
391
|
+
return { entry: { ...base, title, label: title || fileLabel, warning: { code, message }, ...(holds !== undefined ? { holds } : {}) }, document: null };
|
|
392
|
+
};
|
|
319
393
|
|
|
320
394
|
if (collection.nested && path.endsWith('/index')) {
|
|
321
395
|
const parent = path.slice(0, -'/index'.length);
|
|
@@ -336,6 +410,7 @@ function inspect(
|
|
|
336
410
|
const entry: DocumentEntry = { ...base, title, label: title, warning: null };
|
|
337
411
|
// M9 Decision 7: the home page is never hidden.
|
|
338
412
|
if ((document.kind === undefined || document.kind === 'sections') && document.hidden && path !== 'index') entry.hidden = true;
|
|
413
|
+
if ((document.kind === undefined || document.kind === 'sections') && document.holds !== undefined) entry.holds = document.holds;
|
|
339
414
|
if (document.kind === undefined || document.kind === 'sections') {
|
|
340
415
|
entry.uses = [...new Set(document.blocks.flatMap((b) => (b.shared !== undefined ? [b.shared] : [])))];
|
|
341
416
|
} else if (document.kind === 'shared') {
|
|
@@ -422,3 +497,68 @@ const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true })
|
|
|
422
497
|
function byLabel(a: { label: string; path: string }, b: { label: string; path: string }): number {
|
|
423
498
|
return collator.compare(a.label, b.label) || collator.compare(a.path, b.path);
|
|
424
499
|
}
|
|
500
|
+
|
|
501
|
+
// ── The catch-all's reader (collections under pages, Decision 14) ────────────────
|
|
502
|
+
|
|
503
|
+
/** A public page the site's catch-all serves: its collection, its path there, and its address. */
|
|
504
|
+
export interface AddressedDocument {
|
|
505
|
+
collection: string;
|
|
506
|
+
path: string;
|
|
507
|
+
/** `/`, `/about`, `/journal/a-day-on-the-ferry`: no trailing slash, as the listing gives it. */
|
|
508
|
+
url: string;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** What the reader reads: the site, and the routes in its `src/pages/` (the integration sets `pageRoutes`). */
|
|
512
|
+
type CatchAllSite = Pick<ResolvedSite, 'collections' | 'root' | 'blocks'> & Partial<Pick<ResolvedSite, 'pageRoutes'>>;
|
|
513
|
+
|
|
514
|
+
const logged = new Set<string>();
|
|
515
|
+
/** The build's and dev's log, once per sentence per process (Decision 13: logged, never a failed build). */
|
|
516
|
+
function logOnce(message: string): void {
|
|
517
|
+
if (logged.has(message)) return;
|
|
518
|
+
logged.add(message);
|
|
519
|
+
console.warn(`[caracms] ${message}`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Every public page of every collection with an address, for the site's one catch-all route
|
|
524
|
+
* (`src/pages/[...path].astro`'s `getStaticPaths`): Pages, every held collection's documents at
|
|
525
|
+
* their holder's address, and every prefix collection's (`/tags/<slug>`), in config order then
|
|
526
|
+
* the editor's. Left out: a file that can't be opened, a hidden page (and the documents of a
|
|
527
|
+
* hidden holder), a held collection no page holds, and a prefix collection whose base has its
|
|
528
|
+
* own route (`src/pages/tags/[slug].astro`), which that route serves. A holder problem or a
|
|
529
|
+
* route left at a held base is logged once (`caracms validate` reports both as errors).
|
|
530
|
+
* `source`: where the files come from (the disk by default, as a build reads them).
|
|
531
|
+
*/
|
|
532
|
+
export async function addressedDocuments(site: CatchAllSite, source: ContentSource = diskSource): Promise<AddressedDocument[]> {
|
|
533
|
+
const collections = Object.values(site.collections);
|
|
534
|
+
const routes = site.pageRoutes ?? [];
|
|
535
|
+
const ownRouted = new Set(ownRoutedCollections(routes, collections).map((c) => c.name));
|
|
536
|
+
const names = collections.filter((c) => (c.urlBase !== null || c.heldBy) && !ownRouted.has(c.name)).map((c) => c.name);
|
|
537
|
+
const entries = await listDocuments(site, names, source);
|
|
538
|
+
if (hasHeld(site.collections)) {
|
|
539
|
+
const held: { name: string; label: string; base: string; holder: string }[] = [];
|
|
540
|
+
for (const base of Object.values(await heldBasesOf(site, source))) {
|
|
541
|
+
if (base.problem) logOnce(base.problem);
|
|
542
|
+
const c = site.collections[base.collection]!;
|
|
543
|
+
if (base.base && base.holder) held.push({ name: c.name, label: c.label, base: base.base, holder: base.holder.label });
|
|
544
|
+
}
|
|
545
|
+
for (const left of leftBehindRoutes(routes, held)) logOnce(leftBehindMessage(left));
|
|
546
|
+
}
|
|
547
|
+
return entries.filter((e): e is DocumentEntry & { url: string } => e.url !== null && !e.warning && !e.hidden).map(({ collection, path, url }) => ({ collection, path, url }));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* The document a request's path is (`/journal/a-day-on-the-ferry`, with or without a trailing
|
|
552
|
+
* slash, URL-encoded or not), for a catch-all that renders on demand; null when no public page
|
|
553
|
+
* is there (`addressedDocuments` says which are).
|
|
554
|
+
*/
|
|
555
|
+
export async function documentAt(site: CatchAllSite, pathname: string, source: ContentSource = diskSource): Promise<{ collection: string; path: string } | null> {
|
|
556
|
+
let at: string;
|
|
557
|
+
try {
|
|
558
|
+
at = sameAddress(decodeURIComponent(pathname));
|
|
559
|
+
} catch {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
const found = (await addressedDocuments(site, source)).find((d) => sameAddress(d.url) === at);
|
|
563
|
+
return found ? { collection: found.collection, path: found.path } : null;
|
|
564
|
+
}
|
package/src/document.ts
CHANGED
|
@@ -67,6 +67,13 @@ export interface SiteDocument {
|
|
|
67
67
|
* the key out of the file. The home page (`index`) can't be hidden (content.ts reads it as shown).
|
|
68
68
|
*/
|
|
69
69
|
hidden?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Collections under pages (Decision 5): the collection this page holds (the file's
|
|
72
|
+
* top-level `"holds"`, after `title` and `hidden`): its documents are at this page's
|
|
73
|
+
* address + `/` + their slug (held.ts). Edited in the file only, never by the editor,
|
|
74
|
+
* whose saves keep it where it is.
|
|
75
|
+
*/
|
|
76
|
+
holds?: string;
|
|
70
77
|
/**
|
|
71
78
|
* M6c (G2): the page fields' parsed values, keyed by field, every field of the
|
|
72
79
|
* collection's `fields` present (the file's top-level keys after `title`). Present
|
|
@@ -225,6 +232,7 @@ export function parseDocument(raw: unknown, site: Pick<ResolvedSite, 'blocks'>):
|
|
|
225
232
|
blocks: rawBlocks.map((b, index) => parseBlock(b, index, site)),
|
|
226
233
|
...seoOf(doc),
|
|
227
234
|
...(doc.hidden === true ? { hidden: true } : {}),
|
|
235
|
+
...(typeof doc.holds === 'string' ? { holds: doc.holds } : {}),
|
|
228
236
|
};
|
|
229
237
|
}
|
|
230
238
|
|
|
@@ -28,7 +28,7 @@ import { useEditor, useStore } from '../context.tsx';
|
|
|
28
28
|
import type { Editor } from '../editor.ts';
|
|
29
29
|
import { createStore, type Store } from '../state/store.ts';
|
|
30
30
|
import { Icon } from '../shell/Icons.tsx';
|
|
31
|
-
import { docIcon } from '../nav/DocRow.tsx';
|
|
31
|
+
import { docIcon, hiddenTitle } from '../nav/DocRow.tsx';
|
|
32
32
|
import { DocMarksView } from '../live/marks.tsx';
|
|
33
33
|
import { when } from '../live/format.ts';
|
|
34
34
|
import { openDialog } from '../documents/dialogs.tsx';
|
|
@@ -378,6 +378,14 @@ function Row(props: { doc: ListedDocument; columns: readonly TableColumn[]; curr
|
|
|
378
378
|
<span className="sr">, can't be opened: {doc.warning.message}</span>
|
|
379
379
|
</span>
|
|
380
380
|
) : null}
|
|
381
|
+
{/* M9 Decision 7, collections under pages (Decision 11): hidden, or hidden with the page that holds it, named. */}
|
|
382
|
+
{/* The sidebar's mark: the word, small and grey; the page that hides it on hover and to a screen reader. */}
|
|
383
|
+
{doc.hidden ? (
|
|
384
|
+
<span className="hmark" data-mark="hidden" title={hiddenTitle(doc, editor.documents.get())}>
|
|
385
|
+
Hidden
|
|
386
|
+
{doc.hiddenBy ? <span className="sr">: {hiddenTitle(doc, editor.documents.get())}</span> : null}
|
|
387
|
+
</span>
|
|
388
|
+
) : null}
|
|
381
389
|
{editor.mode === 'live' ? <DocMarksView file={doc.file} /> : <span data-slot="presence" hidden />}
|
|
382
390
|
</span>
|
|
383
391
|
{props.extra ? <span className="ct-extra">{props.extra}</span> : null}
|
|
@@ -70,31 +70,76 @@ export async function deleteDocument(editor: Editor, ref: DocRef, title: string)
|
|
|
70
70
|
/**
|
|
71
71
|
* Change a document's address (pre-M7 item 8). Its unsent edits go first (the move
|
|
72
72
|
* carries them), then the document opens at its new address in place of the old one,
|
|
73
|
-
* and the list is read again (other documents' links changed too).
|
|
73
|
+
* and the list is read again (other documents' links changed too). `redirect` false: no
|
|
74
|
+
* redirect from the old address.
|
|
74
75
|
*/
|
|
75
|
-
export async function moveDocument(editor: Editor, ref: DocRef, to: string, title: string): Promise<{ ok: true; body: MoveResponse } | Refused> {
|
|
76
|
-
|
|
77
|
-
const
|
|
76
|
+
export async function moveDocument(editor: Editor, ref: DocRef, to: string, title: string, redirect = true): Promise<{ ok: true; body: MoveResponse } | Refused> {
|
|
77
|
+
// Everything under it moves with it (collections under pages, Decision 9): each of those documents' unsent edits too.
|
|
78
|
+
const moving = addressesMoving(editor.site?.collections ?? [], ref, to, editor.documents?.get() ?? []).map((m) => ({ collection: m.doc.collection, path: m.doc.path }));
|
|
79
|
+
const refs = moving.some((m) => sameDoc(m, ref)) ? moving : [ref, ...moving];
|
|
78
80
|
// Nothing moves while the keeper still holds edits the server hasn't got (a held conflict, a
|
|
79
81
|
// signed-out session, a refused write): the move would discard them.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
flushed =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
for (const r of refs) {
|
|
83
|
+
const id = docId(r);
|
|
84
|
+
let flushed = true;
|
|
85
|
+
await editor.keeper?.flush(id).catch(() => {
|
|
86
|
+
flushed = false;
|
|
87
|
+
});
|
|
88
|
+
if (!flushed || editor.keeper?.dirty(id)) {
|
|
89
|
+
const what = sameDoc(r, ref) ? title : (editor.documents?.get().find((d) => sameDoc(d, r))?.label ?? r.path);
|
|
90
|
+
return { ok: false, status: 0, error: `${what} has changes that haven't been saved yet, so ${sameDoc(r, ref) ? 'its' : `${title}'s`} address can't change. Sort those out first (Use mine or Discard mine, or sign in again), then try again.`, code: 'unsent', usedOn: null };
|
|
91
|
+
}
|
|
86
92
|
}
|
|
87
|
-
const answer = await post<MoveResponse>(editor, ENDPOINTS.move, { collection: ref.collection, path: ref.path, to });
|
|
93
|
+
const answer = await post<MoveResponse>(editor, ENDPOINTS.move, { collection: ref.collection, path: ref.path, to, ...(redirect ? {} : { redirect: false }) });
|
|
88
94
|
if (!answer.ok) return answer;
|
|
89
|
-
editor.keeper?.discard(id);
|
|
90
|
-
await editor.refreshDocuments().catch(() => undefined);
|
|
91
|
-
if (open) await editor.openDocument({ collection: answer.body.collection, path: answer.body.path }, { history: 'replace' });
|
|
92
95
|
const { url, from } = answer.body;
|
|
93
|
-
|
|
94
|
-
|
|
96
|
+
const moved = answer.body.moved ?? [];
|
|
97
|
+
for (const r of refs) editor.keeper?.discard(docId(r));
|
|
98
|
+
await editor.refreshDocuments().catch(() => undefined);
|
|
99
|
+
// The open document goes with it when it's under it.
|
|
100
|
+
const openRef = editor.doc.get().ref;
|
|
101
|
+
const opened = sameDoc(openRef, ref) ? { collection: answer.body.collection, from: ref.path, to: answer.body.path } : moved.find((m) => m.collection === openRef.collection && m.from === openRef.path);
|
|
102
|
+
if (opened && opened.from !== opened.to) await editor.openDocument({ collection: opened.collection, path: opened.to }, { history: 'replace' });
|
|
103
|
+
else if (opened) await editor.reloadOpen();
|
|
104
|
+
const more = Math.max(moved.length - 1, 0);
|
|
105
|
+
editor.toast(more ? `Moved ${title} to ${url}, with ${more} more under it.` : `Moved ${title} to ${url}.`, 'check');
|
|
106
|
+
editor.announce(more ? `Moved ${title} from ${from.url} to ${url}, and ${more} more addresses under it.` : `Moved ${title} from ${from.url} to ${url}.`);
|
|
95
107
|
return answer;
|
|
96
108
|
}
|
|
97
109
|
|
|
110
|
+
/** One address Change address… changes (collections under pages, Decision 9). */
|
|
111
|
+
export interface AddressChange {
|
|
112
|
+
doc: ListedDocument;
|
|
113
|
+
/** Its new address; null while the new path isn't one yet. */
|
|
114
|
+
toUrl: string | null;
|
|
115
|
+
/** A document of a collection a page in the move holds (its file stays; only its address changes). */
|
|
116
|
+
held: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Every address Change address… on `ref` changes once it's at `to` (Decision 9): the
|
|
121
|
+
* document, the pages under it at any depth, and the documents of each collection held by
|
|
122
|
+
* it or a page under it. move.ts → `movesUnder` and `planMoves` work out the same, so the
|
|
123
|
+
* dialog lists them before asking.
|
|
124
|
+
*/
|
|
125
|
+
export function addressesMoving(collections: readonly EditorAnyCollection[], ref: DocRef, to: string, documents: readonly ListedDocument[]): AddressChange[] {
|
|
126
|
+
const collection = collections.find((c) => c.name === ref.collection);
|
|
127
|
+
const root = documents.find((d) => d.collection === ref.collection && d.path === ref.path);
|
|
128
|
+
if (!collection || !root) return [];
|
|
129
|
+
const newPath = (path: string) => (path === ref.path ? to : `${to}${path.slice(ref.path.length)}`);
|
|
130
|
+
const pages = [root, ...(collection.nested ? pagesUnder(collection.name, ref.path, documents) : [])];
|
|
131
|
+
const out: AddressChange[] = pages.map((d) => ({ doc: d, toUrl: addressFor(collection, newPath(d.path)), held: false }));
|
|
132
|
+
for (const held of collections) {
|
|
133
|
+
const holder = held.holder;
|
|
134
|
+
if (!held.heldBy || !holder || holder.collection !== ref.collection) continue;
|
|
135
|
+
if (holder.path !== ref.path && !holder.path.startsWith(`${ref.path}/`)) continue;
|
|
136
|
+
const holderUrl = addressFor(collection, newPath(holder.path));
|
|
137
|
+
const base = holderUrl === null ? null : `${holderUrl.replace(/\/+$/, '')}/`;
|
|
138
|
+
for (const d of documents) if (d.collection === held.name && d.url !== null && !d.warning) out.push({ doc: d, toUrl: base === null ? null : base + d.path, held: true });
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
98
143
|
/**
|
|
99
144
|
* M6c G4 (Decision 9): Replace… on a file. Every unsent edit in this tab goes first, and
|
|
100
145
|
* nothing is replaced while any is still held (a conflict, a refusal, a signed-out
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
21
21
|
import { INDEX_MESSAGE, NO_ADDRESS_MESSAGE, RESERVED_SLUG, addressProblem, slugify } from '../../../slug.ts';
|
|
22
|
+
import { documentsNoun, holdsMessage } from '../../../held.ts';
|
|
22
23
|
import { useEditor, useStore } from '../context.tsx';
|
|
23
24
|
import type { Editor } from '../editor.ts';
|
|
24
25
|
import { blockIcon } from '../block-list/summary.ts';
|
|
@@ -27,7 +28,7 @@ import { Icon } from '../shell/Icons.tsx';
|
|
|
27
28
|
import { createStore, type Store } from '../state/store.ts';
|
|
28
29
|
import type { CreateDeleteRefusal, DocRef, ListedDocument } from '../../types.ts';
|
|
29
30
|
import { INSIDE_CLOSE, InsidePicker } from './InsidePicker.tsx';
|
|
30
|
-
import { addressFor, andList, createDocument, deleteDocument, insideOptions, moveDocument, pagesUnder, singular } from './actions.ts';
|
|
31
|
+
import { addressFor, addressesMoving, andList, createDocument, deleteDocument, insideOptions, moveDocument, pagesUnder, singular } from './actions.ts';
|
|
31
32
|
import { filesLabel, inboxFor } from '../inbox/store.ts';
|
|
32
33
|
import { ReplaceDialog } from '../media/ReplaceDialog.tsx';
|
|
33
34
|
|
|
@@ -297,7 +298,18 @@ function NewDocument(props: { collection: string; inside?: string; at?: string }
|
|
|
297
298
|
/**
|
|
298
299
|
* A document's new address: for nested Pages the group it sits in ("Inside", New page's
|
|
299
300
|
* select, with New group…) and the address's last part, shown whole as it's typed, with
|
|
300
|
-
* New page's rules checked before asking the server.
|
|
301
|
+
* New page's rules checked before asking the server.
|
|
302
|
+
*
|
|
303
|
+
* **Everything under it moves with it** (collections under pages, Decision 9): the pages
|
|
304
|
+
* under it at any depth, and the documents of a collection it (or a page under it) holds.
|
|
305
|
+
* Then the dialog lists every address that changes ("Its 1 page moves with it",
|
|
306
|
+
* "Its 24 journal entries move with it", the collection's own word for its documents), warns about a route of the site's own code under
|
|
307
|
+
* the old address (it doesn't move), and has one tick for the lot. A page with nothing
|
|
308
|
+
* under it keeps the single-page dialog.
|
|
309
|
+
*
|
|
310
|
+
* "Leave a redirect from …" ("Leave redirects from the old addresses" for several), ticked
|
|
311
|
+
* to start with, says whether the old addresses redirect; it shows only when there's one to
|
|
312
|
+
* leave (a live address, not one an earlier move since the last Publish already redirects).
|
|
301
313
|
*/
|
|
302
314
|
function MoveDocument({ docRef }: { docRef: DocRef }) {
|
|
303
315
|
const editor = useEditor();
|
|
@@ -311,8 +323,9 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
|
|
|
311
323
|
const [part, setPart] = useState(docRef.path.slice(slash + 1));
|
|
312
324
|
const [error, setError] = useState<string | null>(null);
|
|
313
325
|
const [busy, setBusy] = useState(false);
|
|
314
|
-
|
|
315
|
-
|
|
326
|
+
const [redirect, setRedirect] = useState(true);
|
|
327
|
+
// Every page but this one and the pages under it: a page can't go under itself.
|
|
328
|
+
const options = useMemo(() => (collection?.nested ? insideOptions(collection, documents).filter((o) => o.path !== docRef.path && !o.path.startsWith(`${docRef.path}/`)) : []), [collection, documents, docRef.path]);
|
|
316
329
|
if (!doc || !collection || !doc.url) return null;
|
|
317
330
|
const nested = collection.nested;
|
|
318
331
|
const newGroup = nested && inside === NEW_GROUP;
|
|
@@ -323,8 +336,10 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
|
|
|
323
336
|
const url = addressFor(collection, path);
|
|
324
337
|
const base = addressFor(collection, folder ? `${folder}/` : '') ?? '/';
|
|
325
338
|
const same = path === docRef.path;
|
|
326
|
-
const
|
|
327
|
-
|
|
339
|
+
const moving = addressesMoving(editor.site.collections, docRef, path, documents);
|
|
340
|
+
const many = moving.length > 1;
|
|
341
|
+
const others = documents.filter((d) => !moving.some((m) => m.doc === d));
|
|
342
|
+
// New page's rules, before asking the server (it applies the same, and checks every address that changes).
|
|
328
343
|
const local = (() => {
|
|
329
344
|
if (same) return null;
|
|
330
345
|
if (newGroup && !slugify(group)) return group.trim() ? NO_ADDRESS_MESSAGE : null;
|
|
@@ -334,21 +349,38 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
|
|
|
334
349
|
})();
|
|
335
350
|
const shown = error ?? local;
|
|
336
351
|
const blocked = same || !last || (newGroup && !slugify(group)) || !!local || busy;
|
|
337
|
-
// Made since the last Publish: nobody has its address yet. Moved since then: its live address is the one it came from
|
|
338
|
-
|
|
352
|
+
// Made since the last Publish: nobody has its address yet. Moved since then: its live address is the one it came from,
|
|
353
|
+
// and its redirect from there is there already, following this move.
|
|
354
|
+
const pendingOf = (d: ListedDocument) => (waiting ?? []).find((w) => w.collection === d.collection && w.path === d.path && (w.change === 'new' || !!w.movedFrom));
|
|
355
|
+
const pending = pendingOf(doc);
|
|
339
356
|
const liveAddress = pending ? (pending.movedFrom ?? null) : doc.url;
|
|
340
|
-
const
|
|
357
|
+
const choose = many ? moving.some((m) => !pendingOf(m.doc)) : !pending;
|
|
341
358
|
const host = editor.site.host;
|
|
342
359
|
const confirm = async () => {
|
|
343
360
|
if (blocked) return;
|
|
344
361
|
setBusy(true);
|
|
345
362
|
setError(null);
|
|
346
|
-
const answer = await moveDocument(editor, docRef, path, doc.label);
|
|
363
|
+
const answer = await moveDocument(editor, docRef, path, doc.label, !choose || redirect);
|
|
347
364
|
setBusy(false);
|
|
348
365
|
if (answer.ok) closeDialog(editor);
|
|
349
366
|
else setError(answer.error);
|
|
350
367
|
};
|
|
351
|
-
const
|
|
368
|
+
const publish = editor.mode === 'live' ? ' Goes live at the next Publish.' : '';
|
|
369
|
+
const intro = !many && liveAddress && !choose ? `${liveAddress} will redirect here.${publish}` : null;
|
|
370
|
+
// What moves with it: the pages under it, then each held collection's documents.
|
|
371
|
+
const under = moving.filter((m) => !m.held && m.doc !== doc);
|
|
372
|
+
const heldBy = new Map<string, number>();
|
|
373
|
+
for (const m of moving) if (m.held) heldBy.set(m.doc.collection, (heldBy.get(m.doc.collection) ?? 0) + 1);
|
|
374
|
+
const labelOf = (name: string) => editor.site.collections.find((c) => c.name === name)?.label ?? name;
|
|
375
|
+
// "Its 1 page moves with it." "Its 24 journal entries move with it." "Its 2 pages and 24 journal entries move with it."
|
|
376
|
+
const heldParts = [...heldBy].map(([name, n]) => `${n} ${n === 1 ? singular({ kind: 'sections', nested: false, label: labelOf(name) }) : documentsNoun(labelOf(name))}`);
|
|
377
|
+
const parts = [...(under.length ? [`${under.length} page${under.length === 1 ? '' : 's'}`] : []), ...heldParts];
|
|
378
|
+
if (parts.length) parts[0] = `Its ${parts[0]}`;
|
|
379
|
+
const one = moving.length === 2 && parts.length === 1;
|
|
380
|
+
const withIt = `${andList(parts)} move${one ? 's' : ''} with it.`;
|
|
381
|
+
// A route of the site's own code under the old address stays where it is (Decision 10).
|
|
382
|
+
const oldBase = `${doc.url.replace(/\/+$/, '')}/`;
|
|
383
|
+
const routes = many ? (editor.site.codeRoutes ?? []).filter((r) => r.url.toLowerCase().startsWith(oldBase.toLowerCase())) : [];
|
|
352
384
|
return (
|
|
353
385
|
<Frame label="move">
|
|
354
386
|
<form
|
|
@@ -408,10 +440,49 @@ function MoveDocument({ docRef }: { docRef: DocRef }) {
|
|
|
408
440
|
</p>
|
|
409
441
|
)}
|
|
410
442
|
</div>
|
|
411
|
-
{
|
|
412
|
-
<
|
|
413
|
-
|
|
414
|
-
|
|
443
|
+
{many ? (
|
|
444
|
+
<div className="field" data-moving={moving.length}>
|
|
445
|
+
<p className="field-help">
|
|
446
|
+
{withIt} {same ? 'The addresses now:' : 'The new addresses:'}
|
|
447
|
+
</p>
|
|
448
|
+
<ul className="mv-list" aria-label="Every address that changes" tabIndex={0}>
|
|
449
|
+
{moving.map((m) => (
|
|
450
|
+
<li key={`${m.doc.collection}/${m.doc.path}`} className="mono" data-to={m.toUrl ?? ''}>
|
|
451
|
+
{same ? m.doc.url : (m.toUrl ?? '…')}
|
|
452
|
+
</li>
|
|
453
|
+
))}
|
|
454
|
+
</ul>
|
|
455
|
+
{routes.length ? (
|
|
456
|
+
<p className="field-help" data-routes={routes.length}>
|
|
457
|
+
<Icon name="warn" size="xs" /> {andList(routes.map((r) => r.file))} {routes.length === 1 ? 'is' : 'are'} the site’s own code under {doc.url}, so {routes.length === 1 ? 'it stays' : 'they stay'} where {routes.length === 1 ? 'it is' : 'they are'}.
|
|
458
|
+
</p>
|
|
459
|
+
) : null}
|
|
460
|
+
</div>
|
|
461
|
+
) : null}
|
|
462
|
+
{choose ? (
|
|
463
|
+
<div className="field">
|
|
464
|
+
<label className="check">
|
|
465
|
+
<input type="checkbox" id="mv-redirect" checked={redirect} aria-describedby="mv-redirect-h" onChange={(e) => setRedirect(e.target.checked)} />
|
|
466
|
+
<span>
|
|
467
|
+
{many ? (
|
|
468
|
+
'Leave redirects from the old addresses'
|
|
469
|
+
) : (
|
|
470
|
+
<>
|
|
471
|
+
Leave a redirect from <span className="mono">{doc.url}</span>
|
|
472
|
+
</>
|
|
473
|
+
)}
|
|
474
|
+
</span>
|
|
475
|
+
</label>
|
|
476
|
+
<p className="field-help" id="mv-redirect-h">
|
|
477
|
+
{many
|
|
478
|
+
? redirect
|
|
479
|
+
? `Anyone with an old address lands on the new one.${publish}`
|
|
480
|
+
: 'Nothing will be at the old addresses: links to them from elsewhere will find no page.'
|
|
481
|
+
: redirect
|
|
482
|
+
? `Anyone with the old address lands here.${publish}`
|
|
483
|
+
: `Nothing will be at ${doc.url}: links to it from elsewhere will find no page.`}
|
|
484
|
+
</p>
|
|
485
|
+
</div>
|
|
415
486
|
) : null}
|
|
416
487
|
<div className="acts">
|
|
417
488
|
<button type="button" className="btn" onClick={() => closeDialog(editor)}>
|
|
@@ -569,6 +640,24 @@ function DeleteDocument({ docRef }: { docRef: DocRef }) {
|
|
|
569
640
|
const doc = documents.find((d) => d.collection === docRef.collection && d.path === docRef.path);
|
|
570
641
|
const collection = editor.site.collections.find((c) => c.name === docRef.collection);
|
|
571
642
|
if (!doc || !collection) return null;
|
|
643
|
+
// Collections under pages (Decision 12): a page that holds a collection isn't deleted, and says so instead of asking.
|
|
644
|
+
const held = doc.holds ? editor.site.collections.find((c) => c.name === doc.holds && c.heldBy === collection.name) : undefined;
|
|
645
|
+
if (held) {
|
|
646
|
+
return (
|
|
647
|
+
<Frame alert label="holds" describedBy="dlg-d">
|
|
648
|
+
<div className="icon-h" style={{ background: 'var(--status-warning-wash)', color: 'var(--status-warning)' }}>
|
|
649
|
+
<Icon name="warn" />
|
|
650
|
+
</div>
|
|
651
|
+
<h2 id="dlg-h">{doc.label} can’t be deleted</h2>
|
|
652
|
+
<p id="dlg-d">{holdsMessage(doc.label, held.label)}</p>
|
|
653
|
+
<div className="acts">
|
|
654
|
+
<button type="button" className="btn ink" data-act="holds-ok" onClick={() => closeDialog(editor)}>
|
|
655
|
+
OK
|
|
656
|
+
</button>
|
|
657
|
+
</div>
|
|
658
|
+
</Frame>
|
|
659
|
+
);
|
|
660
|
+
}
|
|
572
661
|
const shared = collection.kind === 'shared';
|
|
573
662
|
const kids = collection.nested ? pagesUnder(collection.name, doc.path, documents) : [];
|
|
574
663
|
const host = editor.site.host;
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* dismissNotice(id) / signOut() / signInAgain()
|
|
32
32
|
*/
|
|
33
33
|
import { addressOf, inboxAddress, inboxFromAddress, refFromAddress } from '../addresses.ts';
|
|
34
|
+
import { heldBases } from '../../held.ts';
|
|
34
35
|
import {
|
|
35
36
|
docId,
|
|
36
37
|
ENDPOINTS,
|
|
@@ -298,6 +299,24 @@ export interface Editor {
|
|
|
298
299
|
timeZone(): string;
|
|
299
300
|
}
|
|
300
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Collections under pages (Decision 7): each held collection's base, worked out again from a
|
|
304
|
+
* fresh listing (held.ts), so a holder that moved takes its collection's addresses with it in
|
|
305
|
+
* every dialog and table without a reload. The payload's own objects are updated in place.
|
|
306
|
+
*/
|
|
307
|
+
export function followHolders(site: Pick<EditorSite, 'collections' | 'allCollections'>, listed: readonly ListedDocument[]): void {
|
|
308
|
+
if (!site.collections.some((c) => c.heldBy)) return;
|
|
309
|
+
const bases = heldBases(Object.fromEntries(site.collections.map((c) => [c.name, c])), listed);
|
|
310
|
+
for (const b of Object.values(bases)) {
|
|
311
|
+
for (const c of [...site.collections, ...(site.allCollections ?? [])]) {
|
|
312
|
+
if (c.name !== b.collection) continue;
|
|
313
|
+
c.urlBase = b.base;
|
|
314
|
+
c.holder = b.holder ? { collection: b.holder.collection, path: b.holder.path } : null;
|
|
315
|
+
c.problem = b.problem;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
301
320
|
export function createEditor(props: AdminProps, options: { client: boolean }): Editor {
|
|
302
321
|
const { site } = props;
|
|
303
322
|
const opened = props.opened;
|
|
@@ -743,7 +762,9 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
|
|
|
743
762
|
async refreshDocuments() {
|
|
744
763
|
const response = await fetch(ENDPOINTS.documents, { headers: { Accept: 'application/json' } });
|
|
745
764
|
if (!response.ok) return;
|
|
746
|
-
|
|
765
|
+
const listed = (await response.json()) as ListedDocument[];
|
|
766
|
+
followHolders(site, listed);
|
|
767
|
+
documents.set(listed);
|
|
747
768
|
// M6 Decision 9: the unread count rides along (absent when it couldn't be read).
|
|
748
769
|
const unread = response.headers.get(UNREAD_HEADER);
|
|
749
770
|
if (unread !== null && /^\d+$/.test(unread)) submissions.set({ unread: Number(unread) });
|