caracms 0.3.4 → 0.5.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 +73 -3
- 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 +103 -12
- 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 +6 -2
- package/src/content.ts +188 -27
- package/src/document.ts +13 -2
- package/src/editor/README.md +3 -0
- package/src/editor/addresses.ts +6 -1
- package/src/editor/client/arrange/ArrangeStage.tsx +629 -0
- package/src/editor/client/arrange/model.ts +659 -0
- package/src/editor/client/arrange/store.ts +335 -0
- package/src/editor/client/collections/CollectionTable.tsx +9 -1
- package/src/editor/client/documents/actions.ts +75 -19
- package/src/editor/client/documents/dialogs.tsx +137 -18
- package/src/editor/client/editor.ts +78 -6
- 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/Pane.tsx +3 -0
- package/src/editor/client/pane/levels/ArrangeLevel.tsx +342 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +68 -13
- package/src/editor/client/pane/parts.tsx +14 -1
- package/src/editor/client/shell/Stage.tsx +8 -4
- package/src/editor/routes/admin.astro +1 -1
- package/src/editor/routes/arrange.ts +17 -0
- package/src/editor/routes/move.ts +1 -1
- package/src/editor/server/admin-props.ts +24 -4
- package/src/editor/server/arrange.ts +245 -0
- package/src/editor/server/create.ts +13 -2
- package/src/editor/server/drafts-store.ts +55 -10
- package/src/editor/server/move.ts +300 -81
- package/src/editor/styles/editor.css +5 -0
- package/src/editor/styles/levels/arrange.css +152 -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 +83 -7
- package/src/held.ts +116 -0
- package/src/index.ts +6 -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 +170 -77
- package/src/live/editor.ts +26 -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/page-order.ts +253 -0
- package/src/redirects.ts +64 -1
- package/src/relink.ts +40 -9
- package/src/reserved.ts +3 -2
- package/src/serialize.ts +18 -1
- package/src/slug.ts +15 -4
package/src/content.ts
CHANGED
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
* is the URL, `index.json` at the top is the collection's `urlBase`, and an
|
|
12
12
|
* `index.json` any deeper is an error, listed with a warning and not opened.
|
|
13
13
|
* - **Order** (Decision 8): the reader provides it. `listDocuments` returns a nested
|
|
14
|
-
* collection in tree order
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* collection in tree order, and a flat collection alphabetically by title. `pageTree`
|
|
15
|
+
* builds the tree itself; each level follows page-order.ts (arrange pages, Decision 7:
|
|
16
|
+
* Home first, then Site Settings' saved `pageOrder` for Pages, read from the same
|
|
17
|
+
* source first, then what has pages under it, then A–Z, a folder with no page of its
|
|
18
|
+
* own sorted by its label).
|
|
17
19
|
* - A file that isn't valid JSON, or has no `title`, is listed with a warning and
|
|
18
20
|
* can't be opened (Decision 9).
|
|
19
21
|
* - **Two sources, one set of rules** (M4 Current state): the files come from a
|
|
@@ -36,6 +38,11 @@ import { isBuiltInColumn } from './columns.ts';
|
|
|
36
38
|
import { fieldsCover, sectionsCover, type CardCover } from './cards.ts';
|
|
37
39
|
import { own, parseAnyDocument, type AnyDocument, type DocumentKind } from './document.ts';
|
|
38
40
|
import { parseSource } from './json-source.ts';
|
|
41
|
+
import { hasHeld, heldBases, type HeldBase } from './held.ts';
|
|
42
|
+
import { leftBehindMessage, leftBehindRoutes, ownRoutedCollections } from './code-routes.ts';
|
|
43
|
+
import { sameAddress } from './slug.ts';
|
|
44
|
+
import { byLabel, isOrderedCollection, orderSiblings, parsePageOrder, type PageOrder } from './page-order.ts';
|
|
45
|
+
import { SITE_SETTINGS_FILE } from './reserved.ts';
|
|
39
46
|
|
|
40
47
|
// Posts and tags, Decision 14: a route's reader on the disk (read.ts).
|
|
41
48
|
export { contentReader } from './read.ts';
|
|
@@ -79,6 +86,21 @@ export interface DocumentEntry {
|
|
|
79
86
|
*/
|
|
80
87
|
hidden?: true;
|
|
81
88
|
hiddenBy?: string;
|
|
89
|
+
/**
|
|
90
|
+
* The page's own `"hidden": true`, kept apart from `hiddenBy`: a page hidden itself and also
|
|
91
|
+
* under a hidden page has both, and the arrange screen needs to know it stays hidden when moved.
|
|
92
|
+
*/
|
|
93
|
+
hiddenSelf?: true;
|
|
94
|
+
/**
|
|
95
|
+
* Collections under pages (Decision 11): set when `hiddenBy` is a page of another collection,
|
|
96
|
+
* the page that holds this document's collection (`pages`). Absent: `hiddenBy` is in this one.
|
|
97
|
+
*/
|
|
98
|
+
hiddenByCollection?: string;
|
|
99
|
+
/**
|
|
100
|
+
* Collections under pages (Decision 5): a page's `"holds"`, the collection it holds (held.ts).
|
|
101
|
+
* Read from a file that can't be opened too, as far as it can be, so a broken holder is named.
|
|
102
|
+
*/
|
|
103
|
+
holds?: string;
|
|
82
104
|
/** A section builder's shared-block entries, by shared block path, in block order, once each (Decision 8). Absent otherwise. */
|
|
83
105
|
uses?: string[];
|
|
84
106
|
/** A shared block: its block's type (`''` when it has none). Absent otherwise. */
|
|
@@ -220,13 +242,17 @@ export function resolveDocumentFile(site: Pick<ResolvedSite, 'collections'>, col
|
|
|
220
242
|
return file;
|
|
221
243
|
}
|
|
222
244
|
|
|
223
|
-
/**
|
|
245
|
+
/**
|
|
246
|
+
* Every document in one collection, in several (in the order given), or in all of them (in
|
|
247
|
+
* config order), with warnings, in the editor's order. A held collection's documents have
|
|
248
|
+
* their resolved addresses (held.ts), whichever collections are listed.
|
|
249
|
+
*/
|
|
224
250
|
export async function listDocuments(
|
|
225
251
|
site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>,
|
|
226
|
-
collectionName?: string,
|
|
252
|
+
collectionName?: string | readonly string[],
|
|
227
253
|
source: ContentSource = diskSource,
|
|
228
254
|
): Promise<DocumentEntry[]> {
|
|
229
|
-
const names = collectionName
|
|
255
|
+
const names = collectionName === undefined ? Object.keys(site.collections) : typeof collectionName === 'string' ? [collectionName] : [...collectionName];
|
|
230
256
|
const all: DocumentEntry[] = [];
|
|
231
257
|
for (const name of names) {
|
|
232
258
|
const collection = own(site.collections, name);
|
|
@@ -234,9 +260,24 @@ export async function listDocuments(
|
|
|
234
260
|
const paths = await documentPaths(collection, source);
|
|
235
261
|
const entries = (await Promise.all(paths.map((path) => entryFor(site, collection, path, source)))).filter((e) => e !== null);
|
|
236
262
|
if (collection.nested) hideChildren(entries);
|
|
237
|
-
|
|
263
|
+
// Arrange pages (Decision 6): Pages follow Site Settings' saved order, read through the same source.
|
|
264
|
+
const order = isOrderedCollection(collection) ? await savedPageOrder(site, source) : undefined;
|
|
265
|
+
all.push(...(collection.nested ? flattenTree(pageTree(entries, order)) : entries.sort(byLabel)));
|
|
266
|
+
}
|
|
267
|
+
// Collections under pages (Decision 7): a held collection's addresses come from its holder's listing.
|
|
268
|
+
const held = names.map((n) => site.collections[n]!).filter((c) => c.heldBy);
|
|
269
|
+
if (held.length) {
|
|
270
|
+
const holding: DocumentEntry[] = [];
|
|
271
|
+
for (const by of new Set(held.map((c) => c.heldBy!))) {
|
|
272
|
+
holding.push(...(names.includes(by) ? all.filter((e) => e.collection === by) : await listDocuments(site, by, source)));
|
|
273
|
+
}
|
|
274
|
+
const bases = heldBases(site.collections, holding);
|
|
275
|
+
for (const entry of all) {
|
|
276
|
+
const base = own(bases, entry.collection);
|
|
277
|
+
if (base) placeHeld(entry, base);
|
|
278
|
+
}
|
|
238
279
|
}
|
|
239
|
-
if (
|
|
280
|
+
if (collectionName === undefined) {
|
|
240
281
|
// Decision 8's "used on": every document whose block list has an entry for the shared block.
|
|
241
282
|
for (const entry of all) {
|
|
242
283
|
if (entry.kind !== 'shared') continue;
|
|
@@ -246,6 +287,59 @@ export async function listDocuments(
|
|
|
246
287
|
return all;
|
|
247
288
|
}
|
|
248
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Arrange pages (Decision 6): Site Settings' `pageOrder` through `source` (live mode: the
|
|
292
|
+
* `drafts` copy with the autosaves on top); `{}` when there's none or the file can't be read.
|
|
293
|
+
*/
|
|
294
|
+
export async function savedPageOrder(site: Pick<ResolvedSite, 'root'>, source: ContentSource = diskSource): Promise<PageOrder> {
|
|
295
|
+
const text = await source.readText(join(site.root, SITE_SETTINGS_FILE)).catch(() => null);
|
|
296
|
+
if (text === null) return {};
|
|
297
|
+
try {
|
|
298
|
+
const raw = JSON.parse(text) as unknown;
|
|
299
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? parsePageOrder((raw as Record<string, unknown>).pageOrder) : {};
|
|
300
|
+
} catch {
|
|
301
|
+
return {};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** A held document's address and hidden mark from its collection's resolved base (held.ts). */
|
|
306
|
+
function placeHeld(entry: DocumentEntry, held: HeldBase): void {
|
|
307
|
+
entry.url = held.base === null ? null : held.base + entry.path;
|
|
308
|
+
// Decision 11: a hidden holder hides its documents, as a hidden page hides the pages under it.
|
|
309
|
+
if (held.hidden && held.holder) {
|
|
310
|
+
entry.hidden = true;
|
|
311
|
+
entry.hiddenBy = held.holder.path;
|
|
312
|
+
entry.hiddenByCollection = held.holder.collection;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** A held collection's resolved base, from its holder collection's listing through `source`. */
|
|
317
|
+
export async function heldBaseOf(site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>, collectionName: string, source: ContentSource = diskSource): Promise<HeldBase | null> {
|
|
318
|
+
const collection = own(site.collections, collectionName);
|
|
319
|
+
if (!collection?.heldBy) return null;
|
|
320
|
+
return heldBases({ [collection.name]: collection }, await listDocuments(site, collection.heldBy, source))[collection.name] ?? null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Every held collection's resolved base (held.ts), from the listings of the collections that hold them; `{}` on a site with none. */
|
|
324
|
+
export async function heldBasesOf(site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>, source: ContentSource = diskSource): Promise<Record<string, HeldBase>> {
|
|
325
|
+
if (!hasHeld(site.collections)) return {};
|
|
326
|
+
const by = [...new Set(Object.values(site.collections).flatMap((c) => (c.heldBy ? [c.heldBy] : [])))];
|
|
327
|
+
const holding = (await Promise.all(by.map((name) => listDocuments(site, name, source)))).flat();
|
|
328
|
+
return heldBases(site.collections, holding);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* A document's public address as its listing gives it: `documentUrl` for a collection with a
|
|
333
|
+
* prefix, the holder's address + `/` + the path for a held one (null while it isn't held).
|
|
334
|
+
*/
|
|
335
|
+
export async function documentAddress(site: Pick<ResolvedSite, 'collections' | 'root' | 'blocks'>, collectionName: string, path: string, source: ContentSource = diskSource): Promise<string | null> {
|
|
336
|
+
const collection = own(site.collections, collectionName);
|
|
337
|
+
if (!collection) return null;
|
|
338
|
+
if (!collection.heldBy) return documentUrl(collection, path);
|
|
339
|
+
const held = await heldBaseOf(site, collectionName, source);
|
|
340
|
+
return held?.base ? held.base + path : null;
|
|
341
|
+
}
|
|
342
|
+
|
|
249
343
|
/** M9 Decision 7: a hidden page's children are hidden with it; `hiddenBy` names the topmost hidden page above. */
|
|
250
344
|
function hideChildren(entries: DocumentEntry[]): void {
|
|
251
345
|
const hidden = new Set(entries.filter((e) => e.hidden).map((e) => e.path));
|
|
@@ -279,6 +373,10 @@ export async function readDocument(
|
|
|
279
373
|
if (text === null) throw new DocumentError('not-found', `${collection.label}: no document "${path}".`);
|
|
280
374
|
const { entry, document } = inspect(site, collection, path, text);
|
|
281
375
|
if (entry.warning || !document) throw new DocumentError(entry.warning!.code, entry.warning!.message);
|
|
376
|
+
if (collection.heldBy) {
|
|
377
|
+
const held = await heldBaseOf(site, collection.name, source);
|
|
378
|
+
if (held) placeHeld(entry, held);
|
|
379
|
+
}
|
|
282
380
|
return { entry, text, document };
|
|
283
381
|
}
|
|
284
382
|
|
|
@@ -312,10 +410,12 @@ function inspect(
|
|
|
312
410
|
url: documentUrl(collection, path),
|
|
313
411
|
};
|
|
314
412
|
const fileLabel = titleCase(path.split('/').pop()!);
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
413
|
+
// Decision 13: a broken holder is named, so its `"holds"` is read from the text as far as it can be.
|
|
414
|
+
const holdsIn = () => (collection.kind === 'sections' ? /"holds"\s*:\s*"([^"\\]*)"/.exec(text)?.[1] : undefined);
|
|
415
|
+
const warn = (code: WarningCode, message: string, title = '') => {
|
|
416
|
+
const holds = holdsIn();
|
|
417
|
+
return { entry: { ...base, title, label: title || fileLabel, warning: { code, message }, ...(holds !== undefined ? { holds } : {}) }, document: null };
|
|
418
|
+
};
|
|
319
419
|
|
|
320
420
|
if (collection.nested && path.endsWith('/index')) {
|
|
321
421
|
const parent = path.slice(0, -'/index'.length);
|
|
@@ -335,7 +435,8 @@ function inspect(
|
|
|
335
435
|
}
|
|
336
436
|
const entry: DocumentEntry = { ...base, title, label: title, warning: null };
|
|
337
437
|
// M9 Decision 7: the home page is never hidden.
|
|
338
|
-
if ((document.kind === undefined || document.kind === 'sections') && document.hidden && path !== 'index') entry.hidden = true;
|
|
438
|
+
if ((document.kind === undefined || document.kind === 'sections') && document.hidden && path !== 'index') entry.hidden = entry.hiddenSelf = true;
|
|
439
|
+
if ((document.kind === undefined || document.kind === 'sections') && document.holds !== undefined) entry.holds = document.holds;
|
|
339
440
|
if (document.kind === undefined || document.kind === 'sections') {
|
|
340
441
|
entry.uses = [...new Set(document.blocks.flatMap((b) => (b.shared !== undefined ? [b.shared] : [])))];
|
|
341
442
|
} else if (document.kind === 'shared') {
|
|
@@ -375,10 +476,11 @@ export type PageTreeNode =
|
|
|
375
476
|
/**
|
|
376
477
|
* A nested collection's entries as a tree that mirrors their paths. A page with
|
|
377
478
|
* pages under it (`about.json` beside `about/`) is one node with children; a folder
|
|
378
|
-
* with no page is a group. Each level is ordered
|
|
379
|
-
*
|
|
479
|
+
* with no page is a group. Each level is ordered by page-order.ts → `orderSiblings`
|
|
480
|
+
* (arrange pages, Decision 7): Home (`index`) first, then the siblings `order` lists
|
|
481
|
+
* for that level, then those with pages or a held collection under them, then A–Z.
|
|
380
482
|
*/
|
|
381
|
-
export function pageTree(entries: readonly DocumentEntry[]): PageTreeNode[] {
|
|
483
|
+
export function pageTree(entries: readonly DocumentEntry[], order: PageOrder = {}): PageTreeNode[] {
|
|
382
484
|
interface Draft {
|
|
383
485
|
path: string;
|
|
384
486
|
entry?: DocumentEntry;
|
|
@@ -400,17 +502,15 @@ export function pageTree(entries: readonly DocumentEntry[]): PageTreeNode[] {
|
|
|
400
502
|
node.entry = entry;
|
|
401
503
|
}
|
|
402
504
|
const build = (draft: Draft): PageTreeNode[] =>
|
|
403
|
-
|
|
404
|
-
.map((d): PageTreeNode => {
|
|
505
|
+
orderSiblings(
|
|
506
|
+
[...draft.children.values()].map((d): PageTreeNode => {
|
|
405
507
|
const children = build(d);
|
|
406
508
|
if (d.entry) return { kind: 'page', path: d.path, label: d.entry.label, entry: d.entry, children };
|
|
407
509
|
return { kind: 'group', path: d.path, label: titleCase(d.path.split('/').pop()!), children };
|
|
408
|
-
})
|
|
409
|
-
.
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
return byLabel(a, b);
|
|
413
|
-
});
|
|
510
|
+
}),
|
|
511
|
+
Object.hasOwn(order, draft.path) ? order[draft.path] : undefined,
|
|
512
|
+
(n) => n.children.length > 0 || (n.kind === 'page' && n.entry.holds !== undefined),
|
|
513
|
+
);
|
|
414
514
|
return build(top);
|
|
415
515
|
}
|
|
416
516
|
|
|
@@ -418,7 +518,68 @@ function flattenTree(nodes: PageTreeNode[]): DocumentEntry[] {
|
|
|
418
518
|
return nodes.flatMap((n) => [...(n.kind === 'page' ? [n.entry] : []), ...flattenTree(n.children)]);
|
|
419
519
|
}
|
|
420
520
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
521
|
+
|
|
522
|
+
// ── The catch-all's reader (collections under pages, Decision 14) ────────────────
|
|
523
|
+
|
|
524
|
+
/** A public page the site's catch-all serves: its collection, its path there, and its address. */
|
|
525
|
+
export interface AddressedDocument {
|
|
526
|
+
collection: string;
|
|
527
|
+
path: string;
|
|
528
|
+
/** `/`, `/about`, `/journal/a-day-on-the-ferry`: no trailing slash, as the listing gives it. */
|
|
529
|
+
url: string;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** What the reader reads: the site, and the routes in its `src/pages/` (the integration sets `pageRoutes`). */
|
|
533
|
+
type CatchAllSite = Pick<ResolvedSite, 'collections' | 'root' | 'blocks'> & Partial<Pick<ResolvedSite, 'pageRoutes'>>;
|
|
534
|
+
|
|
535
|
+
const logged = new Set<string>();
|
|
536
|
+
/** The build's and dev's log, once per sentence per process (Decision 13: logged, never a failed build). */
|
|
537
|
+
function logOnce(message: string): void {
|
|
538
|
+
if (logged.has(message)) return;
|
|
539
|
+
logged.add(message);
|
|
540
|
+
console.warn(`[caracms] ${message}`);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Every public page of every collection with an address, for the site's one catch-all route
|
|
545
|
+
* (`src/pages/[...path].astro`'s `getStaticPaths`): Pages, every held collection's documents at
|
|
546
|
+
* their holder's address, and every prefix collection's (`/tags/<slug>`), in config order then
|
|
547
|
+
* the editor's. Left out: a file that can't be opened, a hidden page (and the documents of a
|
|
548
|
+
* hidden holder), a held collection no page holds, and a prefix collection whose base has its
|
|
549
|
+
* own route (`src/pages/tags/[slug].astro`), which that route serves. A holder problem or a
|
|
550
|
+
* route left at a held base is logged once (`caracms validate` reports both as errors).
|
|
551
|
+
* `source`: where the files come from (the disk by default, as a build reads them).
|
|
552
|
+
*/
|
|
553
|
+
export async function addressedDocuments(site: CatchAllSite, source: ContentSource = diskSource): Promise<AddressedDocument[]> {
|
|
554
|
+
const collections = Object.values(site.collections);
|
|
555
|
+
const routes = site.pageRoutes ?? [];
|
|
556
|
+
const ownRouted = new Set(ownRoutedCollections(routes, collections).map((c) => c.name));
|
|
557
|
+
const names = collections.filter((c) => (c.urlBase !== null || c.heldBy) && !ownRouted.has(c.name)).map((c) => c.name);
|
|
558
|
+
const entries = await listDocuments(site, names, source);
|
|
559
|
+
if (hasHeld(site.collections)) {
|
|
560
|
+
const held: { name: string; label: string; base: string; holder: string }[] = [];
|
|
561
|
+
for (const base of Object.values(await heldBasesOf(site, source))) {
|
|
562
|
+
if (base.problem) logOnce(base.problem);
|
|
563
|
+
const c = site.collections[base.collection]!;
|
|
564
|
+
if (base.base && base.holder) held.push({ name: c.name, label: c.label, base: base.base, holder: base.holder.label });
|
|
565
|
+
}
|
|
566
|
+
for (const left of leftBehindRoutes(routes, held)) logOnce(leftBehindMessage(left));
|
|
567
|
+
}
|
|
568
|
+
return entries.filter((e): e is DocumentEntry & { url: string } => e.url !== null && !e.warning && !e.hidden).map(({ collection, path, url }) => ({ collection, path, url }));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* The document a request's path is (`/journal/a-day-on-the-ferry`, with or without a trailing
|
|
573
|
+
* slash, URL-encoded or not), for a catch-all that renders on demand; null when no public page
|
|
574
|
+
* is there (`addressedDocuments` says which are).
|
|
575
|
+
*/
|
|
576
|
+
export async function documentAt(site: CatchAllSite, pathname: string, source: ContentSource = diskSource): Promise<{ collection: string; path: string } | null> {
|
|
577
|
+
let at: string;
|
|
578
|
+
try {
|
|
579
|
+
at = sameAddress(decodeURIComponent(pathname));
|
|
580
|
+
} catch {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
const found = (await addressedDocuments(site, source)).find((d) => sameAddress(d.url) === at);
|
|
584
|
+
return found ? { collection: found.collection, path: found.path } : null;
|
|
424
585
|
}
|
package/src/document.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { parseSource, type ParsedSource } from './json-source.ts';
|
|
|
25
25
|
import { isFileKey } from './files.ts';
|
|
26
26
|
import { parseMedia } from './media-value.ts';
|
|
27
27
|
import { parseRedirects, type Redirect } from './redirects.ts';
|
|
28
|
+
import { parsePageOrder, type PageOrder } from './page-order.ts';
|
|
28
29
|
import { parseFormRecipients, parseGoogleTag, parseSeo, type Seo } from './seo.ts';
|
|
29
30
|
|
|
30
31
|
export interface DocumentBlock {
|
|
@@ -67,6 +68,13 @@ export interface SiteDocument {
|
|
|
67
68
|
* the key out of the file. The home page (`index`) can't be hidden (content.ts reads it as shown).
|
|
68
69
|
*/
|
|
69
70
|
hidden?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Collections under pages (Decision 5): the collection this page holds (the file's
|
|
73
|
+
* top-level `"holds"`, after `title` and `hidden`): its documents are at this page's
|
|
74
|
+
* address + `/` + their slug (held.ts). Edited in the file only, never by the editor,
|
|
75
|
+
* whose saves keep it where it is.
|
|
76
|
+
*/
|
|
77
|
+
holds?: string;
|
|
70
78
|
/**
|
|
71
79
|
* M6c (G2): the page fields' parsed values, keyed by field, every field of the
|
|
72
80
|
* collection's `fields` present (the file's top-level keys after `title`). Present
|
|
@@ -121,14 +129,16 @@ export interface SettingsBrand {
|
|
|
121
129
|
export interface SettingsDocument {
|
|
122
130
|
kind: 'settings';
|
|
123
131
|
title: string;
|
|
124
|
-
data: Record<string, unknown> & { editors: string[]; brand: SettingsBrand; seo?: Seo; googleTag?: string; formRecipients?: Record<string, string[]>; redirects?: Redirect[] };
|
|
132
|
+
data: Record<string, unknown> & { editors: string[]; brand: SettingsBrand; seo?: Seo; googleTag?: string; formRecipients?: Record<string, string[]>; redirects?: Redirect[]; pageOrder?: PageOrder };
|
|
125
133
|
}
|
|
126
134
|
|
|
127
135
|
/** M6: Site Settings' search and sharing keys, parsed, each only when `raw` has it. */
|
|
128
|
-
function settingsM6(raw: Record<string, unknown>): { seo?: Seo; googleTag?: string; formRecipients?: Record<string, string[]>; redirects?: Redirect[] } {
|
|
136
|
+
function settingsM6(raw: Record<string, unknown>): { seo?: Seo; googleTag?: string; formRecipients?: Record<string, string[]>; redirects?: Redirect[]; pageOrder?: PageOrder } {
|
|
129
137
|
return {
|
|
130
138
|
// Pre-M7 item 8: the redirects changing an address adds (redirects.ts).
|
|
131
139
|
...(Object.hasOwn(raw, 'redirects') ? { redirects: parseRedirects(raw.redirects) } : {}),
|
|
140
|
+
// Arrange pages (Decision 6): the Pages tree's saved order (page-order.ts).
|
|
141
|
+
...(Object.hasOwn(raw, 'pageOrder') ? { pageOrder: parsePageOrder(raw.pageOrder) } : {}),
|
|
132
142
|
...(Object.hasOwn(raw, 'seo') ? { seo: parseSeo(raw.seo) } : {}),
|
|
133
143
|
...(Object.hasOwn(raw, 'googleTag') ? { googleTag: parseGoogleTag(raw.googleTag) } : {}),
|
|
134
144
|
...(Object.hasOwn(raw, 'formRecipients') ? { formRecipients: parseFormRecipients(raw.formRecipients) } : {}),
|
|
@@ -225,6 +235,7 @@ export function parseDocument(raw: unknown, site: Pick<ResolvedSite, 'blocks'>):
|
|
|
225
235
|
blocks: rawBlocks.map((b, index) => parseBlock(b, index, site)),
|
|
226
236
|
...seoOf(doc),
|
|
227
237
|
...(doc.hidden === true ? { hidden: true } : {}),
|
|
238
|
+
...(typeof doc.holds === 'string' ? { holds: doc.holds } : {}),
|
|
228
239
|
};
|
|
229
240
|
}
|
|
230
241
|
|
package/src/editor/README.md
CHANGED
|
@@ -131,6 +131,9 @@ client/ the browser (and the shell's server render)
|
|
|
131
131
|
fields/ the block level's fields: FieldFrame, Text, Select, Lockup, lockup-rules.ts
|
|
132
132
|
shell/ App, Bar, Stage, Popovers, Toast, NarrowNote, Icons
|
|
133
133
|
pane/ Pane.tsx (the frame), parts.tsx (shared pieces), switcher.tsx (Phase 4's seam), levels/*
|
|
134
|
+
arrange/ Arrange pages (notes/2026-10-25-arrange-pages.md): model.ts (the tree, the plan, the checks, the
|
|
135
|
+
re-plan; pure), store.ts (arrangeFor(editor): the screen's state, Apply), ArrangeStage.tsx (the
|
|
136
|
+
screen in the preview's place, mouse drag); its pane level is pane/levels/ArrangeLevel.tsx
|
|
134
137
|
styles/ tokens.css (the mockup's :root, as is), editor.css (the mockup's chrome CSS), fonts.css,
|
|
135
138
|
levels/*.css (a level's own CSS, loaded by the admin route)
|
|
136
139
|
fonts/ Instrument Sans and Geist Mono (OFL, licences beside them)
|
package/src/editor/addresses.ts
CHANGED
|
@@ -30,7 +30,12 @@ export function inboxFromAddress(pathname: string): { id: string | null } | 'bad
|
|
|
30
30
|
return isSubmissionId(rest) ? { id: rest } : 'bad';
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
/** Arrange pages' address (Decision 15): the screen, with nothing arranged on a reload. */
|
|
34
|
+
export const ARRANGE_ADDRESS = `${ADMIN}/arrange`;
|
|
35
|
+
|
|
36
|
+
export const isArrangeAddress = (pathname: string): boolean => pathname.replace(/\/+$/, '') === ARRANGE_ADDRESS;
|
|
37
|
+
|
|
38
|
+
export const inboxAddress =(id: string | null): string => (id ? `${SUBMISSIONS_ADDRESS}/${encodeURIComponent(id)}` : SUBMISSIONS_ADDRESS);
|
|
34
39
|
|
|
35
40
|
export function addressOf(ref: DocRef, home: DocRef): string {
|
|
36
41
|
if (sameDoc(ref, home)) return ADMIN;
|