caracms 0.4.0 → 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 +14 -0
- package/package.json +1 -1
- package/src/cli/validate.ts +53 -3
- package/src/content-schema.ts +1 -0
- package/src/content.ts +41 -20
- package/src/document.ts +5 -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/documents/actions.ts +14 -3
- package/src/editor/client/documents/dialogs.tsx +33 -3
- package/src/editor/client/editor.ts +56 -5
- 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 +10 -0
- 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/server/admin-props.ts +5 -3
- package/src/editor/server/arrange.ts +245 -0
- package/src/editor/server/drafts-store.ts +34 -4
- package/src/editor/server/move.ts +41 -7
- package/src/editor/styles/levels/arrange.css +152 -0
- package/src/editor/types.ts +52 -2
- package/src/index.ts +2 -0
- package/src/live/drafts-store.ts +62 -4
- package/src/live/editor.ts +4 -0
- package/src/page-order.ts +253 -0
- package/src/redirects.ts +39 -7
- package/src/reserved.ts +3 -2
- package/src/serialize.ts +17 -0
package/README.md
CHANGED
|
@@ -80,6 +80,20 @@ renders on demand uses `documentAt(site, pathname)`. A collection needs no route
|
|
|
80
80
|
own; a prefix collection whose base keeps its own route file is left out of the reader,
|
|
81
81
|
and a route with parameters left at a held collection's address is a `validate` error.
|
|
82
82
|
|
|
83
|
+
## Arranging pages
|
|
84
|
+
|
|
85
|
+
The editor's Pages tree reads Home first, then the pages and groups with pages (or a held
|
|
86
|
+
collection) under them, then the rest, each A–Z. To change that, "Arrange pages…" in the
|
|
87
|
+
tree's header opens a screen at `/admin/arrange`: drag pages to order them, nest one under
|
|
88
|
+
a page or group, make and rename groups, then review every old → new address and apply it
|
|
89
|
+
as one change, with one tick for redirects from the old addresses. Nothing changes until
|
|
90
|
+
Apply, and if the pages change underneath it, Apply refuses and shows the review again.
|
|
91
|
+
|
|
92
|
+
The order is saved in Site Settings as `pageOrder` (each parent's path → its children's
|
|
93
|
+
slugs), so no site field can use that name. It orders the editor's tree, the Inside picker
|
|
94
|
+
and `listDocuments(site, 'pages')`; the site's menus stay lists in Site Settings.
|
|
95
|
+
`caracms validate` warns on an entry that names no page, and `--fix` prunes it.
|
|
96
|
+
|
|
83
97
|
## Hosting
|
|
84
98
|
|
|
85
99
|
caracms runs on any host that keeps one Node server running behind HTTPS: Railway,
|
package/package.json
CHANGED
package/src/cli/validate.ts
CHANGED
|
@@ -33,7 +33,9 @@ import { fieldWarnings, parseDay, richTextOptions, type Field, type Fields, type
|
|
|
33
33
|
import { measureLockup } from '../measure.ts';
|
|
34
34
|
import { isFileKey } from '../files.ts';
|
|
35
35
|
import { readRichText } from '../rich-text.ts';
|
|
36
|
-
import { savedForm } from '../serialize.ts';
|
|
36
|
+
import { applyKeyedEdits, savedForm } from '../serialize.ts';
|
|
37
|
+
import { isOrderedCollection, prunedOrder, unknownOrderEntries } from '../page-order.ts';
|
|
38
|
+
import { SITE_SETTINGS_FILE } from '../reserved.ts';
|
|
37
39
|
import { SCHEMA_KEY, schemaRef } from '../schema-key.ts';
|
|
38
40
|
import { sameAddress } from '../slug.ts';
|
|
39
41
|
import { hiddenDocuments, hiddenPages } from '../links.ts';
|
|
@@ -331,7 +333,7 @@ function checkDocument(c: Context, collection: ResolvedCollection, raw: unknown)
|
|
|
331
333
|
else checkBlock(c, raw.block, 'block', null);
|
|
332
334
|
return;
|
|
333
335
|
case 'settings': {
|
|
334
|
-
checkFields(c, own, raw, '', 'Site Settings', 0, [SCHEMA_KEY, 'title', 'editors', 'brand', 'seo', 'googleTag', 'formRecipients', 'redirects', 'holds']);
|
|
336
|
+
checkFields(c, own, raw, '', 'Site Settings', 0, [SCHEMA_KEY, 'title', 'editors', 'brand', 'seo', 'googleTag', 'formRecipients', 'redirects', 'pageOrder', 'holds']);
|
|
335
337
|
if (raw.holds !== undefined) error(c, 'holds', HOLDS_ON_A_PAGE);
|
|
336
338
|
if (raw.editors !== undefined && !strings(raw.editors)) error(c, 'editors', `Expected a list of email addresses, found ${what(raw.editors)}.`);
|
|
337
339
|
if (raw.brand !== undefined) {
|
|
@@ -354,6 +356,9 @@ function checkDocument(c: Context, collection: ResolvedCollection, raw: unknown)
|
|
|
354
356
|
if (raw.redirects !== undefined && !(Array.isArray(raw.redirects) && raw.redirects.every((r) => isObject(r) && typeof r.from === 'string' && typeof r.to === 'string'))) {
|
|
355
357
|
error(c, 'redirects', 'Expected a list of { "from": "/old", "to": "/new" }.');
|
|
356
358
|
}
|
|
359
|
+
if (raw.pageOrder !== undefined && !(isObject(raw.pageOrder) && Object.values(raw.pageOrder).every(strings))) {
|
|
360
|
+
error(c, 'pageOrder', 'Expected { "<parent path>": ["slug", …] }.');
|
|
361
|
+
}
|
|
357
362
|
return;
|
|
358
363
|
}
|
|
359
364
|
}
|
|
@@ -450,6 +455,19 @@ export async function validateSite(site: Site, codeRoutes: readonly CodeRoute[]
|
|
|
450
455
|
}
|
|
451
456
|
}
|
|
452
457
|
|
|
458
|
+
// Arrange pages (Decision 6): a saved order's entry that names no page or group (the reader ignores it).
|
|
459
|
+
const order = savedOrderOf(site);
|
|
460
|
+
if (order) {
|
|
461
|
+
for (const u of unknownOrderEntries(order, orderedPaths(site, entries))) {
|
|
462
|
+
const where = u.key === '' ? 'the top level' : `/${u.key}`;
|
|
463
|
+
report.warnings.push({
|
|
464
|
+
file: SITE_SETTINGS_FILE,
|
|
465
|
+
path: `pageOrder[${JSON.stringify(u.key)}]`,
|
|
466
|
+
message: u.slug === null ? `The Pages tree's order names ${where}, where there are no pages. \`caracms validate --fix\` prunes it.` : `The Pages tree's order names "${u.slug}" in ${where}, where there's no page or group by that name. \`caracms validate --fix\` prunes it.`,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
453
471
|
for (const change of schemaFileChanges(site)) {
|
|
454
472
|
report.warnings.push({
|
|
455
473
|
file: change.file,
|
|
@@ -460,6 +478,38 @@ export async function validateSite(site: Site, codeRoutes: readonly CodeRoute[]
|
|
|
460
478
|
return report;
|
|
461
479
|
}
|
|
462
480
|
|
|
481
|
+
/** The Pages collection's paths (page-order.ts → isOrderedCollection). */
|
|
482
|
+
const orderedPaths = (site: Pick<ResolvedSite, 'collections'>, entries: readonly DocumentEntry[]) =>
|
|
483
|
+
entries.filter((e) => isOrderedCollection(site.collections[e.collection]!)).map((e) => e.path);
|
|
484
|
+
|
|
485
|
+
/** Site Settings' `pageOrder` as the file has it, or null (no file, no key, or it doesn't parse). */
|
|
486
|
+
function savedOrderOf(site: Pick<ResolvedSite, 'root'>): Record<string, string[]> | null {
|
|
487
|
+
try {
|
|
488
|
+
const raw = JSON.parse(readFileSync(join(site.root, SITE_SETTINGS_FILE), 'utf8')) as unknown;
|
|
489
|
+
const order = isObject(raw) ? raw.pageOrder : undefined;
|
|
490
|
+
if (!isObject(order) || !Object.values(order).every(strings)) return null;
|
|
491
|
+
return order as Record<string, string[]>;
|
|
492
|
+
} catch {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Arrange pages (Decision 6): `--fix` takes the saved order's entries that name nothing out of Site Settings. Returns how many went. */
|
|
498
|
+
export async function prunePageOrder(site: Pick<ResolvedSite, 'root' | 'collections' | 'blocks'>): Promise<number> {
|
|
499
|
+
const order = savedOrderOf(site);
|
|
500
|
+
const settings = Object.values(site.collections).find((c) => c.kind === 'settings');
|
|
501
|
+
if (!order || !settings) return 0;
|
|
502
|
+
const { order: kept, pruned } = prunedOrder(order, orderedPaths(site, await listDocuments(site)));
|
|
503
|
+
if (pruned === 0) return 0;
|
|
504
|
+
const file = join(site.root, SITE_SETTINGS_FILE);
|
|
505
|
+
const text = readFileSync(file, 'utf8');
|
|
506
|
+
const raw = JSON.parse(text) as Record<string, unknown>;
|
|
507
|
+
writeFileSync(file, applyKeyedEdits(text, { title: typeof raw.title === 'string' ? raw.title : '', data: { pageOrder: kept } }, settings));
|
|
508
|
+
return pruned;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const pruneReport = (n: number) => (n ? `caracms validate --fix:\n pruned ${n} page-order ${n === 1 ? 'entry' : 'entries'} (${SITE_SETTINGS_FILE})\n` : '');
|
|
512
|
+
|
|
463
513
|
const REFORMAT_MESSAGE = "This file isn't laid out the way the editor saves it (two-space JSON, a trailing newline), so the editor's next save would reformat it and mix the two. `caracms validate --fix` rewrites it the editor's way, changing no values.";
|
|
464
514
|
|
|
465
515
|
/**
|
|
@@ -504,7 +554,7 @@ export function formatReport(report: Report): string {
|
|
|
504
554
|
export async function command(root: string, options: { fix?: boolean; json?: boolean } = {}): Promise<{ code: number; out: string; err?: string }> {
|
|
505
555
|
const site = await loadSite(root);
|
|
506
556
|
let fixed = '';
|
|
507
|
-
if (options.fix) fixed = schemasReport(await runSchemas(site)) + reformatReport(await reformatContent(site));
|
|
557
|
+
if (options.fix) fixed = schemasReport(await runSchemas(site)) + reformatReport(await reformatContent(site)) + pruneReport(await prunePageOrder(site));
|
|
508
558
|
const report = await validateSite(site);
|
|
509
559
|
const code = report.errors.length ? 1 : 0;
|
|
510
560
|
if (options.json) return { code, out: `${JSON.stringify(report, null, 2)}\n`, err: options.fix ? fixed : undefined };
|
package/src/content-schema.ts
CHANGED
|
@@ -150,6 +150,7 @@ export function collectionSchema(site: Pick<ResolvedSite, 'blocks'> & Partial<Pi
|
|
|
150
150
|
googleTag: string,
|
|
151
151
|
formRecipients: { type: 'object', additionalProperties: strings },
|
|
152
152
|
redirects: { type: 'array', items: closed({ from: string, to: string }, ['from', 'to']) },
|
|
153
|
+
pageOrder: { type: 'object', additionalProperties: strings, description: "The Pages tree's order in the editor: a parent's path (\"\" for the top level) → its children's slugs. Written by Arrange pages." },
|
|
153
154
|
...own,
|
|
154
155
|
},
|
|
155
156
|
['title'],
|
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
|
|
@@ -39,6 +41,8 @@ import { parseSource } from './json-source.ts';
|
|
|
39
41
|
import { hasHeld, heldBases, type HeldBase } from './held.ts';
|
|
40
42
|
import { leftBehindMessage, leftBehindRoutes, ownRoutedCollections } from './code-routes.ts';
|
|
41
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';
|
|
42
46
|
|
|
43
47
|
// Posts and tags, Decision 14: a route's reader on the disk (read.ts).
|
|
44
48
|
export { contentReader } from './read.ts';
|
|
@@ -82,6 +86,11 @@ export interface DocumentEntry {
|
|
|
82
86
|
*/
|
|
83
87
|
hidden?: true;
|
|
84
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;
|
|
85
94
|
/**
|
|
86
95
|
* Collections under pages (Decision 11): set when `hiddenBy` is a page of another collection,
|
|
87
96
|
* the page that holds this document's collection (`pages`). Absent: `hiddenBy` is in this one.
|
|
@@ -251,7 +260,9 @@ export async function listDocuments(
|
|
|
251
260
|
const paths = await documentPaths(collection, source);
|
|
252
261
|
const entries = (await Promise.all(paths.map((path) => entryFor(site, collection, path, source)))).filter((e) => e !== null);
|
|
253
262
|
if (collection.nested) hideChildren(entries);
|
|
254
|
-
|
|
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)));
|
|
255
266
|
}
|
|
256
267
|
// Collections under pages (Decision 7): a held collection's addresses come from its holder's listing.
|
|
257
268
|
const held = names.map((n) => site.collections[n]!).filter((c) => c.heldBy);
|
|
@@ -276,6 +287,21 @@ export async function listDocuments(
|
|
|
276
287
|
return all;
|
|
277
288
|
}
|
|
278
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
|
+
|
|
279
305
|
/** A held document's address and hidden mark from its collection's resolved base (held.ts). */
|
|
280
306
|
function placeHeld(entry: DocumentEntry, held: HeldBase): void {
|
|
281
307
|
entry.url = held.base === null ? null : held.base + entry.path;
|
|
@@ -409,7 +435,7 @@ function inspect(
|
|
|
409
435
|
}
|
|
410
436
|
const entry: DocumentEntry = { ...base, title, label: title, warning: null };
|
|
411
437
|
// M9 Decision 7: the home page is never hidden.
|
|
412
|
-
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;
|
|
413
439
|
if ((document.kind === undefined || document.kind === 'sections') && document.holds !== undefined) entry.holds = document.holds;
|
|
414
440
|
if (document.kind === undefined || document.kind === 'sections') {
|
|
415
441
|
entry.uses = [...new Set(document.blocks.flatMap((b) => (b.shared !== undefined ? [b.shared] : [])))];
|
|
@@ -450,10 +476,11 @@ export type PageTreeNode =
|
|
|
450
476
|
/**
|
|
451
477
|
* A nested collection's entries as a tree that mirrors their paths. A page with
|
|
452
478
|
* pages under it (`about.json` beside `about/`) is one node with children; a folder
|
|
453
|
-
* with no page is a group. Each level is ordered
|
|
454
|
-
*
|
|
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.
|
|
455
482
|
*/
|
|
456
|
-
export function pageTree(entries: readonly DocumentEntry[]): PageTreeNode[] {
|
|
483
|
+
export function pageTree(entries: readonly DocumentEntry[], order: PageOrder = {}): PageTreeNode[] {
|
|
457
484
|
interface Draft {
|
|
458
485
|
path: string;
|
|
459
486
|
entry?: DocumentEntry;
|
|
@@ -475,17 +502,15 @@ export function pageTree(entries: readonly DocumentEntry[]): PageTreeNode[] {
|
|
|
475
502
|
node.entry = entry;
|
|
476
503
|
}
|
|
477
504
|
const build = (draft: Draft): PageTreeNode[] =>
|
|
478
|
-
|
|
479
|
-
.map((d): PageTreeNode => {
|
|
505
|
+
orderSiblings(
|
|
506
|
+
[...draft.children.values()].map((d): PageTreeNode => {
|
|
480
507
|
const children = build(d);
|
|
481
508
|
if (d.entry) return { kind: 'page', path: d.path, label: d.entry.label, entry: d.entry, children };
|
|
482
509
|
return { kind: 'group', path: d.path, label: titleCase(d.path.split('/').pop()!), children };
|
|
483
|
-
})
|
|
484
|
-
.
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
return byLabel(a, b);
|
|
488
|
-
});
|
|
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
|
+
);
|
|
489
514
|
return build(top);
|
|
490
515
|
}
|
|
491
516
|
|
|
@@ -493,10 +518,6 @@ function flattenTree(nodes: PageTreeNode[]): DocumentEntry[] {
|
|
|
493
518
|
return nodes.flatMap((n) => [...(n.kind === 'page' ? [n.entry] : []), ...flattenTree(n.children)]);
|
|
494
519
|
}
|
|
495
520
|
|
|
496
|
-
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true });
|
|
497
|
-
function byLabel(a: { label: string; path: string }, b: { label: string; path: string }): number {
|
|
498
|
-
return collator.compare(a.label, b.label) || collator.compare(a.path, b.path);
|
|
499
|
-
}
|
|
500
521
|
|
|
501
522
|
// ── The catch-all's reader (collections under pages, Decision 14) ────────────────
|
|
502
523
|
|
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 {
|
|
@@ -128,14 +129,16 @@ export interface SettingsBrand {
|
|
|
128
129
|
export interface SettingsDocument {
|
|
129
130
|
kind: 'settings';
|
|
130
131
|
title: string;
|
|
131
|
-
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 };
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
/** M6: Site Settings' search and sharing keys, parsed, each only when `raw` has it. */
|
|
135
|
-
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 } {
|
|
136
137
|
return {
|
|
137
138
|
// Pre-M7 item 8: the redirects changing an address adds (redirects.ts).
|
|
138
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) } : {}),
|
|
139
142
|
...(Object.hasOwn(raw, 'seo') ? { seo: parseSeo(raw.seo) } : {}),
|
|
140
143
|
...(Object.hasOwn(raw, 'googleTag') ? { googleTag: parseGoogleTag(raw.googleTag) } : {}),
|
|
141
144
|
...(Object.hasOwn(raw, 'formRecipients') ? { formRecipients: parseFormRecipients(raw.formRecipients) } : {}),
|
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;
|